Skip to content

Commit 941cfa2

Browse files
Bae Joon HooLabyStudio
andauthored
Add Linux implementation (#10)
* 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 <[email protected]>
1 parent c93d6ea commit 941cfa2

File tree

12 files changed

+788
-3
lines changed

12 files changed

+788
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ because the API reads the information directly from the application itself.
1515
#### Supported operating systems:
1616
- Windows
1717
- macOS
18+
- Linux distros that uses systemd
1819

1920
## Gradle Setup
2021
```groovy

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
}
55

66
group 'de.labystudio'
7-
version '1.1.17'
7+
version '1.2.0'
88

99
compileJava {
1010
sourceCompatibility = '1.8'

src/main/java/de/labystudio/spotifyapi/SpotifyAPIFactory.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package de.labystudio.spotifyapi;
22

33
import de.labystudio.spotifyapi.config.SpotifyConfiguration;
4+
import de.labystudio.spotifyapi.platform.linux.LinuxSpotifyApi;
45
import de.labystudio.spotifyapi.platform.osx.OSXSpotifyApi;
56
import de.labystudio.spotifyapi.platform.windows.WinSpotifyAPI;
67

@@ -16,7 +17,7 @@ public class SpotifyAPIFactory {
1617

1718
/**
1819
* Creates a new SpotifyAPI instance for the current platform.
19-
* Currently, only Windows and OSX are supported.
20+
* Currently, only Windows, OSX and Linux are supported.
2021
*
2122
* @return A new SpotifyAPI instance.
2223
* @throws IllegalStateException if the current platform is not supported.
@@ -30,6 +31,9 @@ public static SpotifyAPI create() {
3031
if (os.contains("mac")) {
3132
return new OSXSpotifyApi();
3233
}
34+
if (os.contains("linux")) {
35+
return new LinuxSpotifyApi();
36+
}
3337

3438
throw new IllegalStateException("Unsupported OS: " + os);
3539
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package de.labystudio.spotifyapi.platform.linux;
2+
3+
import de.labystudio.spotifyapi.SpotifyListener;
4+
import de.labystudio.spotifyapi.model.MediaKey;
5+
import de.labystudio.spotifyapi.model.Track;
6+
import de.labystudio.spotifyapi.platform.AbstractTickSpotifyAPI;
7+
import de.labystudio.spotifyapi.platform.linux.api.MPRISCommunicator;
8+
9+
import java.util.Objects;
10+
11+
/**
12+
* Linux implementation of the SpotifyAPI.
13+
* It uses the MPRIS to access the Spotify's media control and metadata.
14+
*
15+
* @author holybaechu, LabyStudio
16+
* Thanks for LabyStudio for many code snippets.
17+
*/
18+
public class LinuxSpotifyApi extends AbstractTickSpotifyAPI {
19+
20+
private boolean connected = false;
21+
22+
private Track currentTrack;
23+
private int currentPosition = -1;
24+
private boolean isPlaying;
25+
26+
private long lastTimePositionUpdated;
27+
28+
private final MPRISCommunicator mediaPlayer = new MPRISCommunicator();
29+
30+
@Override
31+
protected void onTick() throws Exception {
32+
String trackId = this.mediaPlayer.getTrackId();
33+
34+
// Handle on connect
35+
if (!this.connected && !trackId.isEmpty()) {
36+
this.connected = true;
37+
this.listeners.forEach(SpotifyListener::onConnect);
38+
}
39+
40+
// Handle track changes
41+
if (!Objects.equals(trackId, this.currentTrack == null ? null : this.currentTrack.getId())) {
42+
String trackName = this.mediaPlayer.getTrackName();
43+
String trackArtist = this.mediaPlayer.getArtist();
44+
int trackLength = this.mediaPlayer.getTrackLength();
45+
46+
boolean isFirstTrack = !this.hasTrack();
47+
48+
Track track = new Track(trackId, trackName, trackArtist, trackLength);
49+
this.currentTrack = track;
50+
51+
// Fire on track changed
52+
this.listeners.forEach(listener -> listener.onTrackChanged(track));
53+
54+
// Reset position on song change
55+
if (!isFirstTrack) {
56+
this.updatePosition(0);
57+
}
58+
}
59+
60+
// Handle is playing changes
61+
boolean isPlaying = this.mediaPlayer.isPlaying();
62+
if (isPlaying != this.isPlaying) {
63+
this.isPlaying = isPlaying;
64+
65+
// Fire on play back changed
66+
this.listeners.forEach(listener -> listener.onPlayBackChanged(isPlaying));
67+
}
68+
69+
// Handle position changes
70+
int position = this.mediaPlayer.getPosition();
71+
if (!this.hasPosition() || Math.abs(position - this.getPosition()) >= 1000) {
72+
this.updatePosition(position);
73+
}
74+
75+
// Fire keep alive
76+
this.listeners.forEach(SpotifyListener::onSync);
77+
}
78+
79+
@Override
80+
public void stop() {
81+
super.stop();
82+
this.connected = false;
83+
}
84+
85+
private void updatePosition(int position) {
86+
if (position == this.currentPosition) {
87+
return;
88+
}
89+
90+
// Update position known state
91+
this.currentPosition = position;
92+
this.lastTimePositionUpdated = System.currentTimeMillis();
93+
94+
// Fire on position changed
95+
this.listeners.forEach(listener -> listener.onPositionChanged(position));
96+
}
97+
98+
@Override
99+
public void pressMediaKey(MediaKey mediaKey) {
100+
try {
101+
switch (mediaKey) {
102+
case PLAY_PAUSE:
103+
this.mediaPlayer.playPause();
104+
break;
105+
case NEXT:
106+
this.mediaPlayer.next();
107+
break;
108+
case PREV:
109+
this.mediaPlayer.previous();
110+
break;
111+
}
112+
} catch (Exception e) {
113+
this.listeners.forEach(listener -> listener.onDisconnect(e));
114+
this.connected = false;
115+
}
116+
}
117+
118+
@Override
119+
public int getPosition() {
120+
if (!this.hasPosition()) {
121+
throw new IllegalStateException("Position is not known yet");
122+
}
123+
124+
if (this.isPlaying) {
125+
// Interpolate position
126+
long timePassed = System.currentTimeMillis() - this.lastTimePositionUpdated;
127+
return this.currentPosition + (int) timePassed;
128+
} else {
129+
return this.currentPosition;
130+
}
131+
}
132+
133+
@Override
134+
public Track getTrack() {
135+
return this.currentTrack;
136+
}
137+
138+
@Override
139+
public boolean isPlaying() {
140+
return this.isPlaying;
141+
}
142+
143+
@Override
144+
public boolean isConnected() {
145+
return this.connected;
146+
}
147+
148+
@Override
149+
public boolean hasPosition() {
150+
return this.currentPosition != -1;
151+
}
152+
153+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package de.labystudio.spotifyapi.platform.linux.api;
2+
3+
import java.io.BufferedReader;
4+
import java.io.InputStreamReader;
5+
6+
/**
7+
* Java wrapper for the dbus-send application
8+
* <p>
9+
* The dbus-send command is used to send a message to a D-Bus message bus.
10+
* There are two well-known message buses:
11+
* - the systemwide message bus (installed on many systems as the "messagebus" service)
12+
* - the per-user-login-session message bus (started each time a user logs in).
13+
* <p>
14+
* The "system" parameter and "session" parameter options direct dbus-send to send messages to the system or session buses respectively.
15+
* If neither is specified, dbus-send sends to the session bus.
16+
* <p>
17+
* Nearly all uses of dbus-send must provide the "dest" parameter which is the name of
18+
* a connection on the bus to send the message to. If the "dest" parameter is omitted, no destination is set.
19+
* <p>
20+
* The object path and the name of the message to send must always be specified.
21+
* Following arguments, if any, are the message contents (message arguments).
22+
* These are given as type-specified values and may include containers (arrays, dicts, and variants).
23+
*
24+
* @author LabyStudio
25+
*/
26+
public class DBusSend {
27+
28+
private static final Parameter PARAM_PRINT_REPLY = new Parameter("print-reply");
29+
private static final InterfaceMember INTERFACE_GET = new InterfaceMember("org.freedesktop.DBus.Properties.Get");
30+
31+
private final Parameter[] parameters;
32+
private final String objectPath;
33+
private final Runtime runtime;
34+
35+
/**
36+
* Creates a new DBusSend API for a specific application
37+
*
38+
* @param parameters The parameters to use
39+
* @param objectPath The object path to use
40+
*/
41+
public DBusSend(Parameter[] parameters, String objectPath) {
42+
this.parameters = parameters;
43+
this.objectPath = objectPath;
44+
this.runtime = Runtime.getRuntime();
45+
}
46+
47+
/**
48+
* Request an information from the application
49+
*
50+
* @param keys The requested type of information
51+
* @return The requested information
52+
* @throws Exception If the request failed
53+
*/
54+
public Variant get(String... keys) throws Exception {
55+
String[] contents = new String[keys.length];
56+
for (int i = 0; i < keys.length; i++) {
57+
contents[i] = String.format("string:%s", keys[i]);
58+
}
59+
return this.send(INTERFACE_GET, contents);
60+
}
61+
62+
/**
63+
* Execute an DBusSend command.
64+
*
65+
* @param interfaceMember The interface member to execute
66+
* @param contents The contents to send
67+
* @return The result of the command
68+
* @throws Exception If the command failed
69+
*/
70+
public Variant send(InterfaceMember interfaceMember, String... contents) throws Exception {
71+
// Build arguments
72+
String[] arguments = new String[2 + this.parameters.length + 2 + contents.length];
73+
arguments[0] = "dbus-send";
74+
arguments[1] = PARAM_PRINT_REPLY.toString();
75+
for (int i = 0; i < this.parameters.length; i++) {
76+
arguments[2 + i] = this.parameters[i].toString();
77+
}
78+
arguments[2 + this.parameters.length] = this.objectPath;
79+
arguments[2 + this.parameters.length + 1] = interfaceMember.toString();
80+
for (int i = 0; i < contents.length; i++) {
81+
arguments[2 + this.parameters.length + 2 + i] = contents[i];
82+
}
83+
84+
// Execute dbus-send process
85+
Process process = this.runtime.exec(arguments);
86+
int exitCode = process.waitFor();
87+
if (exitCode == 0) {
88+
// Read response
89+
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
90+
StringBuilder builder = new StringBuilder();
91+
String response;
92+
while ((response = reader.readLine()) != null) {
93+
if (response.startsWith("method ")) {
94+
continue;
95+
}
96+
builder.append(response).append("\n");
97+
}
98+
if (builder.toString().isEmpty()) {
99+
return new Variant("success", true);
100+
}
101+
return Variant.parse(builder.toString());
102+
} else {
103+
// Handle error message
104+
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
105+
String line;
106+
StringBuilder builder = new StringBuilder();
107+
while ((line = reader.readLine()) != null) {
108+
builder.append(line);
109+
}
110+
throw new Exception("dbus-send execution \"" + String.join(" ", arguments) + "\" failed with exit code " + exitCode + ": " + builder);
111+
}
112+
}
113+
114+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package de.labystudio.spotifyapi.platform.linux.api;
2+
3+
/**
4+
* Interface member wrapper for the DBusSend class.
5+
*
6+
* @author LabyStudio
7+
*/
8+
public class InterfaceMember {
9+
10+
private final String path;
11+
12+
public InterfaceMember(String path) {
13+
this.path = path;
14+
}
15+
16+
public String toString() {
17+
return this.path;
18+
}
19+
20+
}

0 commit comments

Comments
 (0)