feat(android): RemotePlayerState container for UPnP-synthesized player state
android / Build + lint + test (push) Failing after 1m28s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:08:04 -04:00
parent 9a7d3b2d30
commit 3aee2276bc
2 changed files with 131 additions and 0 deletions
@@ -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
}
}
@@ -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())
}
}