Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ Access the operating systems "Media Remote"/Now Playing interface from Java/Kotl
| Now playing: livestream detection | | | Yes |
| Now playing: additional metadata | Yes | Yes | No |
| Now playing: position | Yes | Yes | Yes |
| Now playing: virtualized position progression | Yes | Yes | Yes |
| Now playing: computed position progression | Yes | Yes | Yes |
| Playback controls: play/pause/toggle/next/prev/stop | Yes | Yes | Yes |
| Playback controls: seek | No | Yes | Yes |
| Polling: supported | Yes | Yes | Yes |
| Event driven: supported | Yes | Yes | Yes |
| Event driven: process system events | No | No | No |
| Polling: virtualized position progression | Yes | Yes | Yes |
| Event driven: virtualized position progression | Yes | Yes | Yes |
| Polling: computed position progression | Yes | Yes | Yes |
| Event driven: computed position progression | Yes | Yes | Yes |
| Event driven: `onPlaybackStateChanged` | Yes | Yes | Yes |
| Event driven: `onSessionAdded/Removed` | Yes | Yes | No |
| Event driven: `onNowPlayingChanged` | Yes | Yes | Yes |
Expand Down Expand Up @@ -118,4 +118,4 @@ Java Media Interface is licensed under the Apache 2.0 License. (see `LICENSE`)
## Credits

