Skip to content

Commit

Permalink
Add Linux implementation (#10)
Browse files Browse the repository at this point in the history
* Add Linux implementation

* Fix wrong TrackID

* Fix track position and length

* Fix track position

* Fix position update not registering

* Fix position not syncing with actual Spotify client

* Use dbus-send instead of playerctl

* Fix README.md, Fix some comment

* Follow Java naming conventions, Optimize code

* Fix typo in baseCommand

* Fix typo in baseCommand, Fix some warnings

* Fix spacing in code

* Fix spacing in code (again)

* Wrap D-Bus communication into class (to MPRISCommunicator)

* improve dbus & mpris api (wip)

* implement variant parser, fix playback parsing on linux

* Fix track position not updating frequently

* Fix position, Fix position updating while not playing

* Revert "Fix position updating while not playing"

* Fix handling position changes not working as intended

* Fix disconnecting when Play/Pausing

* Rename SpotifyActionTest.java to SpotifyPlayPauseTest.java

* Rename SpotifyActionTest class to SpotifyPlayPauseTest

* use this.getPosition() because we want to fire an positionChanged event if the media player interrupts or changes its current "direction".
added a getPosition output line at onSync in SpotifyListenerTest to debug its current calculated position

* version 1.2.0

---------

Co-authored-by: LabyStudio <labystudio@gmail.com>
  • Loading branch information
Bae Joon Hoo and LabyStudio committed Jan 9, 2024
1 parent c93d6ea commit 941cfa2
Show file tree
Hide file tree
Showing 12 changed files with 788 additions and 3 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Expand Up @@ -4,7 +4,7 @@ plugins {
}

group 'de.labystudio'
version '1.1.17'
version '1.2.0'

compileJava {
sourceCompatibility = '1.8'
Expand Down
@@ -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;

Expand All @@ -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.
Expand All @@ -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);
}
Expand Down
@@ -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;
}

}
@@ -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
* <p>
* 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).
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);
}
}

}
@@ -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;
}

}

0 comments on commit 941cfa2

Please sign in to comment.