diff --git a/README.md b/README.md index 0a9e735..9805261 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ because the API reads the information directly from the application itself. #### Supported operating systems: - Windows - macOS +- Linux distros that uses systemd ## Gradle Setup ```groovy diff --git a/build.gradle b/build.gradle index 2ca5340..7c08af5 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group 'de.labystudio' -version '1.1.17' +version '1.2.0' compileJava { sourceCompatibility = '1.8' diff --git a/src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java b/src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java index 8d455d1..1835f2d 100644 --- a/src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java +++ b/src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java @@ -1,6 +1,7 @@ package de.labystudio.spotifyapi; import de.labystudio.spotifyapi.config.SpotifyConfiguration; +import de.labystudio.spotifyapi.platform.linux.LinuxSpotifyApi; import de.labystudio.spotifyapi.platform.osx.OSXSpotifyApi; import de.labystudio.spotifyapi.platform.windows.WinSpotifyAPI; @@ -16,7 +17,7 @@ public class SpotifyAPIFactory { /** * Creates a new SpotifyAPI instance for the current platform. - * Currently, only Windows and OSX are supported. + * Currently, only Windows, OSX and Linux are supported. * * @return A new SpotifyAPI instance. * @throws IllegalStateException if the current platform is not supported. @@ -30,6 +31,9 @@ public static SpotifyAPI create() { if (os.contains("mac")) { return new OSXSpotifyApi(); } + if (os.contains("linux")) { + return new LinuxSpotifyApi(); + } throw new IllegalStateException("Unsupported OS: " + os); } diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/LinuxSpotifyApi.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/LinuxSpotifyApi.java new file mode 100644 index 0000000..2192fd0 --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/LinuxSpotifyApi.java @@ -0,0 +1,153 @@ +package de.labystudio.spotifyapi.platform.linux; + +import de.labystudio.spotifyapi.SpotifyListener; +import de.labystudio.spotifyapi.model.MediaKey; +import de.labystudio.spotifyapi.model.Track; +import de.labystudio.spotifyapi.platform.AbstractTickSpotifyAPI; +import de.labystudio.spotifyapi.platform.linux.api.MPRISCommunicator; + +import java.util.Objects; + +/** + * Linux implementation of the SpotifyAPI. + * It uses the MPRIS to access the Spotify's media control and metadata. + * + * @author holybaechu, LabyStudio + * Thanks for LabyStudio for many code snippets. + */ +public class LinuxSpotifyApi extends AbstractTickSpotifyAPI { + + private boolean connected = false; + + private Track currentTrack; + private int currentPosition = -1; + private boolean isPlaying; + + private long lastTimePositionUpdated; + + private final MPRISCommunicator mediaPlayer = new MPRISCommunicator(); + + @Override + protected void onTick() throws Exception { + String trackId = this.mediaPlayer.getTrackId(); + + // Handle on connect + if (!this.connected && !trackId.isEmpty()) { + this.connected = true; + this.listeners.forEach(SpotifyListener::onConnect); + } + + // Handle track changes + if (!Objects.equals(trackId, this.currentTrack == null ? null : this.currentTrack.getId())) { + String trackName = this.mediaPlayer.getTrackName(); + String trackArtist = this.mediaPlayer.getArtist(); + int trackLength = this.mediaPlayer.getTrackLength(); + + boolean isFirstTrack = !this.hasTrack(); + + Track track = new Track(trackId, trackName, trackArtist, trackLength); + this.currentTrack = track; + + // Fire on track changed + this.listeners.forEach(listener -> listener.onTrackChanged(track)); + + // Reset position on song change + if (!isFirstTrack) { + this.updatePosition(0); + } + } + + // Handle is playing changes + boolean isPlaying = this.mediaPlayer.isPlaying(); + if (isPlaying != this.isPlaying) { + this.isPlaying = isPlaying; + + // Fire on play back changed + this.listeners.forEach(listener -> listener.onPlayBackChanged(isPlaying)); + } + + // Handle position changes + int position = this.mediaPlayer.getPosition(); + if (!this.hasPosition() || Math.abs(position - this.getPosition()) >= 1000) { + this.updatePosition(position); + } + + // Fire keep alive + this.listeners.forEach(SpotifyListener::onSync); + } + + @Override + public void stop() { + super.stop(); + this.connected = false; + } + + private void updatePosition(int position) { + if (position == this.currentPosition) { + return; + } + + // Update position known state + this.currentPosition = position; + this.lastTimePositionUpdated = System.currentTimeMillis(); + + // Fire on position changed + this.listeners.forEach(listener -> listener.onPositionChanged(position)); + } + + @Override + public void pressMediaKey(MediaKey mediaKey) { + try { + switch (mediaKey) { + case PLAY_PAUSE: + this.mediaPlayer.playPause(); + break; + case NEXT: + this.mediaPlayer.next(); + break; + case PREV: + this.mediaPlayer.previous(); + break; + } + } catch (Exception e) { + this.listeners.forEach(listener -> listener.onDisconnect(e)); + this.connected = false; + } + } + + @Override + public int getPosition() { + if (!this.hasPosition()) { + throw new IllegalStateException("Position is not known yet"); + } + + if (this.isPlaying) { + // Interpolate position + long timePassed = System.currentTimeMillis() - this.lastTimePositionUpdated; + return this.currentPosition + (int) timePassed; + } else { + return this.currentPosition; + } + } + + @Override + public Track getTrack() { + return this.currentTrack; + } + + @Override + public boolean isPlaying() { + return this.isPlaying; + } + + @Override + public boolean isConnected() { + return this.connected; + } + + @Override + public boolean hasPosition() { + return this.currentPosition != -1; + } + +} diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/api/DBusSend.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/DBusSend.java new file mode 100644 index 0000000..747adc6 --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/DBusSend.java @@ -0,0 +1,114 @@ +package de.labystudio.spotifyapi.platform.linux.api; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +/** + * Java wrapper for the dbus-send application + *

