fix(cast): keep relay alive during casting

This commit is contained in:
kaoru 2026-06-14 19:12:57 -04:00
parent 55c6646684
commit 93bbc3e9c3
13 changed files with 801 additions and 34 deletions

View file

@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<queries>
<intent>
@ -52,6 +55,10 @@
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.namecallfilter.glacier.cast.GlacierCastOptionsProvider" />
<service
android:name=".cast.CastRelayKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View file

@ -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),
)
}
}
}

View file

@ -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(

View file

@ -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<String, String>,
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",

View file

@ -168,6 +168,85 @@ class CastRelayServerTest {
}
}
@Test
fun logsRelayTimingForMediaResponses() {
val logs = mutableListOf<String>()
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<String>()
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 ->

View file

@ -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;
},
);

View file

@ -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(
/<script>\s*\(\(\) => \{([\s\S]*)\}\)\(\);\s*<\/script>/,
)?.[1];
if (!inlineScriptBody) {
throw new Error('Unable to find Cast receiver inline script');
}
const inlineScript = `(() => {${inlineScriptBody}})();`;
const createHarness = () => {
let nowMs = 0;
const intervals = [];
const sentMessages = [];
const consoleMessages = [];
const messageListeners = new Map();
const loadInterceptors = new Map();
const seekCalls = [];
const loadCalls = [];
let liveRange = {start: 0, end: 0};
let currentTimeSec = 0;
let playerState = 'PLAYING';
let playbackRate = 1.0;
let playbackConfig = null;
let receiverOptions = null;
const playerManager = {
getLiveSeekableRange: () => liveRange,
getCurrentTimeSec: () => currentTimeSec,
getPlayerState: () => playerState,
getPlaybackRate: () => playbackRate,
setPlaybackConfig: (config) => {
playbackConfig = config;
},
setMessageInterceptor: (type, interceptor) => {
loadInterceptors.set(type, interceptor);
},
seek: (request) => {
seekCalls.push(request);
currentTimeSec = request.currentTime;
},
load: (request) => {
loadCalls.push(request);
return Promise.resolve();
},
};
const receiverContext = {
getPlayerManager: () => playerManager,
getSenders: () => [{id: 'sender-1'}],
sendCustomMessage: (_namespace, _senderId, message) => {
sentMessages.push(message);
},
addCustomMessageListener: (namespace, listener) => {
messageListeners.set(namespace, listener);
},
start: (options) => {
receiverOptions = options;
},
};
const sandbox = {
cast: {
framework: {
CastReceiverContext: {
getInstance: () => receiverContext,
},
PlaybackConfig: function PlaybackConfig() {},
CastReceiverOptions: function CastReceiverOptions() {},
messages: {
MessageType: {
LOAD: 'LOAD',
},
ResumeState: {
PLAYBACK_START: 'PLAYBACK_START',
},
StreamType: {
LIVE: 'LIVE',
},
},
},
},
console: {
log: (message) => {
consoleMessages.push(String(message));
},
},
Date: {
now: () => nowMs,
},
document: {
querySelector: () => ({playbackRate}),
},
JSON,
Math,
Number,
setInterval: (callback, delayMs) => {
intervals.push({callback, delayMs});
return intervals.length;
},
};
vm.runInNewContext(inlineScript, sandbox);
const runInterval = (delayMs) => {
const interval = intervals.find((candidate) => candidate.delayMs === delayMs);
if (!interval) {
throw new Error(`Missing interval ${delayMs}`);
}
interval.callback();
};
const runLoadInterceptor = (loadRequestData) => {
const interceptor = loadInterceptors.get('LOAD');
if (!interceptor) {
throw new Error('Missing LOAD interceptor');
}
return interceptor(loadRequestData);
};
return {
get playbackConfig() {
return playbackConfig;
},
get receiverOptions() {
return receiverOptions;
},
sentMessages,
consoleMessages,
seekCalls,
loadCalls,
setLiveStatus({rangeStart = 0, rangeEnd, currentTime}) {
liveRange = {start: rangeStart, end: rangeEnd};
currentTimeSec = currentTime;
},
setPlayerState(nextState) {
playerState = nextState;
},
advance(ms) {
nowMs += ms;
},
runLatencyCheck() {
runInterval(500);
},
runStatusReport() {
runInterval(1000);
},
runLoadInterceptor,
};
};
test('configures Shaka for stable low-latency live playback', () => {
const harness = createHarness();
assert.equal(harness.receiverOptions.useShakaForHls, true);
assert.equal(harness.playbackConfig.shakaConfig.streaming.lowLatencyMode, true);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.enabled, true);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.targetLatency, 4.0);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.maxPlaybackRate, 1.1);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.minPlaybackRate, 0.98);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.panicMode, true);
assert.equal(harness.playbackConfig.shakaConfig.streaming.liveSync.panicThreshold, 8.0);
assert.equal(harness.playbackConfig.shakaConfig.manifest.defaultPresentationDelay, 4.0);
});
test('emergency latency bypasses normal seek cooldown', () => {
const harness = createHarness();
harness.setPlayerState('BUFFERING');
harness.setLiveStatus({rangeEnd: 50, currentTime: 41});
harness.runLatencyCheck();
assert.equal(harness.seekCalls.length, 1);
assert.equal(harness.seekCalls[0].currentTime, 46);
harness.advance(1000);
harness.setLiveStatus({rangeEnd: 52, currentTime: 41});
harness.runLatencyCheck();
assert.equal(harness.seekCalls.length, 2);
assert.equal(harness.seekCalls[1].currentTime, 48);
assert.ok(
harness.consoleMessages.some((message) =>
message.includes('action=max_latency') &&
message.includes('latency_ms=11000') &&
message.includes('emergency=true'),
),
);
});
test('reloads the current media after sustained emergency latency', () => {
const harness = createHarness();
const loadRequestData = {
media: {
contentId: 'http://127.0.0.1:4000/relay/source.m3u8',
},
};
const interceptedLoad = harness.runLoadInterceptor(loadRequestData);
harness.setPlayerState('BUFFERING');
harness.setLiveStatus({rangeEnd: 100, currentTime: 80});
harness.runLatencyCheck();
assert.equal(harness.seekCalls.length, 1);
for (let index = 0; index < 3; index += 1) {
harness.advance(500);
harness.setLiveStatus({rangeEnd: 100 + index + 1, currentTime: 80});
harness.runLatencyCheck();
}
assert.equal(harness.loadCalls.length, 1);
assert.equal(harness.loadCalls[0], interceptedLoad);
assert.equal(harness.loadCalls[0].autoplay, true);
assert.equal(harness.loadCalls[0].media.streamType, 'LIVE');
assert.ok(
harness.sentMessages.some((message) => message.correction === 'maxLatencyReload'),
);
});
test('does not keep old custom stall recovery logic', () => {
assert.equal(receiverHtml.includes('STALL_RECOVERY_'), false);
assert.equal(receiverHtml.includes('isPlaybackStalled'), false);
assert.equal(receiverHtml.includes('recoverStalledPlayback'), false);
assert.equal(receiverHtml.includes('jumpToLiveWithCooldown'), false);
});

