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
+ * 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