+ * The dbus-send command is used to send a message to a D-Bus message bus. + * There are two well-known message buses: + * - the systemwide message bus (installed on many systems as the "messagebus" service) + * - the per-user-login-session message bus (started each time a user logs in). + *

+ * The "system" parameter and "session" parameter options direct dbus-send to send messages to the system or session buses respectively. + * If neither is specified, dbus-send sends to the session bus. + *

+ * Nearly all uses of dbus-send must provide the "dest" parameter which is the name of + * a connection on the bus to send the message to. If the "dest" parameter is omitted, no destination is set. + *

+ * The object path and the name of the message to send must always be specified. + * Following arguments, if any, are the message contents (message arguments). + * These are given as type-specified values and may include containers (arrays, dicts, and variants). + * + * @author LabyStudio + */ +public class DBusSend { + + private static final Parameter PARAM_PRINT_REPLY = new Parameter("print-reply"); + private static final InterfaceMember INTERFACE_GET = new InterfaceMember("org.freedesktop.DBus.Properties.Get"); + + private final Parameter[] parameters; + private final String objectPath; + private final Runtime runtime; + + /** + * Creates a new DBusSend API for a specific application + * + * @param parameters The parameters to use + * @param objectPath The object path to use + */ + public DBusSend(Parameter[] parameters, String objectPath) { + this.parameters = parameters; + this.objectPath = objectPath; + this.runtime = Runtime.getRuntime(); + } + + /** + * Request an information from the application + * + * @param keys The requested type of information + * @return The requested information + * @throws Exception If the request failed + */ + public Variant get(String... keys) throws Exception { + String[] contents = new String[keys.length]; + for (int i = 0; i < keys.length; i++) { + contents[i] = String.format("string:%s", keys[i]); + } + return this.send(INTERFACE_GET, contents); + } + + /** + * Execute an DBusSend command. + * + * @param interfaceMember The interface member to execute + * @param contents The contents to send + * @return The result of the command + * @throws Exception If the command failed + */ + public Variant send(InterfaceMember interfaceMember, String... contents) throws Exception { + // Build arguments + String[] arguments = new String[2 + this.parameters.length + 2 + contents.length]; + arguments[0] = "dbus-send"; + arguments[1] = PARAM_PRINT_REPLY.toString(); + for (int i = 0; i < this.parameters.length; i++) { + arguments[2 + i] = this.parameters[i].toString(); + } + arguments[2 + this.parameters.length] = this.objectPath; + arguments[2 + this.parameters.length + 1] = interfaceMember.toString(); + for (int i = 0; i < contents.length; i++) { + arguments[2 + this.parameters.length + 2 + i] = contents[i]; + } + + // Execute dbus-send process + Process process = this.runtime.exec(arguments); + int exitCode = process.waitFor(); + if (exitCode == 0) { + // Read response + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + StringBuilder builder = new StringBuilder(); + String response; + while ((response = reader.readLine()) != null) { + if (response.startsWith("method ")) { + continue; + } + builder.append(response).append("\n"); + } + if (builder.toString().isEmpty()) { + return new Variant("success", true); + } + return Variant.parse(builder.toString()); + } else { + // Handle error message + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); + String line; + StringBuilder builder = new StringBuilder(); + while ((line = reader.readLine()) != null) { + builder.append(line); + } + throw new Exception("dbus-send execution \"" + String.join(" ", arguments) + "\" failed with exit code " + exitCode + ": " + builder); + } + } + +} diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/api/InterfaceMember.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/InterfaceMember.java new file mode 100644 index 0000000..206a0e0 --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/InterfaceMember.java @@ -0,0 +1,20 @@ +package de.labystudio.spotifyapi.platform.linux.api; + +/** + * Interface member wrapper for the DBusSend class. + * + * @author LabyStudio + */ +public class InterfaceMember { + + private final String path; + + public InterfaceMember(String path) { + this.path = path; + } + + public String toString() { + return this.path; + } + +} diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/api/MPRISCommunicator.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/MPRISCommunicator.java new file mode 100644 index 0000000..917d5e7 --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/MPRISCommunicator.java @@ -0,0 +1,79 @@ +package de.labystudio.spotifyapi.platform.linux.api; + +import java.util.HashMap; +import java.util.Map; + +/** + * MPRIS communicator + *

+ * This class is used to communicate with the MPRIS interface. + * It can be used to get the current track, track position and to control the playback. + * + * @author holybaechu, LabyStudio + */ +public class MPRISCommunicator { + + private static final Parameter PARAM_DEST = new Parameter("dest", "org.mpris.MediaPlayer2.spotify"); + + private static final InterfaceMember INTERFACE_PLAY_PAUSE = new InterfaceMember("org.mpris.MediaPlayer2.Player.PlayPause"); + private static final InterfaceMember INTERFACE_NEXT = new InterfaceMember("org.mpris.MediaPlayer2.Player.Next"); + private static final InterfaceMember INTERFACE_PREVIOUS = new InterfaceMember("org.mpris.MediaPlayer2.Player.Previous"); + + private final DBusSend dbus = new DBusSend( + new Parameter[]{ + PARAM_DEST + }, + "/org/mpris/MediaPlayer2" + ); + + private final Map metadata = new HashMap<>(); + + private void updateMetadata() throws Exception { + this.metadata.clear(); + + Variant array = this.dbus.get("org.mpris.MediaPlayer2.Player", "Metadata"); + for (Variant entry : array.getValue()) { + this.metadata.put(entry.getSig(), entry.getValue()); + } + } + + public String getTrackId() throws Exception { + this.updateMetadata(); + return ((String) this.metadata.get("mpris:trackid")).split("/")[4]; + } + + public String getTrackName() throws Exception { + this.updateMetadata(); + return this.metadata.get("xesam:title").toString(); + } + + public String getArtist() throws Exception { + this.updateMetadata(); + return String.join(", ", (String[]) this.metadata.get("xesam:artist")); + } + + public Integer getTrackLength() throws Exception { + this.updateMetadata(); + return (int) ((Long) this.metadata.get("mpris:length") / 1000L) + 1; + } + + public boolean isPlaying() throws Exception { + return this.dbus.get("org.mpris.MediaPlayer2.Player", "PlaybackStatus").getValue().equals("Playing"); + } + + public Integer getPosition() throws Exception { + return (int) ((Long) this.dbus.get("org.mpris.MediaPlayer2.Player", "Position").getValue() / 1000L); + } + + public void playPause() throws Exception { + this.dbus.send(INTERFACE_PLAY_PAUSE); + } + + public void next() throws Exception { + this.dbus.send(INTERFACE_NEXT); + } + + public void previous() throws Exception { + this.dbus.send(INTERFACE_PREVIOUS); + } +} diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Parameter.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Parameter.java new file mode 100644 index 0000000..2097e0f --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Parameter.java @@ -0,0 +1,34 @@ +package de.labystudio.spotifyapi.platform.linux.api; + +/** + * Parameter wrapper for the DBusSend class. + *

+ * It appends the parameter key and value to the command. + * If the value is null, it will only append the key using "--key". + * If the value is not null, it will append the key and value using "--key=value". + * + * @author LabyStudio + */ +public class Parameter { + + private final String key; + private final String value; + + public Parameter(String key, String value) { + this.key = key; + this.value = value; + } + + public Parameter(String key) { + this(key, null); + } + + public String toString() { + return String.format( + "--%s%s", + this.key, + this.value == null ? "" : "=" + this.value + ); + } + +} diff --git a/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Variant.java b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Variant.java new file mode 100644 index 0000000..7cf1108 --- /dev/null +++ b/src/main/java/de/labystudio/spotifyapi/platform/linux/api/Variant.java @@ -0,0 +1,225 @@ +package de.labystudio.spotifyapi.platform.linux.api; + +import java.util.ArrayList; +import java.util.List; + +/** + * DBus variant parser + *

+ * This class is used to parse DBus variant responses. + * It stores the data in key=value pairs. + * A value can be a primitive or another variant to represent nested data. + * + * @author LabyStudio + */ +public class Variant { + + private final String sig; + private final Object value; + + public Variant(String sig, Object value) { + this.sig = sig; + this.value = value; + } + + /** + * Get the key of the variant + * If the variant key is not given, the key will be "variant" + * + * @return Key of the variant + */ + public String getSig() { + return this.sig; + } + + /** + * Get the value of the variant + * The value can be a primitive or another variant to represent nested data. + *

+ * If the value is a primitive, it can be a String, Integer, Long, Double or Boolean. + * If the value is a variant, it can be a String[], Integer[], Long[], Double[], Boolean[] or Variant[]. + * + * @param Expected type + * @return Value of the variant + */ + @SuppressWarnings("unchecked") + public T getValue() { + return (T) this.value; + } + + @Override + public String toString() { + return this.sig + ":" + this.value; + } + + public static Variant parse(String raw) { + // Cleanup + raw = raw.trim().replace("\n", ""); + while (raw.contains(" ")) { + raw = raw.replace(" ", " "); + } + return new Variant("variant", parse0(raw)); + } + + private static Object parse0(String raw) { + String[] segments = raw.split(" ", 2); + if (segments.length != 2) { + throw new IllegalArgumentException("Invalid variant: " + raw); + } + + String signature = segments[0]; + String payload = segments[1]; + + if (signature.startsWith("variant")) { + String[] variantSegments = payload.split(" ", 2); + return parseVariant(variantSegments[0], variantSegments[1]); + } else if (signature.startsWith("dict")) { + return parseDict(payload); + } else { + throw new IllegalArgumentException("Invalid variant signature: " + signature); + } + } + + @SuppressWarnings("SuspiciousToArrayCall") + private static Object parseVariant(String type, String value) { + switch (type) { + case "array": { + String collection = value.substring(1, value.length() - 1).trim(); + + List list = new ArrayList<>(); + StringBuilder buffer = new StringBuilder(); + boolean nested = false; + boolean escaped = false; + boolean primitive = false; + String tempType = null; + + for (int i = 0; i < collection.length(); i++) { + char c = collection.charAt(i); + + if (c == '"') { + escaped = !escaped; + } + if (!escaped) { + if (c == '(') { + nested = true; + } + if (c == ')') { + nested = false; + list.add(parseDict(buffer + ")")); + buffer = new StringBuilder(); + continue; + } + if (!nested && c == ' ' && buffer.length() > 0) { + String keyword = buffer.toString().trim(); + buffer = new StringBuilder(); + + if (tempType == null) { + if (!keyword.equals("dict")) { + tempType = keyword; + } + } else { + Object variant = parseVariant(tempType, keyword); + if (!(variant instanceof Variant)) { + primitive = true; + } + list.add(variant); + tempType = null; + } + } + } + buffer.append(c); + } + + if (tempType != null) { + String keyword = buffer.toString().trim(); + Object variant = parseVariant(tempType, keyword); + if (!(variant instanceof Variant)) { + primitive = true; + } + list.add(variant); + } + + if (primitive) { + if (list.get(0) instanceof String) { + return list.toArray(new String[0]); + } + return list.toArray(); + } + + return list.toArray(new Variant[0]); + } + case "string": { + return value.substring(1, value.length() - 1); + } + case "int32": { + return Integer.parseInt(value); + } + case "uint32": { + return Integer.parseUnsignedInt(value); + } + case "int64": { + return Long.parseLong(value); + } + case "uint64": { + return Long.parseUnsignedLong(value); + } + case "double": { + return Double.parseDouble(value); + } + default: { + return value; + } + } + } + + private static Variant parseDict(String payload) { + String sigType = null; + String sig = null; + + StringBuilder buffer = new StringBuilder(); + boolean nested = false; + boolean escaped = false; + + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + + if (c == '"') { + escaped = !escaped; + } + if (!escaped) { + if (c == '(') { + nested = true; + continue; + } + if (c == ')') { + nested = false; + continue; + } + if (nested && c == ' ') { + if (buffer.length() == 0) { + continue; + } + + if (sigType == null) { + sigType = buffer.toString(); + buffer = new StringBuilder(); + + if (!sigType.equals("string")) { + throw new IllegalArgumentException("Invalid dict sig type: " + sigType); + } + } else if (sig == null) { + sig = (String) Variant.parseVariant(sigType, buffer.toString().trim()); + buffer = new StringBuilder(); + } + } + } + + if (nested) { + buffer.append(c); + } + } + return new Variant(sig, parse0(buffer.toString().trim())); + } + + +} diff --git a/src/test/java/SpotifyDBusParserTest.java b/src/test/java/SpotifyDBusParserTest.java new file mode 100644 index 0000000..76380cd --- /dev/null +++ b/src/test/java/SpotifyDBusParserTest.java @@ -0,0 +1,73 @@ +import de.labystudio.spotifyapi.platform.linux.api.Variant; + +public class SpotifyDBusParserTest { + + public static void main(String[] args) { + String metadata = " variant array [\n" + + " dict entry(\n" + + " string \"mpris:trackid\"\n" + + " variant string \"/com/spotify/track/0r1kH7SIkkPP9W7mUknObF\"\n" + + " )\n" + + " dict entry(\n" + + " string \"mpris:length\"\n" + + " variant uint64 172000000\n" + + " )\n" + + " dict entry(\n" + + " string \"mpris:artUrl\"\n" + + " variant string \"https://i.scdn.co/image/ab67616d0000b27397c097afa44e5cdb38a03d4f\"\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:album\"\n" + + " variant string \"Raop\"\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:albumArtist\"\n" + + " variant array [\n" + + " string \"CRO\"\n" + + " ]\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:artist\"\n" + + " variant array [\n" + + " string \"CRO\"\n" + + " ]\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:autoRating\"\n" + + " variant double 0.01\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:discNumber\"\n" + + " variant int32 1\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:title\"\n" + + " variant string \"Easy\"\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:trackNumber\"\n" + + " variant int32 3\n" + + " )\n" + + " dict entry(\n" + + " string \"xesam:url\"\n" + + " variant string \"https://open.spotify.com/track/0r1kH7SIkkPP9W7mUknObF\"\n" + + " )\n" + + " ]"; + + String playing = " variant string \"Playing\""; + + Variant response = Variant.parse(metadata); + for (Variant entry : response.getValue()) { + System.out.println(entry.getSig() + "|" + entry.getValue()); + } + + Variant response2 = Variant.parse(playing); + if (!response2.getSig().equals("variant")) { + throw new IllegalStateException("Invalid sig key: " + response2.getSig()); + } + if (!response2.getValue().equals("Playing")) { + throw new IllegalStateException("Invalid value: " + response2.getValue()); + } + } + +} diff --git a/src/test/java/SpotifyListenerTest.java b/src/test/java/SpotifyListenerTest.java index be1e311..d4066a0 100644 --- a/src/test/java/SpotifyListenerTest.java +++ b/src/test/java/SpotifyListenerTest.java @@ -56,7 +56,7 @@ public void onPlayBackChanged(boolean isPlaying) { @Override public void onSync() { - + // System.out.println(formatDuration(api.getPosition())); } @Override diff --git a/src/test/java/SpotifyPlayPauseTest.java b/src/test/java/SpotifyPlayPauseTest.java new file mode 100644 index 0000000..a0a6e0a --- /dev/null +++ b/src/test/java/SpotifyPlayPauseTest.java @@ -0,0 +1,82 @@ + +import de.labystudio.spotifyapi.SpotifyAPI; +import de.labystudio.spotifyapi.SpotifyAPIFactory; +import de.labystudio.spotifyapi.SpotifyListener; +import de.labystudio.spotifyapi.model.MediaKey; +import de.labystudio.spotifyapi.model.Track; +import de.labystudio.spotifyapi.open.OpenSpotifyAPI; + +import java.awt.image.BufferedImage; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +public class SpotifyPlayPauseTest { + + public static void main(String[] args) { + SpotifyAPI api = SpotifyAPIFactory.create(); + OpenSpotifyAPI openSpotifyAPI = api.getOpenAPI(); + api.registerListener(new SpotifyListener() { + @Override + public void onConnect() { + System.out.println("Connected to Spotify!"); + } + + @Override + public void onTrackChanged(Track track) { + System.out.printf("Track changed: %s (%s)\n", track, formatDuration(track.getLength())); + + try { + BufferedImage imageTrackCover = openSpotifyAPI.requestImage(track); + System.out.println("Loaded track cover: " + imageTrackCover.getWidth() + "x" + imageTrackCover.getHeight()); + } catch (Exception e) { + System.out.println("Could not load track cover: " + e.getMessage()); + } + } + + @Override + public void onPositionChanged(int position) { + if (!api.hasTrack()) { + return; + } + + int length = api.getTrack().getLength(); + float percentage = 100.0F / length * position; + + System.out.printf( + "Position changed: %s of %s (%d%%)\n", + formatDuration(position), + formatDuration(length), + (int) percentage + ); + + System.out.println("Triggered Play/Pause Media key"); + api.pressMediaKey(MediaKey.PLAY_PAUSE); + } + + @Override + public void onPlayBackChanged(boolean isPlaying) { + System.out.println(isPlaying ? "Song started playing" : "Song stopped playing"); + } + + @Override + public void onSync() { + + } + + @Override + public void onDisconnect(Exception exception) { + System.out.println("Disconnected: " + exception.getMessage()); + + // api.stop(); + } + }); + + // Initialize the API + api.initialize(); + } + + private static String formatDuration(long ms) { + Duration duration = Duration.ofMillis(ms); + return String.format("%sm %ss", duration.toMinutes(), duration.getSeconds() - TimeUnit.MINUTES.toSeconds(duration.toMinutes())); + } +}