View file

@ -228,7 +228,7 @@ class _ConnectedCastControls extends StatelessWidget {
@override
Widget build(BuildContext context) {
final receiverName = castState.receiverName ?? 'Cast device';
final latency = castState.latencySeconds;
final latency = castState.formattedLatency;
return Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
@ -241,7 +241,7 @@ class _ConnectedCastControls extends StatelessWidget {
title: Text(receiverName),
subtitle: latency == null
? const Text('Casting')
: Text('Casting - ${latency}s latency'),
: Text('Casting - $latency latency'),
trailing: TextButton(
onPressed: () async {
Navigator.of(context).pop();

View file

@ -481,13 +481,13 @@ class VideoOverlay extends StatelessWidget {
StreamProxyBridge.castState,
builder: (context, castState, _) {
final castLatency =
castState.latencySeconds;
castState.formattedLatency;
return Observer(
builder: (context) => Text(
castState.isCasting &&
castLatency != null
? '${castLatency}s'
? castLatency
: videoStore.latency ?? '—',
style: TextStyle(
color: surfaceColor,

View file

@ -738,11 +738,10 @@ abstract class VideoStoreBase with Store {
if (latencyElement && latencyElement.textContent) {
let latencyText = latencyElement.textContent.trim();
// Convert to whole number with abbreviated unit: "4.69 sec." -> "5s"
// Convert to abbreviated unit: "4.69 sec." -> "4.69s"
const match = latencyText.match(/([0-9.]+)\s*sec/i);
if (match) {
const rounded = Math.round(parseFloat(match[1]));
latencyText = rounded + 's';
latencyText = parseFloat(match[1]).toFixed(2) + 's';
this.hasInitialLatency = true;
}

View file

@ -10,7 +10,15 @@ class CastState {
receiverName = null,
latency = null;
int? get latencySeconds => latency?.inSeconds;
double? get latencySeconds {
final currentLatency = latency;
return currentLatency == null ? null : currentLatency.inMilliseconds / 1000;
}
String? get formattedLatency {
final seconds = latencySeconds;
return seconds == null ? null : '${seconds.toStringAsFixed(2)}s';
}
factory CastState.fromMethodChannelPayload(Object? payload) {
if (payload is! Map) return const CastState.disconnected();

View file

@ -69,7 +69,7 @@ void main() {
expect(find.text('CAST TO DEVICE'), findsOneWidget);
expect(find.text('Jacoby\'s TV'), findsOneWidget);
expect(find.text('Casting - 7s latency'), findsOneWidget);
expect(find.text('Casting - 7.42s latency'), findsOneWidget);
expect(find.text('Disconnect'), findsOneWidget);
});

View file

@ -13,7 +13,8 @@ void main() {
expect(state.isCasting, isTrue);
expect(state.receiverName, 'Jacoby\'s TV');
expect(state.latency, const Duration(milliseconds: 7420));
expect(state.latencySeconds, 7);
expect(state.latencySeconds, 7.42);
expect(state.formattedLatency, '7.42s');
});
test('treats missing and malformed payloads as disconnected', () {