- [@TimLohrer](https://github.com/TimLohrer) for the idea
- [mediaremote-adapter](https://github.com/ungive/mediaremote-adapter/) for the MediaRemote Perl workaround
- [mediaremote-adapter](https://github.com/ungive/mediaremote-adapter/) for the MediaRemote Perl workaround
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public final class EventDrivenMediaExample {
private static final Logger logger = LoggerFactory.getLogger(EventDrivenMediaExample.class);
Expand All @@ -32,19 +34,9 @@ public static void main(String[] args) {

try (SystemMediaInterface media = SystemMediaFactory.createSystemInterface(options)) {
logger.info("Event-driven example running (listener callbacks enabled). Press Ctrl+C to stop.");
Set<String> registeredSessionIds = ConcurrentHashMap.newKeySet();

media.addSessionListener(new MediaSessionListener() {
@Override
public void onSessionAdded(MediaSession session) {
logger.info("Session added: {} ({})", session.getApplicationName(), session.getSessionId());
session.addListener(this);
}

@Override
public void onSessionRemoved(String sessionId) {
logger.info("Session removed: {}", sessionId);
}

MediaSessionListener sessionListener = new MediaSessionListener() {
@Override
public void onPlaybackStateChanged(MediaSession session, PlaybackState state) {
logger.info("State changed [{}]: {}", session.getApplicationName(), state);
Expand All @@ -63,20 +55,22 @@ public void onNowPlayingChanged(MediaSession session, Optional<NowPlaying> nowPl
logger.info("Now playing [{}]: {} - {} ({}/{})",
session.getApplicationName(), title, artist, position, duration);
}
});
};

media.getAllSessions().forEach(session -> session.addListener(new MediaSessionListener() {
media.addSessionListener(new MediaSessionListener() {
@Override
public void onPlaybackStateChanged(MediaSession s, PlaybackState state) {
logger.info("State changed [{}]: {}", s.getApplicationName(), state);
public void onSessionAdded(MediaSession session) {
logger.info("Session added: {} ({})", session.getApplicationName(), session.getSessionId());
registerSessionListener(session, sessionListener, registeredSessionIds);
}

@Override
public void onNowPlayingChanged(MediaSession s, Optional<NowPlaying> nowPlaying) {
String title = nowPlaying.flatMap(NowPlaying::getTitle).orElse("Unknown Title");
logger.info("Initial listener [{}]: {}", s.getApplicationName(), title);
public void onSessionRemoved(String sessionId) {
logger.info("Session removed: {}", sessionId);
}
}));
});

media.getAllSessions().forEach(session -> registerSessionListener(session, sessionListener, registeredSessionIds));

while (true) {
Thread.sleep(1000);
Expand All @@ -95,6 +89,31 @@ private static String formatDuration(Duration duration) {
return String.format("%d:%02d", minutes, seconds);
}

private static void registerSessionListener(MediaSession session,
MediaSessionListener listener,
Set<String> registeredSessionIds) {
if (!registeredSessionIds.add(session.getSessionId())) {
return;
}
session.addListener(listener);

PlaybackState initialState = session.getControls().getPlaybackState();
logger.info("Session available: {} ({}) state={}",
session.getApplicationName(), session.getSessionId(), initialState);

Optional<NowPlaying> nowPlaying = session.getNowPlaying();
String title = nowPlaying.flatMap(NowPlaying::getTitle).orElse("Unknown Title");
String artist = nowPlaying.flatMap(NowPlaying::getArtist).orElse("Unknown Artist");
String position = nowPlaying.flatMap(NowPlaying::getPosition)
.map(EventDrivenMediaExample::formatDuration)
.orElse("--:--");
String duration = nowPlaying.flatMap(NowPlaying::getDuration)
.map(EventDrivenMediaExample::formatDuration)
.orElse("--:--");
logger.info("Initial now playing [{}|{}]: {} - {} ({}/{})",
session.getApplicationName(), session.getSessionId(), title, artist, position, duration);
}

private EventDrivenMediaExample() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.exceptions.DBusException;
import org.freedesktop.dbus.interfaces.Properties;
import org.freedesktop.dbus.types.Variant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -22,6 +21,8 @@

class LinuxMediaSession implements MediaSession {
private static final Logger logger = LoggerFactory.getLogger(LinuxMediaSession.class);
private static final long POSITION_CORRECTION_TOLERANCE_MS = 1500L;
private static final double RATE_EPSILON = 0.0001d;

private final DBusConnection connection;
private final String busName;
Expand All @@ -37,10 +38,11 @@ class LinuxMediaSession implements MediaSession {

private NowPlaying lastNowPlaying;
private PlaybackState lastState = PlaybackState.UNKNOWN;
private String lastTrackKey;
private Optional<Duration> lastRawPosition = Optional.empty();
private Optional<Duration> lastVirtualPosition = Optional.empty();
private long lastPositionSampleMs = System.currentTimeMillis();
private String anchorTrackKey;
private Optional<Duration> anchorPosition = Optional.empty();
private long anchorMonotonicNanos = System.nanoTime();
private PlaybackState anchorState = PlaybackState.UNKNOWN;
private double anchorRate = 1.0d;

public LinuxMediaSession(DBusConnection connection,
String busName,
Expand All @@ -67,7 +69,7 @@ public synchronized Optional<NowPlaying> getNowPlaying() {
Optional<Map<String, Object>> metadataMap = MprisMetadataUtils.toMetadataMap(metadata);
return metadataMap
.map(map -> new LinuxNowPlaying(map, player, properties))
.map(this::withVirtualizedPositionIfNeeded);
.map(this::withAnchoredPosition);
} catch (Exception e) {
return Optional.empty();
}
Expand Down Expand Up @@ -163,7 +165,7 @@ private void checkForChanges() {
NowPlaying current = currentNowPlaying.get();
if (lastNowPlaying == null
|| !sameMedia(lastNowPlaying, current)
|| !samePositionBucket(lastNowPlaying, current)) {
|| !sameEventPosition(lastNowPlaying, current, currentState)) {
lastNowPlaying = current;
listeners.forEach(listener -> listener.onNowPlayingChanged(this, Optional.of(current)));
}
Expand All @@ -184,47 +186,116 @@ public void close() {
}
}

private NowPlaying withVirtualizedPositionIfNeeded(LinuxNowPlaying nowPlaying) {
long nowMs = System.currentTimeMillis();
String trackKey = trackKey(nowPlaying);
boolean trackChanged = !Objects.equals(trackKey, lastTrackKey);

private NowPlaying withAnchoredPosition(LinuxNowPlaying nowPlaying) {
long nowMonotonicNanos = System.nanoTime();
PlaybackState state = controls.getPlaybackState();
Optional<Duration> rawPosition = nowPlaying.getPosition();
Optional<Duration> virtualPosition = rawPosition;
double rate = readPlaybackRate().orElse(1.0d);
String trackKey = trackKey(nowPlaying);
boolean trackChanged = !Objects.equals(trackKey, anchorTrackKey);

Optional<Duration> predictedBefore = projectedAnchorPosition(nowMonotonicNanos);

if (trackChanged) {
lastTrackKey = trackKey;
} else if (state == PlaybackState.PLAYING) {
anchorTrackKey = trackKey;
if (rawPosition.isPresent()) {
if (lastRawPosition.isPresent() && lastVirtualPosition.isPresent()
&& rawPosition.get().equals(lastRawPosition.get())) {
long elapsedMs = Math.max(0L, nowMs - lastPositionSampleMs);
virtualPosition = Optional.of(lastVirtualPosition.get().plusMillis(elapsedMs));
}
} else if (lastVirtualPosition.isPresent()) {
long elapsedMs = Math.max(0L, nowMs - lastPositionSampleMs);
virtualPosition = Optional.of(lastVirtualPosition.get().plusMillis(elapsedMs));
anchorPosition = rawPosition;
} else {
anchorPosition = Optional.empty();
}
anchorMonotonicNanos = nowMonotonicNanos;
anchorState = state;
anchorRate = rate;
} else if (rawPosition.isPresent()) {
boolean shouldAnchorToRaw = shouldAnchorToRaw(rawPosition.get(), predictedBefore, state, rate);
if (shouldAnchorToRaw) {
anchorPosition = rawPosition;
anchorMonotonicNanos = nowMonotonicNanos;
}
} else if (rawPosition.isEmpty() && lastVirtualPosition.isPresent()) {
// Keep last known position stable while paused/stopped when source omits position.
virtualPosition = lastVirtualPosition;
anchorState = state;
anchorRate = rate;
} else if (state != anchorState || Math.abs(rate - anchorRate) > RATE_EPSILON) {
anchorPosition = predictedBefore;
anchorMonotonicNanos = nowMonotonicNanos;
anchorState = state;
anchorRate = rate;
}

Optional<Duration> duration = nowPlaying.getDuration();
if (virtualPosition.isPresent() && duration.isPresent()
&& virtualPosition.get().compareTo(duration.get()) > 0) {
virtualPosition = duration;
Optional<Duration> computedPosition = projectedAnchorPosition(nowMonotonicNanos);
if (computedPosition.isEmpty()) {
computedPosition = rawPosition;
}

lastRawPosition = rawPosition;
lastVirtualPosition = virtualPosition;
lastPositionSampleMs = nowMs;
Optional<Duration> duration = nowPlaying.getDuration();
if (computedPosition.isPresent() && duration.isPresent()
&& computedPosition.get().compareTo(duration.get()) > 0) {
computedPosition = duration;
}

if (Objects.equals(virtualPosition, rawPosition)) {
if (Objects.equals(computedPosition, rawPosition)) {
return nowPlaying;
}
return new PositionedNowPlaying(nowPlaying, virtualPosition, Instant.ofEpochMilli(nowMs));
return new PositionedNowPlaying(nowPlaying, computedPosition, Instant.now());
}

private boolean shouldAnchorToRaw(Duration rawPosition,
Optional<Duration> predictedBefore,
PlaybackState state,
double rate) {
if (anchorPosition.isEmpty()) {
return true;
}
if (state != anchorState || Math.abs(rate - anchorRate) > RATE_EPSILON) {
return true;
}
if (predictedBefore.isEmpty()) {
return true;
}

long diffMillis = Math.abs(rawPosition.minus(predictedBefore.get()).toMillis());
if (diffMillis > POSITION_CORRECTION_TOLERANCE_MS) {
return true;
}

// Avoid backward jitter from coarse/stale Position values while playing.
return !isBackward(rawPosition, predictedBefore.get(), state);
}

private static boolean isBackward(Duration rawPosition, Duration predictedPosition, PlaybackState state) {
return state == PlaybackState.PLAYING && rawPosition.compareTo(predictedPosition) < 0;
}

private Optional<Duration> projectedAnchorPosition(long nowMonotonicNanos) {
if (anchorPosition.isEmpty()) {
return Optional.empty();
}
if (anchorState != PlaybackState.PLAYING || anchorRate <= 0.0d) {
return anchorPosition;
}

long elapsedNanos = Math.max(0L, nowMonotonicNanos - anchorMonotonicNanos);
long deltaNanos = Math.max(0L, Math.round(elapsedNanos * anchorRate));
return Optional.of(anchorPosition.get().plusNanos(deltaNanos));
}

private Optional<Double> readPlaybackRate() {
try {
return Optional.of(player.getRate());
} catch (Exception e) {
logger.debug("Failed to get playback rate via direct method for {}: {}", busName, e.getMessage());
}

try {
Object rateObj = properties.Get("org.mpris.MediaPlayer2.Player", "Rate");
Object unwrapped = MprisMetadataUtils.unwrap(rateObj);
if (unwrapped instanceof Number number) {
return Optional.of(number.doubleValue());
}
} catch (Exception e) {
logger.debug("Failed to get playback rate via properties for {}: {}", busName, e.getMessage());
}

return Optional.empty();
}

private static String trackKey(NowPlaying nowPlaying) {
Expand All @@ -242,7 +313,12 @@ private static boolean sameMedia(NowPlaying a, NowPlaying b) {
a.getDuration().equals(b.getDuration());
}

private static boolean samePositionBucket(NowPlaying a, NowPlaying b) {
private static boolean sameEventPosition(NowPlaying a, NowPlaying b, PlaybackState currentState) {
if (currentState == PlaybackState.PLAYING) {
long aMs = a.getPosition().map(Duration::toMillis).orElse(-1L);
long bMs = b.getPosition().map(Duration::toMillis).orElse(-1L);
return aMs == bMs;
}
long aSec = a.getPosition().map(Duration::getSeconds).orElse(-1L);
long bSec = b.getPosition().map(Duration::getSeconds).orElse(-1L);
return aSec == bSec;
Expand Down