From 5c8295c0eca379fac6b855f35836e30ffccdcf4a Mon Sep 17 00:00:00 2001 From: Lazar Lominski Date: Wed, 18 Feb 2026 12:45:40 +0100 Subject: [PATCH] Linux: anchor MPRIS position progression and emit advancing events Use monotonic-time anchored interpolation for Linux MPRIS position updates, including rate/state/track re-anchoring and drift correction. Emit onNowPlayingChanged in event-driven mode when position advances while playing. Update the event-driven example to register all sessions and print initial plus ongoing position changes. Update README wording from virtualized to computed progression. Signed-off-by: Lazar Lominski --- README.md | 8 +- .../examples/EventDrivenMediaExample.java | 59 ++++--- .../linux/LinuxMediaSession.java | 148 +++++++++++++----- 3 files changed, 155 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 8010ba3..5ce1e4f 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 \ No newline at end of file +- [mediaremote-adapter](https://github.com/ungive/mediaremote-adapter/) for the MediaRemote Perl workaround diff --git a/examples/src/main/java/org/endlesssource/mediainterface/examples/EventDrivenMediaExample.java b/examples/src/main/java/org/endlesssource/mediainterface/examples/EventDrivenMediaExample.java index 9ce825c..50d6f00 100644 --- a/examples/src/main/java/org/endlesssource/mediainterface/examples/EventDrivenMediaExample.java +++ b/examples/src/main/java/org/endlesssource/mediainterface/examples/EventDrivenMediaExample.java @@ -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); @@ -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 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); @@ -63,20 +55,22 @@ public void onNowPlayingChanged(MediaSession session, Optional 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) { - 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); @@ -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 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 = 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() { } } diff --git a/mediainterface-linux/src/main/java/org/endlesssource/mediainterface/linux/LinuxMediaSession.java b/mediainterface-linux/src/main/java/org/endlesssource/mediainterface/linux/LinuxMediaSession.java index 0b1aba9..a2c8872 100644 --- a/mediainterface-linux/src/main/java/org/endlesssource/mediainterface/linux/LinuxMediaSession.java +++ b/mediainterface-linux/src/main/java/org/endlesssource/mediainterface/linux/LinuxMediaSession.java @@ -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; @@ -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; @@ -37,10 +38,11 @@ class LinuxMediaSession implements MediaSession { private NowPlaying lastNowPlaying; private PlaybackState lastState = PlaybackState.UNKNOWN; - private String lastTrackKey; - private Optional lastRawPosition = Optional.empty(); - private Optional lastVirtualPosition = Optional.empty(); - private long lastPositionSampleMs = System.currentTimeMillis(); + private String anchorTrackKey; + private Optional anchorPosition = Optional.empty(); + private long anchorMonotonicNanos = System.nanoTime(); + private PlaybackState anchorState = PlaybackState.UNKNOWN; + private double anchorRate = 1.0d; public LinuxMediaSession(DBusConnection connection, String busName, @@ -67,7 +69,7 @@ public synchronized Optional getNowPlaying() { Optional> 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(); } @@ -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))); } @@ -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 rawPosition = nowPlaying.getPosition(); - Optional virtualPosition = rawPosition; + double rate = readPlaybackRate().orElse(1.0d); + String trackKey = trackKey(nowPlaying); + boolean trackChanged = !Objects.equals(trackKey, anchorTrackKey); + + Optional 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 = nowPlaying.getDuration(); - if (virtualPosition.isPresent() && duration.isPresent() - && virtualPosition.get().compareTo(duration.get()) > 0) { - virtualPosition = duration; + Optional computedPosition = projectedAnchorPosition(nowMonotonicNanos); + if (computedPosition.isEmpty()) { + computedPosition = rawPosition; } - lastRawPosition = rawPosition; - lastVirtualPosition = virtualPosition; - lastPositionSampleMs = nowMs; + Optional 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 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 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 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) { @@ -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;