From 93bbc3e9c32163bc4650454b005ad571ca692b30 Mon Sep 17 00:00:00 2001 From: kaoru Date: Sun, 14 Jun 2026 19:12:57 -0400 Subject: [PATCH] fix(cast): keep relay alive during casting --- android/app/src/main/AndroidManifest.xml | 7 + .../glacier/cast/CastRelayKeepAliveService.kt | 154 ++++++++++++ .../glacier/cast/GlacierCastController.kt | 32 +++ .../glacier/streamproxy/CastRelayServer.kt | 178 +++++++++++-- .../streamproxy/CastRelayServerTest.kt | 79 ++++++ cast_receiver/index.html | 123 ++++++++- cast_receiver/index.test.mjs | 234 ++++++++++++++++++ lib/screens/channel/video/cast_button.dart | 4 +- lib/screens/channel/video/video_overlay.dart | 4 +- lib/screens/channel/video/video_store.dart | 5 +- lib/services/cast_state.dart | 10 +- .../channel/video/cast_button_test.dart | 2 +- test/services/cast_state_test.dart | 3 +- 13 files changed, 801 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastRelayKeepAliveService.kt create mode 100644 cast_receiver/index.test.mjs diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f7faec66..1a18fdbe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + @@ -52,6 +55,10 @@ + diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastRelayKeepAliveService.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastRelayKeepAliveService.kt new file mode 100644 index 00000000..6bdab612 --- /dev/null +++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastRelayKeepAliveService.kt @@ -0,0 +1,154 @@ +package com.namecallfilter.glacier.cast + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import com.namecallfilter.glacier.R + +class CastRelayKeepAliveService : Service() { + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + + startForegroundCompat() + acquireLocks() + Log.d(LOG_TAG, "cast_keep_alive action=started") + return START_STICKY + } + + override fun onDestroy() { + releaseLocks() + Log.d(LOG_TAG, "cast_keep_alive action=stopped") + super.onDestroy() + } + + private fun startForegroundCompat() { + createNotificationChannel() + val notification = notification() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val channel = NotificationChannel( + CHANNEL_ID, + "Cast relay", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps the local Cast relay active while casting" + setShowBadge(false) + } + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun notification(): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + + return builder + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Glacier cast relay") + .setContentText("Keeping local playback relay active") + .setOngoing(true) + .setShowWhen(false) + .build() + } + + private fun acquireLocks() { + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + if (wakeLock?.isHeld != true) { + wakeLock = powerManager + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Glacier:CastRelayWakeLock") + .apply { + setReferenceCounted(false) + acquire() + } + } + + val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + if (wifiLock?.isHeld != true) { + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + WifiManager.WIFI_MODE_FULL_LOW_LATENCY + } else { + @Suppress("DEPRECATION") + WifiManager.WIFI_MODE_FULL_HIGH_PERF + } + wifiLock = wifiManager + .createWifiLock(mode, "Glacier:CastRelayWifiLock") + .apply { + setReferenceCounted(false) + acquire() + } + } + } + + private fun releaseLocks() { + wakeLock + ?.takeIf(PowerManager.WakeLock::isHeld) + ?.release() + wakeLock = null + + wifiLock + ?.takeIf(WifiManager.WifiLock::isHeld) + ?.release() + wifiLock = null + } + + companion object { + private const val ACTION_START = "com.namecallfilter.glacier.cast.START_RELAY_KEEP_ALIVE" + private const val ACTION_STOP = "com.namecallfilter.glacier.cast.STOP_RELAY_KEEP_ALIVE" + private const val CHANNEL_ID = "glacier_cast_relay" + private const val NOTIFICATION_ID = 5312 + private const val LOG_TAG = "GlacierCast" + + fun start(context: Context) { + val intent = Intent(context, CastRelayKeepAliveService::class.java) + .setAction(ACTION_START) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService( + Intent(context, CastRelayKeepAliveService::class.java) + .setAction(ACTION_STOP), + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt index e08349ad..9e9cdfd4 100644 --- a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt +++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt @@ -56,6 +56,9 @@ class GlacierCastController( @Volatile private var connectingRouteName: String? = null + @Volatile + private var keepAliveActive = false + init { runCatching { CastContext.getSharedInstance(applicationContext) @@ -169,6 +172,7 @@ class GlacierCastController( connectingRouteName = null castContext?.sessionManager?.endCurrentSession(true) relayServer.close() + stopKeepAlive() emitDisconnected() emitRoutes() } @@ -242,6 +246,29 @@ class GlacierCastController( ) } + private fun startKeepAlive() { + if (keepAliveActive) return + + keepAliveActive = true + runCatching { + CastRelayKeepAliveService.start(applicationContext) + }.onFailure { error -> + keepAliveActive = false + log("cast_keep_alive action=start_failed reason=${error.javaClass.simpleName}") + } + } + + private fun stopKeepAlive() { + if (!keepAliveActive) return + + keepAliveActive = false + runCatching { + CastRelayKeepAliveService.stop(applicationContext) + }.onFailure { error -> + log("cast_keep_alive action=stop_failed reason=${error.javaClass.simpleName}") + } + } + private fun currentSession(): CastSession? { return castContext?.sessionManager?.currentCastSession } @@ -350,6 +377,7 @@ class GlacierCastController( receiverLatencyMs = null connectingRouteId = null connectingRouteName = null + startKeepAlive() attachReceiverChannel(session) emitState(session) emitRoutes() @@ -363,6 +391,7 @@ class GlacierCastController( receiverLatencyMs = null connectingRouteId = null connectingRouteName = null + stopKeepAlive() emitDisconnected() emitRoutes(error = "Unable to connect to Cast device.") Log.d( @@ -383,6 +412,7 @@ class GlacierCastController( connectingRouteId = null connectingRouteName = null relayServer.close() + stopKeepAlive() emitDisconnected() emitRoutes() Log.d( @@ -398,6 +428,7 @@ class GlacierCastController( override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { connectingRouteId = null connectingRouteName = null + startKeepAlive() attachReceiverChannel(session) emitState(session) emitRoutes() @@ -410,6 +441,7 @@ class GlacierCastController( receiverLatencyMs = null connectingRouteId = null connectingRouteName = null + stopKeepAlive() emitDisconnected() emitRoutes(error = "Unable to reconnect to Cast device.") Log.d( diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt index 60b1b984..7d95b82c 100644 --- a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt +++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt @@ -152,9 +152,15 @@ class CastRelayServer( } private fun handleRelayRequest(socket: Socket, request: HttpRequest) { + val requestStartedNs = System.nanoTime() val path = request.target.substringBefore("?") if (!path.startsWith("/relay/")) { sendError(socket, 404, "Not Found") + log( + "cast_relay action=response method=${request.method} " + + "path=$path status=404 total_ms=${elapsedMs(requestStartedNs)} " + + "bytes=9 playlist=false reason=invalid_path", + ) return } @@ -164,8 +170,12 @@ class CastRelayServer( .takeIf(String::isNotEmpty) val target = targets[id] if (target == null) { - log("cast_relay action=response path=$path status=404 reason=missing_target") sendError(socket, 404, "Not Found") + log( + "cast_relay action=response method=${request.method} " + + "path=$path status=404 total_ms=${elapsedMs(requestStartedNs)} " + + "bytes=9 playlist=false reason=missing_target", + ) return } @@ -203,10 +213,19 @@ class CastRelayServer( router = router, directWhenSkipped = true, ) + val upstreamConnectedNs = System.nanoTime() if (response == null) { - log("cast_relay action=response path=$path status=502 reason=upstream_null") sendError(socket, 502, "Bad Gateway") + log( + "cast_relay action=response method=${request.method} " + + "path=$path status=502 " + + "source=${sourceDescription(upstreamUrl)} " + + "upstream_connect_ms=${elapsedMs(requestStartedNs, upstreamConnectedNs)} " + + "upstream_first_byte_ms=-1 " + + "total_ms=${elapsedMs(requestStartedNs)} " + + "bytes=11 playlist=$playlistRequest reason=upstream_null", + ) return } @@ -216,12 +235,17 @@ class CastRelayServer( var mimeType = upstream.mimeType var selectedQuality: String? = null val playlistResponse = isPlaylistResponse(upstreamUrl, upstream) + var firstByteNs: Long? = null + var playlistMetadata = PlaylistLiveEdgeMetadata() if (playlistResponse) { - var body = if (headOnly) { + val body = if (headOnly) { ByteArray(0) } else { - val playlist = decodeBody(upstream.body.readBytes(), upstream.encoding) + val timedBody = readBodyWithTiming(upstream.body) + firstByteNs = timedBody.firstByteNs + val playlist = decodeBody(timedBody.bytes, upstream.encoding) + playlistMetadata = playlistLiveEdgeMetadata(playlist) val rewritten = HlsPlaylistRewriter.rewritePlaylist( body = playlist, baseUrl = upstreamUrl, @@ -231,6 +255,13 @@ class CastRelayServer( }, ) selectedQuality = rewritten.selectedQuality + log( + "cast_relay action=playlist_edge " + + "path=$path media_sequence=${playlistMetadata.mediaSequence.orEmpty()} " + + "program_date_time=${sanitizeLogValue(playlistMetadata.programDateTime)} " + + "target_duration=${playlistMetadata.targetDuration.orEmpty()} " + + "last_segment_uri=${sanitizeLogValue(playlistMetadata.lastSegmentUri)}", + ) rewritten.body.toByteArray(StandardCharsets.UTF_8) } mimeType = "application/vnd.apple.mpegurl" @@ -239,14 +270,6 @@ class CastRelayServer( headers.putAll(corsHeaders()) headers["Content-Length"] = body.size.toString() - log( - "cast_relay action=response method=${request.method} " + - "path=$path status=${upstream.statusCode} " + - "source=${sourceDescription(upstreamUrl)} " + - "bytes=${body.size} mime=${mimeType.orEmpty()} " + - "playlist=true selected_quality=${selectedQuality.orEmpty()}", - ) - sendResponse( socket = socket, statusCode = upstream.statusCode, @@ -255,6 +278,20 @@ class CastRelayServer( body = body, headOnly = headOnly, ) + log( + "cast_relay action=response method=${request.method} " + + "path=$path status=${upstream.statusCode} " + + "source=${sourceDescription(upstreamUrl)} " + + "upstream_connect_ms=${elapsedMs(requestStartedNs, upstreamConnectedNs)} " + + "upstream_first_byte_ms=${elapsedMsOrMissing(requestStartedNs, firstByteNs)} " + + "total_ms=${elapsedMs(requestStartedNs)} " + + "bytes=${body.size} mime=${mimeType.orEmpty()} " + + "playlist=true selected_quality=${selectedQuality.orEmpty()} " + + "media_sequence=${playlistMetadata.mediaSequence.orEmpty()} " + + "program_date_time=${sanitizeLogValue(playlistMetadata.programDateTime)} " + + "target_duration=${playlistMetadata.targetDuration.orEmpty()} " + + "last_segment_uri=${sanitizeLogValue(playlistMetadata.lastSegmentUri)}", + ) return@use } @@ -271,7 +308,7 @@ class CastRelayServer( } headers.putAll(corsHeaders()) - val bytes = sendStreamingResponse( + val streamResult = sendStreamingResponse( socket = socket, statusCode = upstream.statusCode, reasonPhrase = upstream.reasonPhrase, @@ -283,7 +320,11 @@ class CastRelayServer( "cast_relay action=response method=${request.method} " + "path=$path status=${upstream.statusCode} " + "source=${sourceDescription(upstreamUrl)} " + - "bytes=$bytes mime=${mimeType.orEmpty()} " + + "upstream_connect_ms=${elapsedMs(requestStartedNs, upstreamConnectedNs)} " + + "upstream_first_byte_ms=" + + "${elapsedMsOrMissing(requestStartedNs, streamResult.firstByteNs)} " + + "total_ms=${elapsedMs(requestStartedNs)} " + + "bytes=${streamResult.bytes} mime=${mimeType.orEmpty()} " + "playlist=false selected_quality=", ) } @@ -359,16 +400,50 @@ class CastRelayServer( headers: Map, body: InputStream, headOnly: Boolean, - ): Long { + ): StreamResult { val output = sendResponseHeaders(socket, statusCode, reasonPhrase, headers) if (headOnly) { output.flush() - return 0L + return StreamResult(bytes = 0L, firstByteNs = null) } - val bytes = body.copyTo(output, STREAM_BUFFER_SIZE) + val buffer = ByteArray(STREAM_BUFFER_SIZE) + var bytes = 0L + var firstByteNs: Long? = null + + while (true) { + val count = body.read(buffer) + if (count == -1) break + if (count == 0) continue + if (firstByteNs == null) { + firstByteNs = System.nanoTime() + } + output.write(buffer, 0, count) + bytes += count + } output.flush() - return bytes + return StreamResult(bytes = bytes, firstByteNs = firstByteNs) + } + + private fun readBodyWithTiming(input: InputStream): TimedBody { + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(STREAM_BUFFER_SIZE) + var firstByteNs: Long? = null + + while (true) { + val count = input.read(buffer) + if (count == -1) break + if (count == 0) continue + if (firstByteNs == null) { + firstByteNs = System.nanoTime() + } + output.write(buffer, 0, count) + } + + return TimedBody( + bytes = output.toByteArray(), + firstByteNs = firstByteNs, + ) } private fun sendResponseHeaders( @@ -480,6 +555,55 @@ class CastRelayServer( } } + private fun playlistLiveEdgeMetadata(playlist: String): PlaylistLiveEdgeMetadata { + var mediaSequence: String? = null + var programDateTime: String? = null + var targetDuration: String? = null + var lastSegmentUri: String? = null + + playlist.lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .forEach { line -> + when { + line.startsWith("#EXT-X-MEDIA-SEQUENCE:", ignoreCase = true) -> { + mediaSequence = line.substringAfter(":").trim() + } + line.startsWith("#EXT-X-PROGRAM-DATE-TIME:", ignoreCase = true) -> { + programDateTime = line.substringAfter(":").trim() + } + line.startsWith("#EXT-X-TARGETDURATION:", ignoreCase = true) -> { + targetDuration = line.substringAfter(":").trim() + } + !line.startsWith("#") -> { + lastSegmentUri = line + } + } + } + + return PlaylistLiveEdgeMetadata( + mediaSequence = mediaSequence, + programDateTime = programDateTime, + targetDuration = targetDuration, + lastSegmentUri = lastSegmentUri, + ) + } + + private fun elapsedMs(startNs: Long, endNs: Long = System.nanoTime()): Long { + return (endNs - startNs).coerceAtLeast(0L) / 1_000_000L + } + + private fun elapsedMsOrMissing(startNs: Long, endNs: Long?): Long { + return endNs?.let { elapsedMs(startNs, it) } ?: -1L + } + + private fun sanitizeLogValue(value: String?): String { + return value + ?.replace(Regex("\\s+"), "_") + ?.take(MAX_LOG_VALUE_LENGTH) + .orEmpty() + } + private fun stableTargetId(key: String): String { val digest = MessageDigest.getInstance("SHA-256") .digest(key.toByteArray(StandardCharsets.UTF_8)) @@ -547,9 +671,27 @@ class CastRelayServer( val selectedQuality: String?, ) + private data class TimedBody( + val bytes: ByteArray, + val firstByteNs: Long?, + ) + + private data class StreamResult( + val bytes: Long, + val firstByteNs: Long?, + ) + + private data class PlaylistLiveEdgeMetadata( + val mediaSequence: String? = null, + val programDateTime: String? = null, + val targetDuration: String? = null, + val lastSegmentUri: String? = null, + ) + private companion object { private const val SERVER_BACKLOG = 32 private const val STREAM_BUFFER_SIZE = 64 * 1024 + private const val MAX_LOG_VALUE_LENGTH = 240 private val hopByHopHeaders = setOf( "connection", diff --git a/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServerTest.kt b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServerTest.kt index 42c7ac2a..2d266bff 100644 --- a/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServerTest.kt +++ b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServerTest.kt @@ -168,6 +168,85 @@ class CastRelayServerTest { } } + @Test + fun logsRelayTimingForMediaResponses() { + val logs = mutableListOf() + + RawUpstreamServer { _, output -> + writeHttpResponse( + output = output, + contentType = "video/mp2t", + body = "segment", + ) + }.use { upstream -> + CastRelayServer(log = logs::add).use { relay -> + val relayUrl = localRelayUrl( + relay.relayUrlFor(upstream.url("/segment.ts")), + ) + + val response = get(relayUrl) + + assertEquals("segment", response.body) + val responseLog = logs.firstOrNull { log -> + log.contains("cast_relay action=response") && + log.contains("playlist=false") + } ?: error("Expected media response timing log in $logs") + + assertTrue(responseLog.contains("upstream_connect_ms=")) + assertTrue(responseLog.contains("upstream_first_byte_ms=")) + assertTrue(responseLog.contains("total_ms=")) + assertTrue(responseLog.contains("bytes=7")) + assertTrue(responseLog.contains("playlist=false")) + } + } + } + + @Test + fun logsRelayTimingAndLiveEdgeMetadataForPlaylists() { + val logs = mutableListOf() + + RawUpstreamServer { _, output -> + writeHttpResponse( + output = output, + contentType = "application/vnd.apple.mpegurl", + body = """ + #EXTM3U + #EXT-X-MEDIA-SEQUENCE:777 + #EXT-X-TARGETDURATION:2 + #EXT-X-PROGRAM-DATE-TIME:2026-06-12T10:00:00.000Z + #EXTINF:2.000, + segment-777.ts + #EXT-X-PROGRAM-DATE-TIME:2026-06-12T10:00:02.000Z + #EXTINF:2.000, + segment-778.ts + """.trimIndent(), + ) + }.use { upstream -> + CastRelayServer(log = logs::add).use { relay -> + val relayUrl = localRelayUrl( + relay.relayUrlFor(upstream.url("/live/index.m3u8")), + ) + + val response = get(relayUrl) + + assertTrue(response.body.contains("#EXTM3U")) + val responseLog = logs.firstOrNull { log -> + log.contains("cast_relay action=response") && + log.contains("playlist=true") + } ?: error("Expected playlist response timing log in $logs") + + assertTrue(responseLog.contains("upstream_connect_ms=")) + assertTrue(responseLog.contains("upstream_first_byte_ms=")) + assertTrue(responseLog.contains("total_ms=")) + assertTrue(responseLog.contains("playlist=true")) + assertTrue(responseLog.contains("media_sequence=777")) + assertTrue(responseLog.contains("program_date_time=2026-06-12T10:00:02.000Z")) + assertTrue(responseLog.contains("target_duration=2")) + assertTrue(responseLog.contains("last_segment_uri=segment-778.ts")) + } + } + } + @Test fun rewritesPlaylistResponsesAndAppliesNoCacheHeaders() { RawUpstreamServer { _, output -> diff --git a/cast_receiver/index.html b/cast_receiver/index.html index 92923006..1aeabba0 100644 --- a/cast_receiver/index.html +++ b/cast_receiver/index.html @@ -26,13 +26,16 @@ (() => { const MESSAGE_NAMESPACE = 'urn:x-cast:com.namecallfilter.glacier.cast'; - const TARGET_LATENCY_SECONDS = 2.0; - const MAX_LATENCY_SECONDS = 5.0; + const TARGET_LATENCY_SECONDS = 4.0; + const MAX_LATENCY_SECONDS = 8.0; + const EMERGENCY_LATENCY_SECONDS = 10.0; const STATUS_INTERVAL_MS = 1000; const LATENCY_CHECK_INTERVAL_MS = 500; const STARTUP_LIVE_SEEK_THRESHOLD_SECONDS = TARGET_LATENCY_SECONDS + 0.5; const MAX_LATENCY_SEEK_COOLDOWN_MS = 8000; + const MAX_LATENCY_RELOAD_COOLDOWN_MS = 15000; + const HIGH_LATENCY_RELOAD_TICKS = 3; const context = cast.framework.CastReceiverContext.getInstance(); const playerManager = context.getPlayerManager(); @@ -65,7 +68,7 @@ maxPlaybackRate: 1.1, minPlaybackRate: 0.98, - panicMode: false, + panicMode: true, panicThreshold: MAX_LATENCY_SECONDS, dynamicTargetLatency: { @@ -113,6 +116,18 @@ let pendingStartupLiveSeek = false; let lastMaxLatencySeekAtMs = 0; + let lastMaxLatencyReloadAtMs = 0; + let emergencyRecoveryActive = false; + let emergencyTicksAfterSeek = 0; + let lastLoadRequestData = null; + + const logReceiverEvent = (action, fields = {}) => { + const details = Object.entries({action, ...fields}) + .map(([key, value]) => `${key}=${value == null ? '' : value}`) + .join(' '); + + console.log(`[GlacierCastReceiver] ${details}`); + }; const currentPlaybackRate = () => { if (typeof playerManager.getPlaybackRate === 'function') { @@ -195,6 +210,32 @@ return ok; }; + const reloadCurrentMedia = () => { + if (!lastLoadRequestData || typeof playerManager.load !== 'function') { + return false; + } + + lastLoadRequestData.autoplay = true; + pendingStartupLiveSeek = true; + + try { + const loadResult = playerManager.load(lastLoadRequestData); + if (loadResult && typeof loadResult.catch === 'function') { + loadResult.catch((error) => { + logReceiverEvent('reload_failed', { + reason: error && error.name ? error.name : 'unknown', + }); + }); + } + return true; + } catch (error) { + logReceiverEvent('reload_failed', { + reason: error && error.name ? error.name : 'unknown', + }); + return false; + } + }; + const tryStartupLiveSeek = () => { if (!pendingStartupLiveSeek) return false; @@ -209,6 +250,11 @@ if (status.latency <= STARTUP_LIVE_SEEK_THRESHOLD_SECONDS) return false; const ok = seekToTargetLive(); + if (ok && status.latency > EMERGENCY_LATENCY_SECONDS) { + lastMaxLatencySeekAtMs = Date.now(); + emergencyRecoveryActive = true; + emergencyTicksAfterSeek = 0; + } sendStatus({ correction: ok ? 'startupJumpToLive' : 'startupJumpFailed', latencyBeforeCorrectionMs: Math.round(status.latency * 1000), @@ -218,10 +264,71 @@ }; const enforceMaxLatencySeek = (status) => { - if (status.latency <= MAX_LATENCY_SECONDS) return; - if (Date.now() - lastMaxLatencySeekAtMs < MAX_LATENCY_SEEK_COOLDOWN_MS) return; + if (status.latency <= MAX_LATENCY_SECONDS) { + emergencyRecoveryActive = false; + emergencyTicksAfterSeek = 0; + return; + } - lastMaxLatencySeekAtMs = Date.now(); + const now = Date.now(); + const latencyBeforeCorrectionMs = Math.round(status.latency * 1000); + const emergency = status.latency > EMERGENCY_LATENCY_SECONDS; + + logReceiverEvent('max_latency', { + state: playerManager.getPlayerState(), + latency_ms: latencyBeforeCorrectionMs, + max_latency_ms: Math.round(MAX_LATENCY_SECONDS * 1000), + target_latency_ms: Math.round(TARGET_LATENCY_SECONDS * 1000), + current_sec: status.currentTime, + range_start_sec: status.rangeStart, + range_end_sec: status.rangeEnd, + emergency, + }); + + if (emergency && emergencyRecoveryActive) { + emergencyTicksAfterSeek += 1; + + if (emergencyTicksAfterSeek >= HIGH_LATENCY_RELOAD_TICKS) { + const reloadCooldownActive = + lastMaxLatencyReloadAtMs > 0 && + now - lastMaxLatencyReloadAtMs < MAX_LATENCY_RELOAD_COOLDOWN_MS; + + if (!reloadCooldownActive) { + lastMaxLatencyReloadAtMs = now; + emergencyRecoveryActive = false; + emergencyTicksAfterSeek = 0; + const ok = reloadCurrentMedia(); + + sendStatus({ + correction: ok ? 'maxLatencyReload' : 'maxLatencyReloadFailed', + latencyBeforeCorrectionMs, + }); + return; + } + } + + sendStatus({ + correction: 'maxLatencyObserved', + latencyBeforeCorrectionMs, + }); + return; + } + + const seekCooldownActive = + lastMaxLatencySeekAtMs > 0 && + now - lastMaxLatencySeekAtMs < MAX_LATENCY_SEEK_COOLDOWN_MS; + + if (!emergency && seekCooldownActive) { + sendStatus({ + correction: 'maxLatencyObserved', + latencyBeforeCorrectionMs, + }); + return; + } + + lastMaxLatencySeekAtMs = now; + emergencyRecoveryActive = emergency; + emergencyTicksAfterSeek = 0; const ok = seekToTargetLive(); sendStatus({ @@ -251,6 +358,9 @@ pendingStartupLiveSeek = true; lastMaxLatencySeekAtMs = 0; + lastMaxLatencyReloadAtMs = 0; + emergencyRecoveryActive = false; + emergencyTicksAfterSeek = 0; if (media) { media.entity = media.entity || media.contentId; @@ -260,6 +370,7 @@ } loadRequestData.autoplay = true; + lastLoadRequestData = loadRequestData; return loadRequestData; }, ); diff --git a/cast_receiver/index.test.mjs b/cast_receiver/index.test.mjs new file mode 100644 index 00000000..c8f46b71 --- /dev/null +++ b/cast_receiver/index.test.mjs @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const receiverHtml = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8'); +const inlineScriptBody = receiverHtml.match( + /