From 3aee2276bc64560fb90a612bbe5ac64be139954c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:08:04 -0400 Subject: [PATCH] feat(android): RemotePlayerState container for UPnP-synthesized player state Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/RemotePlayerState.kt | 65 ++++++++++++++++++ .../minstrel/player/RemotePlayerStateTest.kt | 66 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt new file mode 100644 index 00000000..3745e3b9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -0,0 +1,65 @@ +package com.fabledsword.minstrel.player + +/** + * Synthesized state for the UPnP route -- what the ForwardingPlayer + * exposes via Player.getCurrentPosition / isPlaying / etc. when the + * remote leg is active. Not a Player; a container. + * + * Updates flow in from: + * - 1Hz GetPositionInfo poll -> applyPositionInfo() + * - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}() + * - Error paths -> applyError() (drop fallback) or recordPollFailure() + * + * The poll-failure counter implements the rolling-3 drop heuristic: 3 + * consecutive poll failures = remote considered dropped (returns true + * from recordPollFailure for the caller to surface). Success resets it. + */ +class RemotePlayerState { + + @Volatile var positionMs: Long = 0L; private set + @Volatile var durationMs: Long = 0L; private set + @Volatile var isPlaying: Boolean = false; private set + @Volatile var currentTrackUri: String = ""; private set + @Volatile var lastError: Throwable? = null; private set + + private var consecutivePollFailures: Int = 0 + + fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) { + this.positionMs = positionMs + this.durationMs = durationMs + this.currentTrackUri = trackUri + } + + fun applyTransportPlaying() { isPlaying = true } + fun applyTransportPaused() { isPlaying = false } + fun applyTransportStopped() { + isPlaying = false + positionMs = 0L + } + + fun applyError(t: Throwable) { + isPlaying = false + lastError = t + } + + /** Returns true when the rolling threshold trips this call. */ + fun recordPollFailure(): Boolean { + consecutivePollFailures += 1 + return consecutivePollFailures >= DROP_THRESHOLD + } + + fun recordPollSuccess() { consecutivePollFailures = 0 } + + fun reset() { + positionMs = 0L + durationMs = 0L + isPlaying = false + currentTrackUri = "" + lastError = null + consecutivePollFailures = 0 + } + + private companion object { + const val DROP_THRESHOLD = 3 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt new file mode 100644 index 00000000..1ff51e9c --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.player + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RemotePlayerStateTest { + + @Test + fun `starts in idle state`() { + val state = RemotePlayerState() + assertFalse(state.isPlaying) + assertEquals(0L, state.positionMs) + assertEquals(0L, state.durationMs) + } + + @Test + fun `applyPositionInfo updates position and duration`() { + val state = RemotePlayerState() + state.applyPositionInfo(positionMs = 65_000L, durationMs = 210_000L, trackUri = "x") + assertEquals(65_000L, state.positionMs) + assertEquals(210_000L, state.durationMs) + assertEquals("x", state.currentTrackUri) + } + + @Test + fun `applyTransportPlaying flips isPlaying true`() { + val state = RemotePlayerState() + state.applyTransportPlaying() + assertTrue(state.isPlaying) + } + + @Test + fun `applyTransportPaused flips isPlaying false`() { + val state = RemotePlayerState().apply { applyTransportPlaying() } + state.applyTransportPaused() + assertFalse(state.isPlaying) + } + + @Test + fun `applyError resets to idle and records error`() { + val state = RemotePlayerState().apply { applyTransportPlaying() } + val ex = RuntimeException("disconnected") + state.applyError(ex) + assertFalse(state.isPlaying) + assertEquals(ex, state.lastError) + } + + @Test + fun `recordPollFailure trips after threshold`() { + val state = RemotePlayerState() + assertFalse(state.recordPollFailure()) + assertFalse(state.recordPollFailure()) + assertTrue(state.recordPollFailure()) + } + + @Test + fun `recordPollSuccess clears the failure counter`() { + val state = RemotePlayerState() + state.recordPollFailure() + state.recordPollFailure() + state.recordPollSuccess() + assertFalse(state.recordPollFailure()) + } +}