From 4f963d2673c682f3931a6a19f08951ee8c23ef9c Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:20:06 -0400 Subject: [PATCH] feat: improve SiloCast remote controls --- .../network/AndroidServerIdentityTest.kt | 104 +++++ .../siloserver/silo/android/MainActivity.kt | 25 ++ .../android/cast/RemoteVolumeReconciler.kt | 52 +++ .../silo/android/cast/SiloCastController.kt | 225 +++++++++-- .../cast/SiloCastMediaSessionStarter.kt | 21 +- .../ui/screens/cast/SiloCastRemoteScreen.kt | 358 +++++++++++------- .../screens/cast/SiloCastTargetPickerSheet.kt | 10 +- .../cast/RemoteVolumeReconcilerTest.kt | 50 +++ .../cast/SiloCastMediaSessionStarterTest.kt | 24 +- .../tv/cast/RemotePlaybackIdentityManager.kt | 5 +- .../silo/tv/cast/SiloCastVolumeTracker.kt | 52 +++ .../silo/tv/cast/TvSiloCastReceiver.kt | 28 +- .../tv/ui/screens/player/TvPlayerScreen.kt | 33 +- .../player/TvSiloCastVolumeStateTest.kt | 43 +++ .../silo/network/AndroidServerRegistry.kt | 120 ++++++ 15 files changed, 929 insertions(+), 221 deletions(-) create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/AndroidServerIdentityTest.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconciler.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconcilerTest.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/SiloCastVolumeTracker.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSiloCastVolumeStateTest.kt diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/AndroidServerIdentityTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/AndroidServerIdentityTest.kt new file mode 100644 index 000000000..c8bf260a8 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/AndroidServerIdentityTest.kt @@ -0,0 +1,104 @@ +package org.siloserver.silo.common.network + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.siloserver.silo.network.AndroidServerRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class AndroidServerIdentityTest { + + @Test + fun matchesSchemeAndHostCaseAndDefaultPorts() { + val phone = wireId("https://Media.Example.test/library") + val tv = wireId("HTTPS://media.example.test:443/library/") + + assertFalse(phone == tv, "persisted registry keys remain unchanged") + assertTrue(AndroidServerRegistry.serverIdsMatch(phone, tv)) + assertTrue( + AndroidServerRegistry.serverIdsMatch( + wireId("http://MEDIA.example.test:80/library"), + wireId("http://media.example.test/library"), + ), + ) + } + + @Test + fun preservesCredentialsPathQueryFragmentAndNonDefaultPort() { + val canonical = wireId("https://User:Pass@MEDIA.example.test:8443/Library?mode=A#Top") + + assertTrue( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("HTTPS://User:Pass@media.example.test:8443/Library?mode=A#Top"), + ), + ) + assertFalse( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("https://user:Pass@media.example.test:8443/Library?mode=A#Top"), + ), + ) + assertFalse( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("https://User:Pass@media.example.test:443/Library?mode=A#Top"), + ), + ) + assertFalse( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("https://User:Pass@media.example.test:8443/library?mode=A#Top"), + ), + ) + assertFalse( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("https://User:Pass@media.example.test:8443/Library?mode=a#Top"), + ), + ) + assertFalse( + AndroidServerRegistry.serverIdsMatch( + canonical, + wireId("https://User:Pass@media.example.test:8443/Library?mode=A#top"), + ), + ) + assertTrue( + AndroidServerRegistry.serverIdsMatch( + wireId("https://User:Pass@MÉDIA.example.test:443/Library"), + wireId("HTTPS://User:Pass@média.example.test/Library"), + ), + ) + } + + @Test + fun decoderRequiresAnExactRoundTrippingHttpUrl() { + val original = "https://Média.example.test:443/silo?mode=A#top" + val serverId = wireId(original) + + assertEquals(original, AndroidServerRegistry.urlForServerId(serverId)) + assertNull(AndroidServerRegistry.urlForServerId("not-a-registry-id")) + assertNull(AndroidServerRegistry.urlForServerId(wireId("file:///tmp/silo"))) + assertNull( + AndroidServerRegistry.urlForServerId( + AndroidServerRegistry.idFor("https://media.example.test/"), + ), + ) + assertNull(AndroidServerRegistry.urlForServerId("$serverId=")) + } + + @Test + fun exactUnknownIdsMatchButMissingOrDistinctIdsDoNot() { + assertTrue(AndroidServerRegistry.serverIdsMatch("future-format", "future-format")) + assertFalse(AndroidServerRegistry.serverIdsMatch("future-format-a", "future-format-b")) + assertFalse(AndroidServerRegistry.serverIdsMatch(null, "future-format")) + assertFalse(AndroidServerRegistry.serverIdsMatch("", "")) + } + + private fun wireId(url: String): String = + AndroidServerRegistry.idFor(url.trim().trimEnd('/')) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt index 7e17ce387..34c620e2d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -31,6 +32,7 @@ import androidx.lifecycle.lifecycleScope import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMISSION import org.siloserver.silo.android.downloads.hasLegacyPublicDownloadPermission +import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.android.push.PushNotificationPresenter import org.siloserver.silo.android.ui.navigation.AppNavigation import org.siloserver.silo.android.ui.navigation.ExternalRouteRequest @@ -217,6 +219,29 @@ class MainActivity : ComponentActivity() { } } + /** + * While the full Remote Control owns volume, consume both halves of each + * hardware-key event so Android neither changes local volume nor shows its + * volume HUD. Repeated ACTION_DOWN events intentionally remain individual + * remote steps when the user holds a button. + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val step = when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> 1 + KeyEvent.KEYCODE_VOLUME_DOWN -> -1 + else -> return super.dispatchKeyEvent(event) + } + val controller = get(SiloCastController::class.java) + return when (event.action) { + KeyEvent.ACTION_DOWN -> { + controller.stepVolumeOptimistic(step) || super.dispatchKeyEvent(event) + } + else -> { + controller.shouldInterceptHardwareVolumeKeys() || super.dispatchKeyEvent(event) + } + } + } + override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconciler.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconciler.kt new file mode 100644 index 000000000..57ff85386 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconciler.kt @@ -0,0 +1,52 @@ +package org.siloserver.silo.android.cast + +import java.util.ArrayDeque +import kotlin.math.abs + +/** + * Holds locally requested absolute volume levels until the TV acknowledges + * them in order. + * + * SiloCast sends absolute values and the TV answers every command with a full + * state frame. During a burst, an older reply must not rewind the optimistic + * level used by the next hardware-button step. Tracking the ordered requests + * also handles reversals such as `0.5 -> 0.5625 -> 0.5`: a pre-command `0.5` + * snapshot cannot acknowledge the second request while the first is pending. + */ +internal class RemoteVolumeReconciler { + private data class PendingRequest( + val volume: Double, + val requestedAtMs: Long, + ) + + private val pending = ArrayDeque() + + fun requested(volume: Double, atMs: Long) { + pending.addLast(PendingRequest(volume = volume, requestedAtMs = atMs)) + } + + fun clear() { + pending.clear() + } + + fun reconcile(inbound: Double, atMs: Long): Double { + val latest = pending.peekLast() ?: return inbound + if (atMs - latest.requestedAtMs >= WINDOW_MS) { + pending.clear() + return inbound + } + + val earliest = pending.peekFirst() + if (earliest != null && abs(inbound - earliest.volume) < TOLERANCE) { + pending.removeFirst() + return pending.peekLast()?.volume ?: inbound + } + + return latest.volume + } + + private companion object { + const val WINDOW_MS = 4_000L + const val TOLERANCE = 0.001 + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt index 109c64634..9920a419a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +46,7 @@ import org.siloserver.silo.common.cast.SiloCastNsdBrowser import org.siloserver.silo.common.cast.SiloCastTarget import org.siloserver.silo.common.lan.SiloCastTls import org.siloserver.silo.common.lan.SiloCastTlsClientSession +import org.siloserver.silo.network.AndroidServerRegistry import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.ApiResult @@ -112,6 +114,23 @@ class SiloCastController( ignoreUnknownKeys = true } private val sendMutex = Mutex() + private val controlCommands = Channel(Channel.UNLIMITED) + + /** + * Immutable identity for the socket that may receive queued controls. + * Capturing its output stream means an old command can never fall through + * to a replacement TV, even if that replacement connects while the FIFO is + * draining. + */ + private data class ControlTransport(val output: OutputStream) + + private data class QueuedControlCommand( + val command: SiloCastControlCommand, + val transport: ControlTransport, + ) + + @Volatile + private var controlTransport: ControlTransport? = null // Serializes ensureConnected/closeConnection: rapid taps on different // targets otherwise interleave connect/teardown across IO coroutines and @@ -124,6 +143,8 @@ class SiloCastController( private val launchJobLock = Any() private var launchJob: Job? = null private val clock = SiloCastPlaybackClock() + private val volumeStateLock = Any() + private val volumeReconciler = RemoteVolumeReconciler() private val _state = MutableStateFlow(SiloCastControllerState()) val state: StateFlow = _state.asStateFlow() @@ -153,6 +174,9 @@ class SiloCastController( @Volatile private var remoteScreenVisible = false + @Volatile + private var appInForeground = false + private var negotiatedVersion = CompletableDeferred() @Volatile private var pendingHandoff: PendingHandoff? = null @@ -163,6 +187,17 @@ class SiloCastController( _state.update { it.copy(targets = targets) } } } + // One writer preserves absolute-command order during rapid hardware + // volume presses without ever performing socket I/O on the UI thread. + scope.launch { + for (queued in controlCommands) { + // Target switches invalidate the token before closing the old + // stream. Drop its backlog instead of applying it to the new TV. + if (controlTransport !== queued.transport) continue + runCatching { sendControl(queued) } + .onFailure { error -> _state.update { it.copy(error = error.message) } } + } + } } fun startBrowsing() { @@ -178,7 +213,7 @@ class SiloCastController( val self = currentCoroutineContext()[Job] ?: return@launch try { launchMutex.withLock { - ensureConnected(target) + ensureConnected(target, allowCrossServer = true) // AFTER ensureConnected: its teardown of any previous session // resets the flag, so setting it earlier would be undone. _state.update { it.copy(isLaunching = true) } @@ -322,13 +357,75 @@ class SiloCastController( } fun setVolume(volume: Double) { - sendControl(SiloCastControlCommand.setVolume(volume.coerceIn(0.0, 1.0))) + val clamped = volume.coerceIn(0.0, 1.0) + synchronized(volumeStateLock) { + if (_state.value.playbackState != null) { + volumeReconciler.requested(clamped, nowMs()) + _state.update { state -> + state.copy(playbackState = state.playbackState?.copy(volume = clamped)) + } + } + sendControl(SiloCastControlCommand.setVolume(clamped)) + } } fun setMuted(muted: Boolean) { - sendControl(SiloCastControlCommand.setMuted(muted)) + synchronized(volumeStateLock) { + volumeReconciler.clear() + _state.update { state -> + state.copy(playbackState = state.playbackState?.copy(isMuted = muted)) + } + sendControl(SiloCastControlCommand.setMuted(muted)) + } } + /** + * Applies one physical volume-button step while the foreground full remote + * owns those keys. Muted volume-down is intentionally a consumed no-op; + * volume-up unmutes and advances from the retained level, not display zero. + * + * @return true when the remote owned and consumed the button press. + */ + fun stepVolumeOptimistic(step: Int): Boolean { + if (step != -1 && step != 1) return false + synchronized(volumeStateLock) { + val playback = _state.value.playbackState ?: return false + if (!_state.value.isConnected || + !appInForeground || + !remoteScreenVisible || + playback.contentId.isNullOrEmpty() + ) { + return false + } + + if (playback.isMuted && step < 0) return true + if (playback.isMuted) { + volumeReconciler.clear() + _state.update { state -> + state.copy(playbackState = state.playbackState?.copy(isMuted = false)) + } + sendControl(SiloCastControlCommand.setMuted(false)) + } + + val next = (playback.volume + step.toDouble() / VOLUME_STEPS).coerceIn(0.0, 1.0) + if (next != playback.volume) { + volumeReconciler.requested(next, nowMs()) + _state.update { state -> + state.copy(playbackState = state.playbackState?.copy(volume = next)) + } + sendControl(SiloCastControlCommand.setVolume(next)) + } + return true + } + } + + /** Whether volume-key up/repeat events should stay consumed by the remote. */ + fun shouldInterceptHardwareVolumeKeys(): Boolean = + _state.value.isConnected && + appInForeground && + remoteScreenVisible && + !_state.value.playbackState?.contentId.isNullOrEmpty() + fun setVideoGravity(value: String) { sendControl(SiloCastControlCommand.setVideoGravity(value)) } @@ -357,6 +454,7 @@ class SiloCastController( } fun onAppForeground() { + appInForeground = true if (session != null) { // Validate liveness immediately rather than waiting out the // heartbeat interval on a socket that died while backgrounded. @@ -368,6 +466,7 @@ class SiloCastController( } fun onAppBackground() { + appInForeground = false // A half-finished probe can't complete while suspended; an unengaged // auto-resumed session shouldn't outlive the app being visible. cancelAutoResumeProbe() @@ -385,7 +484,13 @@ class SiloCastController( fun attemptAutoResumeIfIdle() { if (session != null || reconnectJob != null || autoResumeJob != null) return val persisted = lastTargetStore.load() ?: return - if (persisted.serverId == null || persisted.serverId != serverRegistry.activeEntry.value?.id) return + if (!AndroidServerRegistry.serverIdsMatch( + persisted.serverId, + serverRegistry.activeEntry.value?.id, + ) + ) { + return + } autoResumeJob = scope.launch { try { @@ -433,13 +538,27 @@ class SiloCastController( // Any outbound command counts as user engagement — the session is no // longer a passive auto-resume attachment after this. sessionIsAutoResumed = false - scope.launch { - runCatching { send(SiloCastMessage.Control(command)) } - .onFailure { error -> _state.update { it.copy(error = error.message) } } + val transport = controlTransport + if (transport == null || + controlCommands.trySend(QueuedControlCommand(command, transport)).isFailure + ) { + _state.update { it.copy(error = "Remote Control is unavailable.") } } } - private suspend fun ensureConnected(target: SiloCastTarget) = connectionMutex.withLock { + private suspend fun ensureConnected( + target: SiloCastTarget, + allowCrossServer: Boolean = false, + ) = connectionMutex.withLock { + val activeServerId = serverRegistry.activeEntry.value?.id + ?: error("Choose a server before controlling a TV.") + val targetsActiveServer = AndroidServerRegistry.serverIdsMatch(target.serverId, activeServerId) + require( + targetsActiveServer || + (allowCrossServer && target.version >= SiloCastProtocol.version), + ) { + "That TV is connected to a different server." + } if (_state.value.connectedTarget?.deviceId == target.deviceId && session?.isConnected == true) return@withLock reconnectJob?.cancel() reconnectJob = null @@ -454,9 +573,11 @@ class SiloCastController( ) } openSessionLocked(target) - lastTargetStore.save( - SiloCastPersistedTarget(deviceId = target.deviceId, name = target.name, serverId = target.serverId), - ) + if (targetsActiveServer) { + lastTargetStore.save( + SiloCastPersistedTarget(deviceId = target.deviceId, name = target.name, serverId = target.serverId), + ) + } Unit } @@ -480,6 +601,9 @@ class SiloCastController( negotiatedVersion = CompletableDeferred() missedHeartbeats = 0 send(SiloCastMessage.Hello(makeHello())) + // Publish the queue token only after Hello is fully written, so a + // control can never overtake the session handshake. + controlTransport = ControlTransport(newSession.output) _state.update { it.copy( connectedTarget = target, @@ -574,7 +698,10 @@ class SiloCastController( } else -> error("The TV sent an unexpected handoff reply.") } - require(ready.serverId == server.id && ready.profileId == profileId) { + require( + AndroidServerRegistry.serverIdsMatch(ready.serverId, server.id) && + ready.profileId == profileId, + ) { "The TV activated a different remote playback profile." } } catch (t: Throwable) { @@ -762,7 +889,6 @@ class SiloCastController( } } is SiloCastMessage.State -> { - clock.ingest(message.state, nowMs()) val isIdle = message.state.contentId.isNullOrEmpty() if (sessionIsAutoResumed && isIdle && !remoteScreenVisible) { // The user never engaged with this silently-resumed @@ -771,14 +897,27 @@ class SiloCastController( quietDisconnect() return } - _state.update { - it.copy( - playbackState = message.state, - error = null, - isAutoResuming = if (!isIdle) false else it.isAutoResuming, - isLaunching = if (!isIdle) false else it.isLaunching, - ) + val now = nowMs() + val reconciled = synchronized(volumeStateLock) { + val next = if (isIdle) { + volumeReconciler.clear() + message.state + } else { + message.state.copy( + volume = volumeReconciler.reconcile(message.state.volume, now), + ) + } + _state.update { + it.copy( + playbackState = next, + error = null, + isAutoResuming = if (!isIdle) false else it.isAutoResuming, + isLaunching = if (!isIdle) false else it.isLaunching, + ) + } + next } + clock.ingest(reconciled, now) } is SiloCastMessage.Error -> { if (sessionIsAutoResumed && !remoteScreenVisible) { @@ -813,11 +952,32 @@ class SiloCastController( } } + private suspend fun sendControl(queued: QueuedControlCommand) { + val frame = SiloCastFrame.encode( + json.encodeToString( + SiloCastMessage.serializer(), + SiloCastMessage.Control(queued.command), + ).encodeToByteArray(), + ) + sendMutex.withLock { + // Recheck after waiting behind any in-flight frame. A teardown can + // invalidate the token meanwhile; if it happens after this check, + // the captured old stream is still the only stream we can touch. + if (controlTransport !== queued.transport) return@withLock + withContext(Dispatchers.IO) { + queued.transport.output.write(frame) + queued.transport.output.flush() + } + } + } + private suspend fun closeConnection() = connectionMutex.withLock { closeConnectionLocked() } /** Tears the transport down but keeps the connected-target/playback state * fields, so a reconnect renders continuity instead of a blank remote. */ private fun teardownTransportLocked() { + // Invalidate queued controls before closing or replacing the stream. + controlTransport = null runCatching { session?.close() } session = null output = null @@ -833,16 +993,19 @@ class SiloCastController( private fun closeConnectionLocked() { teardownTransportLocked() sessionIsAutoResumed = false - _state.update { - it.copy( - connectedTarget = null, - playbackState = null, - isConnecting = false, - connectingDeviceId = null, - isReconnecting = false, - isAutoResuming = false, - isLaunching = false, - ) + synchronized(volumeStateLock) { + volumeReconciler.clear() + _state.update { + it.copy( + connectedTarget = null, + playbackState = null, + isConnecting = false, + connectingDeviceId = null, + isReconnecting = false, + isAutoResuming = false, + isLaunching = false, + ) + } } } @@ -851,6 +1014,7 @@ class SiloCastController( cancelReconnect() cancelAutoResumeProbe() closeConnectionLocked() + controlCommands.close() scope.cancel() } @@ -899,5 +1063,6 @@ class SiloCastController( const val AUTO_RESUME_SCAN_ROUNDS = 8 const val AUTO_RESUME_SCAN_STEP_MS = 500L const val AUTO_RESUME_CONFIRM_TIMEOUT_MS = 6_000L + const val VOLUME_STEPS = 16.0 } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarter.kt index 09aeb564b..366133e46 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarter.kt @@ -3,7 +3,6 @@ package org.siloserver.silo.android.cast import android.content.Context import android.content.Intent import androidx.annotation.OptIn -import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner @@ -64,9 +63,10 @@ class SiloCastMediaSessionStarter( when (action) { RemoteMediaServiceAction.None -> Unit RemoteMediaServiceAction.Stop -> appContext.stopService(intent) + // Media3 promotes an ongoing session itself. Launching with + // startForegroundService here arms the platform watchdog + // before Media3 has decided that a notification is needed. RemoteMediaServiceAction.Start -> appContext.startService(intent) - RemoteMediaServiceAction.StartForeground -> - ContextCompat.startForegroundService(appContext, intent) } }.onFailure { error -> android.util.Log.w(TAG, "Could not apply Remote Control media-service action $action", error) @@ -80,14 +80,12 @@ class SiloCastMediaSessionStarter( internal data class RemoteServiceState( val hasMedia: Boolean, - val needsForegroundStart: Boolean, ) internal enum class RemoteMediaServiceAction { None, Stop, Start, - StartForeground, } internal fun resolveRemoteMediaServiceAction( @@ -96,16 +94,9 @@ internal fun resolveRemoteMediaServiceAction( ): RemoteMediaServiceAction = when { !state.hasMedia -> RemoteMediaServiceAction.Stop !appForeground -> RemoteMediaServiceAction.None - state.needsForegroundStart -> RemoteMediaServiceAction.StartForeground else -> RemoteMediaServiceAction.Start } -private fun SiloCastControllerState.toRemoteServiceState(): RemoteServiceState { - val playback = playbackState - return RemoteServiceState( - hasMedia = !playback?.contentId.isNullOrBlank(), - needsForegroundStart = playback?.let { - it.isPlaying || it.isLoading || it.isBuffering - } == true, - ) -} +private fun SiloCastControllerState.toRemoteServiceState(): RemoteServiceState = RemoteServiceState( + hasMedia = !playbackState?.contentId.isNullOrBlank(), +) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt index 4025d84fe..5f5ba3d3f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt @@ -2,9 +2,9 @@ package org.siloserver.silo.android.ui.screens.cast import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -24,6 +24,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -33,8 +37,6 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.VolumeOff -import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.AspectRatio import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.GraphicEq @@ -75,6 +77,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -157,9 +160,12 @@ fun SiloCastRemoteScreen( Column(modifier = Modifier.fillMaxSize()) { RemoteTopBar( + playback = playback, onMinimize = onBack, onChooseTv = { showTargetPicker = true }, onStopPlayback = { controller.stopPlayback() }, + onSetVideoGravity = controller::setVideoGravity, + onSetHdrEnabled = controller::setHdrEnabled, onDisconnect = { controller.disconnect() onBack() @@ -265,14 +271,18 @@ private fun RemoteArtworkBackground(urlString: String?) { @Composable private fun RemoteTopBar( + playback: SiloCastPlaybackState?, onMinimize: () -> Unit, onChooseTv: () -> Unit, onStopPlayback: () -> Unit, + onSetVideoGravity: (String) -> Unit, + onSetHdrEnabled: (Boolean) -> Unit, onDisconnect: () -> Unit, showBatterySettings: Boolean, onBatterySettings: () -> Unit, ) { var menuExpanded by remember { mutableStateOf(false) } + var aspectMenuExpanded by remember { mutableStateOf(false) } Row( modifier = Modifier .fillMaxWidth() @@ -313,6 +323,38 @@ private fun RemoteTopBar( onStopPlayback() }, ) + if (playback?.supportsVideoGravity == true) { + DropdownMenuItem( + text = { Text("Aspect Ratio") }, + leadingIcon = { + Icon(Icons.Outlined.AspectRatio, contentDescription = null) + }, + trailingIcon = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + onClick = { + menuExpanded = false + aspectMenuExpanded = true + }, + ) + } + if (playback?.supportsHDRToggle == true) { + DropdownMenuItem( + text = { Text("HDR") }, + leadingIcon = { + Icon(Icons.Outlined.AspectRatio, contentDescription = null) + }, + trailingIcon = { + if (playback.hdrEnabled) { + Icon(Icons.Filled.Check, contentDescription = "Enabled") + } + }, + onClick = { + menuExpanded = false + onSetHdrEnabled(!playback.hdrEnabled) + }, + ) + } if (showBatterySettings) { DropdownMenuItem( text = { Text(stringResource(R.string.remote_battery_settings)) }, @@ -341,6 +383,40 @@ private fun RemoteTopBar( }, ) } + DropdownMenu( + expanded = aspectMenuExpanded && playback?.supportsVideoGravity == true, + onDismissRequest = { aspectMenuExpanded = false }, + ) { + Text( + "Aspect Ratio", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + listOf("fit" to "Fit", "fill" to "Fill", "stretch" to "Stretch") + .forEach { (id, label) -> + val selected = playback?.videoGravity == id || + (id == "fill" && playback?.videoGravity in listOf("zoom", "crop")) + DropdownMenuItem( + text = { Text(label) }, + leadingIcon = { + if (selected) { + Icon( + Icons.Filled.Check, + contentDescription = "Selected", + modifier = Modifier.size(18.dp), + ) + } else { + Spacer(modifier = Modifier.size(18.dp)) + } + }, + onClick = { + aspectMenuExpanded = false + onSetVideoGravity(id) + }, + ) + } + } } } } @@ -450,93 +526,116 @@ private fun RemoteNowPlaying( } } - Column( + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .navigationBarsPadding() - .padding(bottom = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .navigationBarsPadding(), ) { - Spacer(modifier = Modifier.height(8.dp)) - - RemotePoster(posterUrl = posterUrl, posterThumbhash = posterThumbhash) + // Portrait phones use Apple's flexible artwork region. Very short + // windows and enlarged text retain a vertical-scroll fallback instead + // of clipping transport or accessibility-sized labels. + val useScrollableLayout = maxHeight < 520.dp || LocalDensity.current.fontScale > 1.2f + val contentModifier = if (useScrollableLayout) { + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + } else { + Modifier.fillMaxSize() + } - Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = contentModifier + .padding(horizontal = 24.dp) + .padding(bottom = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = if (useScrollableLayout) { + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + } else { + Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = 12.dp) + }, + ) { + RemotePoster(posterUrl = posterUrl, posterThumbhash = posterThumbhash) + } - Text( - playback.title.ifEmpty { "Loading" }, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - color = RemoteOnSurface, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - playback.subtitle?.takeIf { it.isNotEmpty() }?.let { - Spacer(modifier = Modifier.height(4.dp)) Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = RemoteSecondary, + playback.title.ifEmpty { "Loading" }, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = RemoteOnSurface, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } - - if (!targetName.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(10.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .clip(CircleShape) - .background(RemoteChipFill) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Icon( - Icons.Outlined.SettingsRemote, - contentDescription = null, - tint = RemoteSecondary, - modifier = Modifier.size(13.dp), - ) + playback.subtitle?.takeIf { it.isNotEmpty() }?.let { + Spacer(modifier = Modifier.height(4.dp)) Text( - "Playing on $targetName", - style = MaterialTheme.typography.labelMedium, + it, + style = MaterialTheme.typography.bodyMedium, color = RemoteSecondary, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } - } - Spacer(modifier = Modifier.height(22.dp)) - RemoteScrubber(playback = playback, clockTick = clockTick, controller = controller) + if (!targetName.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(CircleShape) + .background(RemoteChipFill) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Icon( + Icons.Outlined.SettingsRemote, + contentDescription = null, + tint = RemoteSecondary, + modifier = Modifier.size(13.dp), + ) + Text( + "Playing on $targetName", + style = MaterialTheme.typography.labelMedium, + color = RemoteSecondary, + ) + } + } - Spacer(modifier = Modifier.height(18.dp)) - RemoteTransport(playback = playback, clockTick = clockTick, controller = controller) + Spacer(modifier = Modifier.height(24.dp)) + RemoteScrubber(playback = playback, clockTick = clockTick, controller = controller) - Spacer(modifier = Modifier.height(18.dp)) - RemoteVolumeRow(playback = playback, controller = controller) + Spacer(modifier = Modifier.height(20.dp)) + RemoteTransport(playback = playback, clockTick = clockTick, controller = controller) - Spacer(modifier = Modifier.height(20.dp)) - RemoteSecondaryControls(playback = playback, controller = controller) + Spacer(modifier = Modifier.height(20.dp)) + RemoteVolumeRow(playback = playback, controller = controller) - if (!error.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = RemoteOnSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(RemoteError.copy(alpha = 0.9f)) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) - } + Spacer(modifier = Modifier.height(20.dp)) + RemoteSecondaryControls(playback = playback, controller = controller) - Spacer(modifier = Modifier.height(8.dp)) + if (!error.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = RemoteOnSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(RemoteError.copy(alpha = 0.9f)) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) + } + } } } @@ -622,8 +721,19 @@ private fun RemoteTransport( @Suppress("UNUSED_EXPRESSION") clockTick Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(28.dp), + horizontalArrangement = if (playback.hasNextEpisode) { + Arrangement.SpaceBetween + } else { + Arrangement.spacedBy(28.dp) + }, + modifier = if (playback.hasNextEpisode) Modifier.fillMaxWidth() else Modifier, ) { + if (playback.hasNextEpisode) { + // Balance the trailing Next button so the core transport remains + // centered instead of shifting left whenever an episode follows. + Spacer(modifier = Modifier.size(48.dp)) + } + IconButton(onClick = { controller.seek((controller.displayTime() - 10.0).coerceAtLeast(0.0)) }) { Icon( Icons.Filled.Replay10, @@ -693,12 +803,18 @@ private fun RemoteVolumeRow( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .widthIn(max = 280.dp) + .fillMaxWidth(), ) { IconButton(onClick = { controller.setMuted(!playback.isMuted) }) { Icon( - if (playback.isMuted || playback.volume <= 0.001) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp, + if (playback.isMuted || playback.volume <= 0.001) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeDown + }, contentDescription = if (playback.isMuted) "Unmute" else "Mute", tint = RemoteOnSurface, ) @@ -713,6 +829,17 @@ private fun RemoteVolumeRow( colors = remoteSliderColors(), modifier = Modifier.weight(1f), ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(width = 28.dp, height = 48.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = null, + tint = RemoteOnSurface.copy(alpha = 0.55f), + modifier = Modifier.size(20.dp), + ) + } } } @@ -742,10 +869,24 @@ private fun RemoteSecondaryControls( Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), ) { + if (playback.qualityOptions.isNotEmpty()) { + RemoteChipMenu( + icon = Icons.Outlined.Tune, + caption = "Quality", + enabled = !playback.isQualitySwitching, + entries = playback.qualityOptions.map { option -> + MenuEntry( + label = option.label, + selected = playback.activeQualityId == option.id, + onClick = { controller.selectQuality(option.id) }, + ) + }, + modifier = Modifier.weight(1f), + ) + } + if (playback.audioTracks.isNotEmpty()) { RemoteChipMenu( icon = Icons.Outlined.GraphicEq, @@ -757,6 +898,7 @@ private fun RemoteSecondaryControls( onClick = { controller.selectAudioTrack(track.trackId) }, ) }, + modifier = Modifier.weight(1f), ) } @@ -768,21 +910,7 @@ private fun RemoteSecondaryControls( icon = Icons.Outlined.ClosedCaption, caption = "Subtitles", entries = subtitleMenuEntries(playback, controller), - ) - } - - if (playback.qualityOptions.isNotEmpty()) { - RemoteChipMenu( - icon = Icons.Outlined.Tune, - caption = "Quality", - enabled = !playback.isQualitySwitching, - entries = playback.qualityOptions.map { option -> - MenuEntry( - label = option.label, - selected = playback.activeQualityId == option.id, - onClick = { controller.selectQuality(option.id) }, - ) - }, + modifier = Modifier.weight(1f), ) } @@ -796,15 +924,8 @@ private fun RemoteSecondaryControls( onClick = { controller.setPlaybackSpeed(speed) }, ) }, + modifier = Modifier.weight(1f), ) - - if (playback.supportsVideoGravity || playback.supportsHDRToggle) { - RemoteChipMenu( - icon = Icons.Outlined.AspectRatio, - caption = if (playback.supportsVideoGravity) "Aspect" else "HDR", - entries = displayMenuEntries(playback, controller), - ) - } } } @@ -861,53 +982,24 @@ private fun subtitleMenuEntries( } } -private fun displayMenuEntries( - playback: SiloCastPlaybackState, - controller: SiloCastController, -): List = buildList { - if (playback.supportsVideoGravity) { - // Wire values follow Apple's VideoGravity enum; the Android TV - // receiver maps its legacy "zoom"/"crop" report onto Fill. - listOf("fit" to "Fit", "fill" to "Fill", "stretch" to "Stretch").forEach { (id, label) -> - val selected = playback.videoGravity == id || - (id == "fill" && playback.videoGravity in listOf("zoom", "crop")) - add( - MenuEntry( - label = label, - selected = selected, - onClick = { controller.setVideoGravity(id) }, - ), - ) - } - } - if (playback.supportsHDRToggle) { - add( - MenuEntry( - label = if (playback.hdrEnabled) "HDR On" else "HDR Off", - selected = playback.hdrEnabled, - onClick = { controller.setHdrEnabled(!playback.hdrEnabled) }, - ), - ) - } -} - @Composable private fun RemoteChipMenu( icon: ImageVector, caption: String, entries: List, enabled: Boolean = true, + modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } - Box { + Box(modifier = modifier) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier - .widthIn(min = 76.dp) + .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .then(if (enabled) Modifier.clickable { expanded = true } else Modifier) - .padding(vertical = 8.dp, horizontal = 10.dp), + .padding(vertical = 8.dp, horizontal = 4.dp), ) { Icon( icon, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt index 3125fb861..549095624 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt @@ -40,6 +40,7 @@ import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.cast.SiloCastLaunchRequest import org.siloserver.silo.cast.SiloCastProtocol import org.siloserver.silo.common.cast.SiloCastTarget +import org.siloserver.silo.network.AndroidServerRegistry import org.siloserver.silo.network.ServerRegistry /** @@ -87,7 +88,9 @@ fun SiloCastTargetPickerSheet( val displayedTargets = if (launchRequest != null) { state.targets } else { - state.targets.filter { it.serverId != null && it.serverId == activeServerId } + state.targets.filter { + AndroidServerRegistry.serverIdsMatch(it.serverId, activeServerId) + } } ModalBottomSheet( @@ -204,6 +207,7 @@ private fun TargetRow( ) { val needsUpdate = target.version < SiloCastProtocol.version val enabled = !needsUpdate + val targetsActiveServer = AndroidServerRegistry.serverIdsMatch(target.serverId, activeServerId) Row( verticalAlignment = Alignment.CenterVertically, @@ -238,9 +242,9 @@ private fun TargetRow( "Update Silo on this TV to use your profile" to MaterialTheme.colorScheme.onSurfaceVariant target.isPlaying -> "Playing now" to MaterialTheme.colorScheme.primary - target.serverId != null && target.serverId == activeServerId && target.serverName != null -> + targetsActiveServer && target.serverName != null -> target.serverName!! to MaterialTheme.colorScheme.onSurfaceVariant - target.serverId != null && target.serverId != activeServerId -> + target.serverId != null && !targetsActiveServer -> "Will temporarily use your server" to MaterialTheme.colorScheme.onSurfaceVariant else -> null } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconcilerTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconcilerTest.kt new file mode 100644 index 000000000..d32314180 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/RemoteVolumeReconcilerTest.kt @@ -0,0 +1,50 @@ +package org.siloserver.silo.android.cast + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RemoteVolumeReconcilerTest { + @Test + fun `latest requested volume survives stale replies during a burst`() { + val reconciler = RemoteVolumeReconciler() + + reconciler.requested(0.5625, atMs = 1_000L) + reconciler.requested(0.625, atMs = 1_100L) + + assertEquals(0.625, reconciler.reconcile(0.5625, atMs = 1_200L), 0.0001) + assertEquals(0.625, reconciler.reconcile(0.625, atMs = 1_300L), 0.0001) + assertEquals(0.2, reconciler.reconcile(0.2, atMs = 1_400L), 0.0001) + } + + @Test + fun `reversal cannot be acknowledged by a pre-request snapshot`() { + val reconciler = RemoteVolumeReconciler() + + reconciler.requested(0.5625, atMs = 1_000L) + reconciler.requested(0.5, atMs = 1_100L) + + assertEquals(0.5, reconciler.reconcile(0.5, atMs = 1_150L), 0.0001) + assertEquals(0.5, reconciler.reconcile(0.5625, atMs = 1_200L), 0.0001) + assertEquals(0.5, reconciler.reconcile(0.5, atMs = 1_250L), 0.0001) + assertEquals(0.8, reconciler.reconcile(0.8, atMs = 1_300L), 0.0001) + } + + @Test + fun `held volume expires when the TV never confirms it`() { + val reconciler = RemoteVolumeReconciler() + reconciler.requested(0.9, atMs = 1_000L) + + assertEquals(0.9, reconciler.reconcile(0.3, atMs = 4_999L), 0.0001) + assertEquals(0.3, reconciler.reconcile(0.3, atMs = 5_000L), 0.0001) + } + + @Test + fun `clearing makes inbound volume authoritative`() { + val reconciler = RemoteVolumeReconciler() + reconciler.requested(0.9, atMs = 1_000L) + + reconciler.clear() + + assertEquals(0.3, reconciler.reconcile(0.3, atMs = 1_100L), 0.0001) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarterTest.kt index d75a7ce49..8918432c7 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastMediaSessionStarterTest.kt @@ -5,36 +5,22 @@ import kotlin.test.assertEquals class SiloCastMediaSessionStarterTest { @Test - fun `active playback starts a foreground service only while app is foregrounded`() { - val active = RemoteServiceState(hasMedia = true, needsForegroundStart = true) - - assertEquals( - RemoteMediaServiceAction.StartForeground, - resolveRemoteMediaServiceAction(active, appForeground = true), - ) - assertEquals( - RemoteMediaServiceAction.None, - resolveRemoteMediaServiceAction(active, appForeground = false), - ) - } - - @Test - fun `paused media uses an ordinary service start only while app is foregrounded`() { - val paused = RemoteServiceState(hasMedia = true, needsForegroundStart = false) + fun `media uses an ordinary service start only while app is foregrounded`() { + val media = RemoteServiceState(hasMedia = true) assertEquals( RemoteMediaServiceAction.Start, - resolveRemoteMediaServiceAction(paused, appForeground = true), + resolveRemoteMediaServiceAction(media, appForeground = true), ) assertEquals( RemoteMediaServiceAction.None, - resolveRemoteMediaServiceAction(paused, appForeground = false), + resolveRemoteMediaServiceAction(media, appForeground = false), ) } @Test fun `cleared media stops the service even while app is backgrounded`() { - val empty = RemoteServiceState(hasMedia = false, needsForegroundStart = false) + val empty = RemoteServiceState(hasMedia = false) assertEquals( RemoteMediaServiceAction.Stop, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt index 434536966..769c937db 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt @@ -47,7 +47,7 @@ class RemotePlaybackIdentityManager( fun matches(offer: SiloCastHandoffOffer, controllerDeviceId: String): Boolean { val active = activeIdentity ?: return false - return active.serverId == offer.serverId && + return AndroidServerRegistry.serverIdsMatch(active.serverId, offer.serverId) && active.profileId == offer.profileId && active.controllerDeviceId == controllerDeviceId } @@ -188,6 +188,9 @@ class RemotePlaybackIdentityManager( require(normalized.isNotBlank() && offer.profileId.isNotBlank()) { "The phone sent an invalid server or profile." } + // This binding is deliberately exact: canonical matching is suitable + // for recognizing a server, but must not let an offer claim a URL that + // does not encode to the id sent alongside it. require(AndroidServerRegistry.idFor(normalized) == offer.serverId) { "The phone's server identity does not match its URL." } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/SiloCastVolumeTracker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/SiloCastVolumeTracker.kt new file mode 100644 index 000000000..23e9ebdb9 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/SiloCastVolumeTracker.kt @@ -0,0 +1,52 @@ +package org.siloserver.silo.tv.cast + +internal data class SiloCastVolumeState( + val volume: Double, + val isMuted: Boolean, +) + +/** + * Retains the TV player's last audible level for the lifetime of the SiloCast + * receiver, rather than one player composition. That lifetime matters across + * episode/content transitions and while MediaController is connecting: mute + * must publish silence without discarding the level a later unmute restores. + */ +internal class SiloCastVolumeTracker( + initialVolume: Double = 1.0, +) { + private val lock = Any() + private var retainedVolume = initialVolume.coerceIn(0.0, 1.0) + private var muted = false + + fun recordVolume(volume: Double) = synchronized(lock) { + val clamped = volume.coerceIn(0.0, 1.0) + muted = clamped <= SILENT_VOLUME + if (!muted) retainedVolume = clamped + } + + fun recordMuted(isMuted: Boolean, currentVolume: Double?) = synchronized(lock) { + currentVolume + ?.coerceIn(0.0, 1.0) + ?.takeIf { it > SILENT_VOLUME } + ?.let { retainedVolume = it } + muted = isMuted + } + + fun retainedAudibleVolume(): Double = synchronized(lock) { retainedVolume } + + fun resolve(currentVolume: Double?): SiloCastVolumeState = synchronized(lock) { + if (currentVolume != null) { + val clamped = currentVolume.coerceIn(0.0, 1.0) + muted = clamped <= SILENT_VOLUME + if (!muted) retainedVolume = clamped + } + SiloCastVolumeState( + volume = retainedVolume, + isMuted = muted, + ) + } + + private companion object { + const val SILENT_VOLUME = 0.001 + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 085bf7df5..4c85794f5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -39,6 +39,7 @@ import org.siloserver.silo.common.cast.SiloCastFrameBuffer import org.siloserver.silo.common.cast.SiloCastNsdAdvertiser import org.siloserver.silo.common.lan.SiloCastTls import org.siloserver.silo.common.lan.SiloCastTlsSession +import org.siloserver.silo.network.AndroidServerRegistry import org.siloserver.silo.network.ServerRegistry /** @@ -92,6 +93,7 @@ class TvSiloCastReceiver( * register — they belong to the previous epoch. */ private var sessionEpoch: Long = 0 private var activePlayer: ActivePlayer? = null + private val volumeTracker = SiloCastVolumeTracker() private val launchRequestChannel = Channel(capacity = 1) val launchRequests: Flow = launchRequestChannel.receiveAsFlow() private var pendingPlayerIdentityGeneration: String? = null @@ -230,6 +232,19 @@ class TvSiloCastReceiver( closePreviousController() } + internal fun recordPlayerVolume(volume: Double) { + volumeTracker.recordVolume(volume) + } + + internal fun recordPlayerMuted(isMuted: Boolean, currentVolume: Double?) { + volumeTracker.recordMuted(isMuted, currentVolume) + } + + internal fun retainedPlayerVolume(): Double = volumeTracker.retainedAudibleVolume() + + internal fun resolvePlayerVolume(currentVolume: Double?): SiloCastVolumeState = + volumeTracker.resolve(currentVolume) + private suspend fun acceptLoop(socket: ServerSocket) { while (true) { val client = try { @@ -374,7 +389,7 @@ class TvSiloCastReceiver( val activeServerId = identityManager.activeIdentity?.serverId ?: serverRegistry.activeServerId.value val offered = message.hello.serverId - session.isAuthorized = !offered.isNullOrEmpty() && activeServerId != null && offered == activeServerId + session.isAuthorized = AndroidServerRegistry.serverIdsMatch(offered, activeServerId) DiagnosticsCastLogger.event( if (session.isAuthorized) "TV cast controller authorized" else "TV cast handoff required", ) @@ -496,7 +511,11 @@ class TvSiloCastReceiver( ) return true } - if (message.launch.serverId != identityManager.activeIdentity?.serverId) { + if (!AndroidServerRegistry.serverIdsMatch( + message.launch.serverId, + identityManager.activeIdentity?.serverId, + ) + ) { session.send( SiloCastMessage.Error( SiloCastError( @@ -581,7 +600,10 @@ class TvSiloCastReceiver( refreshAdvertisement() val session = activeSession if (session != null) { - session.isAuthorized = session.controllerServerId == serverRegistry.activeServerId.value + session.isAuthorized = AndroidServerRegistry.serverIdsMatch( + session.controllerServerId, + serverRegistry.activeServerId.value, + ) if (session.isAuthorized) { session.send(SiloCastMessage.State(currentState())) refreshStandbyState() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index b22cefb04..816bf0234 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -139,6 +139,7 @@ import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.player.DolbyVisionDetection import org.siloserver.silo.player.formatSubtitleTrackDisplayLabel import org.siloserver.silo.tv.R +import org.siloserver.silo.tv.cast.SiloCastVolumeState import org.siloserver.silo.tv.cast.TvSiloCastPlayerAdapter import org.siloserver.silo.tv.cast.TvSiloCastReceiver import org.siloserver.silo.tv.ui.components.TvErrorScreen @@ -476,10 +477,6 @@ fun TvPlayerScreen( val latestSiloCastMediaController by rememberUpdatedState(mediaController) val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) DisposableEffect(siloCastReceiver, viewModel, contentId) { - var lastAudibleRemoteVolume = latestSiloCastMediaController - ?.volume - ?.takeIf { it > 0.001f } - ?: 1f val adapter = TvSiloCastPlayerAdapter( play = { // Watch Together is authoritative for transport: suppress @@ -530,29 +527,31 @@ fun TvPlayerScreen( ) }, setVolume = { volume -> - val next = volume.toFloat().coerceIn(0f, 1f) - if (next > 0.001f) lastAudibleRemoteVolume = next - latestSiloCastMediaController?.volume = next + latestSiloCastMediaController?.let { controller -> + val next = volume.toFloat().coerceIn(0f, 1f) + siloCastReceiver.recordPlayerVolume(next.toDouble()) + controller.volume = next + } }, setMuted = { muted -> - val controller = latestSiloCastMediaController - if (muted) { - controller?.volume?.takeIf { it > 0.001f }?.let { lastAudibleRemoteVolume = it } - controller?.volume = 0f - } else { - controller?.volume = lastAudibleRemoteVolume + latestSiloCastMediaController?.let { controller -> + siloCastReceiver.recordPlayerMuted(muted, controller.volume.toDouble()) + controller.volume = if (muted) 0f else siloCastReceiver.retainedPlayerVolume().toFloat() } }, playNext = viewModel::playNextEpisodeNow, ) val registration = siloCastReceiver.registerPlayer(adapter) { + val volumeState = siloCastReceiver.resolvePlayerVolume( + currentVolume = latestSiloCastMediaController?.volume?.toDouble(), + ) viewModel.uiState.value.toSiloCastPlaybackState( contentId = contentId, playbackSpeed = latestSiloCastPlaybackSpeed, hdrEnabled = latestSiloCastHdrEnabled, subtitleDelayMs = latestSiloCastSubtitleDelayMs, subtitleAppearance = latestSiloCastSubtitleAppearance, - volume = latestSiloCastMediaController?.volume?.toDouble() ?: 1.0, + volumeState = volumeState, ) } onDispose { registration.close() } @@ -3322,7 +3321,7 @@ private fun TvPlayerViewModel.UiState.toSiloCastPlaybackState( hdrEnabled: Boolean, subtitleDelayMs: Int, subtitleAppearance: SubtitleAppearance, - volume: Double, + volumeState: SiloCastVolumeState, ): SiloCastPlaybackState { val activeQualityId = videoQualities.firstOrNull { it.isSelected }?.id ?: VIDEO_QUALITY_AUTO_ID return SiloCastPlaybackState( @@ -3354,8 +3353,8 @@ private fun TvPlayerViewModel.UiState.toSiloCastPlaybackState( subtitlePosition = subtitleAppearance.position.toSiloCastPositionValue(), supportsSubtitleDelay = true, supportsSubtitlePosition = true, - volume = volume.coerceIn(0.0, 1.0), - isMuted = volume <= 0.001, + volume = volumeState.volume, + isMuted = volumeState.isMuted, hasNextEpisode = nextEpisode != null, nextEpisodeTitle = nextEpisode?.title, error = error, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSiloCastVolumeStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSiloCastVolumeStateTest.kt new file mode 100644 index 000000000..fb283c492 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSiloCastVolumeStateTest.kt @@ -0,0 +1,43 @@ +package org.siloserver.silo.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.siloserver.silo.tv.cast.SiloCastVolumeTracker + +class TvSiloCastVolumeStateTest { + + @Test + fun mutedStateReportsLastAudibleVolume() { + val tracker = SiloCastVolumeTracker() + tracker.resolve(currentVolume = 0.42) + tracker.recordMuted(isMuted = true, currentVolume = 0.42) + val state = tracker.resolve(currentVolume = 0.0) + + assertEquals(0.42, state.volume) + assertTrue(state.isMuted) + } + + @Test + fun unmutedStateReportsCurrentVolume() { + val tracker = SiloCastVolumeTracker(initialVolume = 0.42) + val state = tracker.resolve(currentVolume = 0.73) + + assertEquals(0.73, state.volume) + assertFalse(state.isMuted) + } + + @Test + fun missingControllerPreservesMutedStateAcrossPlayerRegistrationChanges() { + val tracker = SiloCastVolumeTracker() + tracker.resolve(currentVolume = 0.42) + tracker.recordMuted(isMuted = true, currentVolume = 0.42) + + val betweenPlayers = tracker.resolve(currentVolume = null) + + assertEquals(0.42, betweenPlayers.volume) + assertTrue(betweenPlayers.isMuted) + assertEquals(0.42, tracker.retainedAudibleVolume()) + } +} diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt index b7efb1dbd..6e4398ee6 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.network import android.content.SharedPreferences import android.util.Base64 +import java.net.URI import org.siloserver.silo.model.server.ServerEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -389,6 +390,125 @@ class AndroidServerRegistry( return Base64.encodeToString(normalizedUrl.toByteArray(Charsets.UTF_8), flags) } + /** + * Reverses a URL-derived registry id only when it is an exact id for a + * normalized HTTP(S) URL. The round-trip check keeps unknown future id + * formats from being interpreted as URLs accidentally. + */ + fun urlForServerId(serverId: String): String? { + if (serverId.isEmpty()) return null + val flags = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + val decoded = runCatching { + Base64.decode(serverId, flags).toString(Charsets.UTF_8) + }.getOrNull() ?: return null + val normalized = normalizeWireUrl(decoded) + if (canonicalComparisonUrl(normalized) == null || idFor(normalized) != serverId) { + return null + } + return normalized + } + + /** + * Compares URL-derived server ids without changing either persisted + * registry key. Exact ids (including unknown future formats) match + * first. Otherwise only scheme/host case and explicit default ports + * are canonicalized; credentials and the remaining URL components are + * retained exactly. + */ + fun serverIdsMatch(lhs: String?, rhs: String?): Boolean { + if (lhs.isNullOrEmpty() || rhs.isNullOrEmpty()) return false + if (lhs == rhs) return true + val lhsUrl = urlForServerId(lhs) ?: return false + val rhsUrl = urlForServerId(rhs) ?: return false + return canonicalComparisonUrl(lhsUrl) == canonicalComparisonUrl(rhsUrl) + } + + private fun canonicalComparisonUrl(url: String): String? { + val normalized = normalizeWireUrl(url) + val uri = runCatching { URI(normalized) }.getOrNull() ?: return null + if (uri.isOpaque) return null + val scheme = uri.scheme?.lowercase() + ?.takeIf { it == "http" || it == "https" } + ?: return null + val authority = uri.rawAuthority ?: return null + val canonicalAuthority = canonicalAuthority( + rawAuthority = authority, + scheme = scheme, + ) ?: return null + return buildString { + append(scheme) + append("://") + append(canonicalAuthority) + append(uri.rawPath.orEmpty()) + uri.rawQuery?.let { + append('?') + append(it) + } + uri.rawFragment?.let { + append('#') + append(it) + } + } + } + + private fun canonicalAuthority( + rawAuthority: String, + scheme: String, + ): String? { + val userInfoSeparator = rawAuthority.indexOf('@') + if (userInfoSeparator >= 0 && rawAuthority.indexOf('@', userInfoSeparator + 1) >= 0) { + return null + } + val userInfoPrefix = if (userInfoSeparator < 0) { + "" + } else { + rawAuthority.substring(0, userInfoSeparator + 1) + } + val hostAndPort = rawAuthority.substring(userInfoPrefix.length) + if (hostAndPort.isEmpty()) return null + + val (host, rawPort) = if (hostAndPort.startsWith('[')) { + val bracket = hostAndPort.indexOf(']') + if (bracket <= 1) return null + val bracketedHost = hostAndPort.substring(0, bracket + 1) + val remainder = hostAndPort.substring(bracket + 1) + if (remainder.isEmpty()) { + bracketedHost to null + } else { + if (!remainder.startsWith(':')) return null + bracketedHost to remainder.substring(1) + } + } else { + if (hostAndPort.contains('[') || hostAndPort.contains(']')) return null + val colon = hostAndPort.lastIndexOf(':') + if (colon < 0) { + hostAndPort to null + } else { + val unbracketedHost = hostAndPort.substring(0, colon) + if (unbracketedHost.contains(':')) return null + unbracketedHost to hostAndPort.substring(colon + 1) + } + } + if (host.isBlank() || host.any(Char::isWhitespace)) return null + + val port = rawPort?.let { value -> + if (value.isEmpty() || value.any { !it.isDigit() }) return null + value.toIntOrNull()?.takeIf { it in 0..65535 } ?: return null + } + val isDefaultPort = (scheme == "http" && port == 80) || + (scheme == "https" && port == 443) + return buildString { + append(userInfoPrefix) + append(host.lowercase()) + if (rawPort != null && !isDefaultPort) { + append(':') + append(rawPort) + } + } + } + + private fun normalizeWireUrl(raw: String): String = raw.trim().trimEnd('/') + fun normalizeUrl(raw: String): String { val trimmed = raw.trim().trimEnd('/') if (trimmed.isEmpty()) return trimmed