fix(cast): keep relay alive during casting
This commit is contained in:
parent
55c6646684
commit
93bbc3e9c3
13 changed files with 801 additions and 34 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
234
cast_receiver/index.test.mjs
Normal file
234
cast_receiver/index.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue