Add Google Cast support with local HLS relay

- Add Cast receiver configuration and Android glue
- Proxy and rewrite HLS playback for Chromecast
- Surface cast state and controls in the Flutter UI
This commit is contained in:
kaoru 2026-06-10 12:55:48 -04:00
parent 9b7decab98
commit dbf606c1da
38 changed files with 3485 additions and 1076 deletions

View file

@ -1,2 +1,3 @@
CLIENT_ID=
SECRET=
CAST_RECEIVER_APP_ID=

View file

@ -14,6 +14,40 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
val envProperties = Properties()
val envPropertiesFile = rootProject.file("../.env")
if (envPropertiesFile.exists()) {
envPropertiesFile.inputStream().use { envInput ->
envInput
.bufferedReader()
.lineSequence()
.map(String::trim)
.filter { line -> line.isNotEmpty() && !line.startsWith("#") }
.forEach { line ->
val separatorIndex = line.indexOf("=")
if (separatorIndex > 0) {
val key = line.substring(0, separatorIndex).trim()
val value = line.substring(separatorIndex + 1)
.trim()
.trim('"')
.trim('\'')
envProperties.setProperty(key, value)
}
}
}
}
fun resolveConfigValue(name: String): String {
return providers.gradleProperty(name).orNull
?: System.getenv(name)
?: envProperties.getProperty(name)
?: ""
}
fun String.asAndroidStringResource(): String {
return "\"${replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
android {
namespace = "com.namecallfilter.glacier"
compileSdk = flutter.compileSdkVersion
@ -34,6 +68,11 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
resValue(
"string",
"cast_receiver_app_id",
resolveConfigValue("CAST_RECEIVER_APP_ID").asAndroidStringResource(),
)
}
signingConfigs {
@ -46,6 +85,10 @@ android {
}
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
// Use the real upload key when key.properties is present (release
// workflows create it from secrets). Allow debug signing only when a CI
@ -67,5 +110,6 @@ flutter {
dependencies {
implementation("androidx.webkit:webkit:1.15.0")
implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
testImplementation("junit:junit:4.13.2")
}

View file

@ -0,0 +1 @@
<resources><string name="app_name">Glacier Debug</string></resources>

View file

@ -1,67 +1,70 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="Glacier"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"/>
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"/>
<data android:host="twitch.tv" />
<data android:host="www.twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.namecallfilter.glacier.cast.GlacierCastOptionsProvider" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View file

@ -1,12 +1,25 @@
package com.namecallfilter.glacier
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import android.content.res.Configuration
import cl.puntito.simple_pip_mode.PipCallbackHelper
import com.namecallfilter.glacier.streamproxy.StreamProxyChannel
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() {
class MainActivity: FlutterFragmentActivity() {
private val pipCallbackHelper = PipCallbackHelper()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
StreamProxyChannel.register(flutterEngine)
pipCallbackHelper.configureFlutterEngine(flutterEngine)
StreamProxyChannel.register(flutterEngine, this)
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipCallbackHelper.onPictureInPictureModeChanged(isInPictureInPictureMode)
}
}

View file

@ -0,0 +1,69 @@
package com.namecallfilter.glacier.cast
object CastReceiverMessageParser {
fun parse(message: String): CastReceiverStatus? {
if (stringField(message, "type") != "status") return null
val latencyMs = numberField(message, "latencyMs")
?.takeIf { it >= 0 }
return CastReceiverStatus(
latencyMs = latencyMs,
playerState = stringField(message, "playerState"),
currentTimeSec = doubleField(message, "currentTimeSec"),
rangeStartSec = doubleField(message, "rangeStartSec"),
rangeEndSec = doubleField(message, "rangeEndSec"),
targetLatencySec = doubleField(message, "targetLatencySec"),
)
}
private fun stringField(message: String, name: String): String? {
return rawField(message, name)
?.takeIf { value -> !value.startsWith("{") && !value.startsWith("[") }
?.takeIf(String::isNotBlank)
}
private fun numberField(message: String, name: String): Long? {
return rawField(message, name)?.toLongOrNull()
}
private fun doubleField(message: String, name: String): Double? {
return rawField(message, name)?.toDoubleOrNull()
}
private fun rawField(message: String, name: String): String? {
val keyIndex = message.indexOf("\"$name\"")
if (keyIndex < 0) return null
val colonIndex = message.indexOf(":", startIndex = keyIndex + name.length + 2)
if (colonIndex < 0) return null
var valueIndex = colonIndex + 1
while (valueIndex < message.length && message[valueIndex].isWhitespace()) {
valueIndex += 1
}
if (valueIndex >= message.length) return null
if (message[valueIndex] == '"') {
val endIndex = message.indexOf('"', startIndex = valueIndex + 1)
if (endIndex < 0) return null
return message.substring(valueIndex + 1, endIndex)
}
val endIndex = message.indexOfAny(
chars = charArrayOf(',', '}'),
startIndex = valueIndex,
).takeIf { it >= 0 } ?: message.length
return message.substring(valueIndex, endIndex).trim()
}
}
data class CastReceiverStatus(
val latencyMs: Long?,
val playerState: String?,
val currentTimeSec: Double?,
val rangeStartSec: Double?,
val rangeEndSec: Double?,
val targetLatencySec: Double?,
)

View file

@ -0,0 +1,391 @@
package com.namecallfilter.glacier.cast
import android.app.Activity
import android.content.Context
import android.util.Log
import android.view.ContextThemeWrapper
import android.view.Gravity
import android.widget.FrameLayout
import androidx.mediarouter.app.MediaRouteButton
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.CastButtonFactory
import com.namecallfilter.glacier.R
import com.namecallfilter.glacier.streamproxy.CastRelayServer
import com.namecallfilter.glacier.streamproxy.StreamProxyConfig
import com.namecallfilter.glacier.streamproxy.StreamProxySessionRegistry
class GlacierCastController(
context: Context,
private val onStateChanged: (Map<String, Any?>) -> Unit = {},
) {
private val activity = context as? Activity
private val applicationContext = context.applicationContext
private val receiverApplicationIdDescription =
GlacierCastReceiverConfig.maskedReceiverApplicationId(applicationContext)
private val relayServer = CastRelayServer(::log)
private val sessionListener = CastSessionListener()
@Volatile
private var castContext: CastContext? = null
@Volatile
private var streamContext: CastStreamContext? = null
@Volatile
private var pendingLoad = false
@Volatile
private var receiverLatencyMs: Long? = null
@Volatile
private var routeButton: MediaRouteButton? = null
init {
runCatching {
CastContext.getSharedInstance(applicationContext)
}.onSuccess { context ->
castContext = context
context.sessionManager.addSessionManagerListener(
sessionListener,
CastSession::class.java,
)
emitState(context.sessionManager.currentCastSession)
}.onFailure { error ->
Log.d(LOG_TAG, "cast action=init_failed reason=${error.javaClass.simpleName}")
}
}
fun updateContext(context: CastStreamContext) {
streamContext = context
updateRelay(context)
if (pendingLoad && currentSession()?.isConnected == true) {
loadCurrent()
}
}
fun prepareLoad() {
pendingLoad = true
if (currentSession()?.isConnected == true) {
loadCurrent()
}
}
fun showCastDialog() {
val session = currentSession()
if (session?.isConnected == true) {
emitState(session)
return
}
prepareLoad()
val button = routeButton ?: ensureRouteButton()
if (button == null) {
Log.d(LOG_TAG, "cast_button action=show_failed reason=route_button_unavailable")
return
}
button.post {
runCatching {
button.performClick()
}.onFailure { error ->
Log.d(
LOG_TAG,
"cast_button action=show_failed reason=${error.javaClass.simpleName} " +
"message=${error.message}",
error,
)
}
}
}
fun stopCasting() {
pendingLoad = false
receiverLatencyMs = null
castContext?.sessionManager?.endCurrentSession(true)
relayServer.close()
emitDisconnected()
}
private fun ensureRouteButton(): MediaRouteButton? {
routeButton?.let { button ->
if (button.isAttachedToWindow) return button
}
val activity = activity ?: return null
return try {
val buttonContext = ContextThemeWrapper(
activity,
R.style.GlacierCastRouteButtonTheme,
)
val button = object : MediaRouteButton(buttonContext) {
override fun performClick(): Boolean {
prepareLoad()
return super.performClick()
}
}
button.alpha = 0f
@Suppress("DEPRECATION")
button.setAlwaysVisible(true)
CastButtonFactory.setUpMediaRouteButton(activity, button)
activity.addContentView(
button,
FrameLayout.LayoutParams(
1,
1,
Gravity.TOP or Gravity.START,
),
)
routeButton = button
Log.d(LOG_TAG, "cast_button action=attached")
button
} catch (error: Exception) {
Log.d(
LOG_TAG,
"cast_button action=setup_failed reason=${error.javaClass.simpleName} " +
"message=${error.message}",
error,
)
null
}
}
private fun loadCurrent() {
val context = streamContext ?: return
val router = StreamProxySessionRegistry.routerFor(context.webViewIdentifier)
if (router == null) {
log("cast action=load_failed reason=missing_router")
return
}
updateRelay(context)
val manifestUrl = router.latestUsherManifestUrl(context.channelLogin)
if (manifestUrl == null) {
log("cast action=load_failed reason=missing_manifest channel=${context.channelLogin}")
return
}
val relayUrl = relayServer.relayUrlFor(
sourceUrl = manifestUrl,
selectedQuality = context.quality,
)
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC).apply {
putString(MediaMetadata.KEY_TITLE, context.title)
context.subtitle
?.takeIf(String::isNotBlank)
?.let { subtitle ->
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
}
}
val mediaInfo = MediaInfo.Builder(relayUrl, relayUrl)
.setStreamType(MediaInfo.STREAM_TYPE_LIVE)
.setContentType(HLS_CONTENT_TYPE)
.setContentUrl(relayUrl)
.setEntity(relayUrl)
.setMetadata(metadata)
.build()
val request = MediaLoadRequestData.Builder()
.setMediaInfo(mediaInfo)
.setAutoplay(true)
.build()
val remoteMediaClient = currentSession()?.remoteMediaClient
if (remoteMediaClient == null) {
log("cast action=load_failed reason=missing_remote_media_client")
return
}
remoteMediaClient.load(request).setResultCallback { result ->
val status = result.status
Log.d(
LOG_TAG,
"cast action=load_result success=${status.isSuccess} " +
"status=${status.statusCode} " +
"message=${status.statusMessage ?: ""} " +
"relay=$relayUrl",
)
}
pendingLoad = false
emitState(currentSession())
log("cast action=load relay=$relayUrl quality=${context.quality}")
}
private fun updateRelay(context: CastStreamContext) {
val router = StreamProxySessionRegistry.getOrCreateRouter(context.webViewIdentifier)
relayServer.update(
router = router,
config = context.config,
)
}
private fun currentSession(): CastSession? {
return castContext?.sessionManager?.currentCastSession
}
private fun attachReceiverChannel(session: CastSession) {
runCatching {
session.setMessageReceivedCallbacks(CAST_NAMESPACE) { _, _, message ->
val status = CastReceiverMessageParser.parse(message)
?: return@setMessageReceivedCallbacks
receiverLatencyMs = status.latencyMs ?: receiverLatencyMs
logReceiverStatus(status)
emitState(session)
}
}.onFailure { error ->
log("cast action=receiver_channel_failed reason=${error.javaClass.simpleName}")
}
}
private fun logReceiverStatus(status: CastReceiverStatus) {
log(
"cast action=receiver_status " +
"state=${status.playerState ?: ""} " +
"latency_ms=${status.latencyMs ?: -1} " +
"current_sec=${status.currentTimeSec ?: -1.0} " +
"range_start_sec=${status.rangeStartSec ?: -1.0} " +
"range_end_sec=${status.rangeEndSec ?: -1.0} " +
"target_sec=${status.targetLatencySec ?: -1.0}",
)
}
private fun detachReceiverChannel(session: CastSession) {
runCatching {
session.removeMessageReceivedCallbacks(CAST_NAMESPACE)
}
}
private fun emitState(session: CastSession? = currentSession()) {
val isCasting = session?.isConnected == true
onStateChanged(
mapOf(
"isCasting" to isCasting,
"receiverName" to session
?.takeIf { isCasting }
?.castDevice
?.friendlyName,
"latencyMs" to receiverLatencyMs.takeIf { isCasting },
),
)
}
private fun emitDisconnected() {
onStateChanged(
mapOf(
"isCasting" to false,
"receiverName" to null,
"latencyMs" to null,
),
)
}
private fun log(message: String) {
if (streamContext?.config?.debugLogging == true) {
Log.d(LOG_TAG, message)
}
}
private inner class CastSessionListener : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) = Unit
override fun onSessionStarted(session: CastSession, sessionId: String) {
receiverLatencyMs = null
attachReceiverChannel(session)
emitState(session)
if (pendingLoad) {
loadCurrent()
}
}
override fun onSessionStartFailed(session: CastSession, error: Int) {
pendingLoad = false
receiverLatencyMs = null
emitDisconnected()
Log.d(
LOG_TAG,
"cast action=session_start_failed error=$error " +
"status=${castStatusName(error)} " +
"receiver=$receiverApplicationIdDescription",
)
}
override fun onSessionEnding(session: CastSession) {
detachReceiverChannel(session)
}
override fun onSessionEnded(session: CastSession, error: Int) {
pendingLoad = false
receiverLatencyMs = null
relayServer.close()
emitDisconnected()
Log.d(
LOG_TAG,
"cast action=session_ended error=$error " +
"status=${castStatusName(error)} " +
"receiver=$receiverApplicationIdDescription",
)
}
override fun onSessionResuming(session: CastSession, sessionId: String) = Unit
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
attachReceiverChannel(session)
emitState(session)
if (pendingLoad) {
loadCurrent()
}
}
override fun onSessionResumeFailed(session: CastSession, error: Int) {
receiverLatencyMs = null
emitDisconnected()
Log.d(
LOG_TAG,
"cast action=session_resume_failed error=$error " +
"status=${castStatusName(error)} " +
"receiver=$receiverApplicationIdDescription",
)
}
override fun onSessionSuspended(session: CastSession, reason: Int) {
emitState(session)
log("cast action=session_suspended reason=$reason")
}
}
private companion object {
private const val LOG_TAG = "GlacierCast"
private const val CAST_NAMESPACE = "urn:x-cast:com.namecallfilter.glacier.cast"
private const val HLS_CONTENT_TYPE = "application/x-mpegURL"
private fun castStatusName(error: Int): String {
return when (error) {
0 -> "SUCCESS"
2001 -> "TIMEOUT"
2002 -> "CANCELED"
2003 -> "INTERRUPTED"
2004 -> "APPLICATION_NOT_FOUND"
2005 -> "APPLICATION_NOT_RUNNING"
2100 -> "AUTHENTICATION_FAILED"
else -> "UNKNOWN"
}
}
}
}
data class CastStreamContext(
val webViewIdentifier: Long,
val channelLogin: String,
val title: String,
val subtitle: String?,
val quality: String?,
val config: StreamProxyConfig,
)

View file

@ -0,0 +1,20 @@
package com.namecallfilter.glacier.cast
import android.content.Context
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
class GlacierCastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId(GlacierCastReceiverConfig.receiverApplicationId(context))
.setCastMediaOptions(CastMediaOptions.Builder().build())
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}

View file

@ -0,0 +1,39 @@
package com.namecallfilter.glacier.cast
import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
import com.namecallfilter.glacier.R
object GlacierCastReceiverConfig {
fun receiverApplicationId(context: Context): String {
return customReceiverApplicationId(context)
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
}
fun maskedReceiverApplicationId(context: Context): String {
val customId = customReceiverApplicationId(context)
val receiverId = customId ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
val source = if (customId == null) "default" else "custom"
return "$source:${receiverId.masked()}"
}
private fun customReceiverApplicationId(context: Context): String? {
return context
.getString(R.string.cast_receiver_app_id)
.trim()
.takeIf(String::isNotEmpty)
}
private fun String.masked(): String {
if (length <= MASK_VISIBLE_CHARACTERS) {
return "*".repeat(length)
}
return "${take(MASK_VISIBLE_PREFIX)}***${takeLast(MASK_VISIBLE_SUFFIX)}"
}
private const val MASK_VISIBLE_PREFIX = 3
private const val MASK_VISIBLE_SUFFIX = 2
private const val MASK_VISIBLE_CHARACTERS = MASK_VISIBLE_PREFIX + MASK_VISIBLE_SUFFIX
}

View file

@ -0,0 +1,462 @@
package com.namecallfilter.glacier.streamproxy
import java.io.Closeable
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.net.URI
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Collections
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
class CastRelayServer(
private val log: (String) -> Unit,
) : Closeable {
private val targets = ConcurrentHashMap<String, RelayTarget>()
private val targetIds = ConcurrentHashMap<String, String>()
private val fetcher = StreamProxyFetcher(log)
@Volatile
private var router = StreamProxyRequestRouter()
@Volatile
private var config = StreamProxyConfig(
mode = StreamProxyMode.OFF,
currentChannelLogin = "",
proxyUrls = emptyList(),
whitelistedChannels = emptySet(),
debugLogging = false,
)
@Volatile
private var serverSocket: ServerSocket? = null
@Volatile
private var baseUrl: String? = null
fun update(
router: StreamProxyRequestRouter,
config: StreamProxyConfig,
) {
this.router = router
this.config = config
}
fun relayUrlFor(
sourceUrl: String,
selectedQuality: String? = null,
): String {
ensureStarted()
val relayBaseUrl = baseUrl ?: error("Cast relay did not start")
val key = "$sourceUrl\n${selectedQuality.orEmpty()}"
val id = targetIds.getOrPut(key) {
stableTargetId(key)
}
targets[id] = RelayTarget(
sourceUrl = sourceUrl,
selectedQuality = selectedQuality,
)
return "$relayBaseUrl/relay/$id.${extensionFor(sourceUrl)}"
}
override fun close() {
serverSocket?.close()
serverSocket = null
baseUrl = null
targets.clear()
targetIds.clear()
}
private fun ensureStarted() {
if (serverSocket != null) return
synchronized(this) {
if (serverSocket != null) return
val socket = ServerSocket(
0,
SERVER_BACKLOG,
InetAddress.getByName("0.0.0.0"),
)
serverSocket = socket
baseUrl = "http://${lanAddress()}:${socket.localPort}"
Thread {
acceptLoop(socket)
}.apply {
isDaemon = true
name = "GlacierCastRelay"
start()
}
log("cast_relay action=started url=$baseUrl")
}
}
private fun acceptLoop(socket: ServerSocket) {
while (!socket.isClosed) {
try {
val client = socket.accept()
Thread {
handleClient(client)
}.apply {
isDaemon = true
name = "GlacierCastRelayClient"
start()
}
} catch (error: Exception) {
if (!socket.isClosed) {
log("cast_relay action=accept_failed reason=${error.javaClass.simpleName}")
}
}
}
}
private fun handleClient(socket: Socket) {
socket.use { client ->
try {
val request = readRequest(client)
?: return
log(
"cast_relay action=request method=${request.method} " +
"path=${request.target.substringBefore("?")} " +
"from=${client.inetAddress.hostAddress}",
)
when (request.method.uppercase(Locale.US)) {
"OPTIONS" -> sendResponse(
socket = client,
statusCode = 204,
reasonPhrase = "No Content",
headers = corsHeaders(),
body = ByteArray(0),
headOnly = true,
)
"GET",
"HEAD" -> handleRelayRequest(client, request)
else -> sendError(client, 405, "Method Not Allowed")
}
} catch (error: Exception) {
log("cast_relay action=request_failed reason=${error.javaClass.simpleName}")
runCatching { sendError(client, 500, "Internal Server Error") }
}
}
}
private fun handleRelayRequest(socket: Socket, request: HttpRequest) {
val path = request.target.substringBefore("?")
if (!path.startsWith("/relay/")) {
sendError(socket, 404, "Not Found")
return
}
val id = path
.removePrefix("/relay/")
.substringBefore(".")
.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")
return
}
val currentConfig = config
val requestHeaders = CastRelayUpstreamHeaders.build(
requestHeaders = request.headers,
config = currentConfig,
)
val upstreamMethod = if (request.method.equals("HEAD", ignoreCase = true)) {
"HEAD"
} else {
"GET"
}
val upstreamRequest = StreamProxyRequest(
url = target.sourceUrl,
method = upstreamMethod,
headers = requestHeaders,
)
val decision = router.route(
url = target.sourceUrl,
method = upstreamMethod,
headers = requestHeaders,
config = currentConfig,
)
val response = fetcher.fetch(
request = upstreamRequest,
decision = decision,
config = currentConfig,
router = router,
directWhenSkipped = true,
)
if (response == null) {
log("cast_relay action=response path=$path status=502 reason=upstream_null")
sendError(socket, 502, "Bad Gateway")
return
}
response.use { upstream ->
val headOnly = request.method.equals("HEAD", ignoreCase = true)
var body = if (headOnly) {
ByteArray(0)
} else {
upstream.body.readBytes()
}
val headers = responseHeaders(upstream)
var mimeType = upstream.mimeType
var selectedQuality: String? = null
if (!headOnly && isPlaylistResponse(target.sourceUrl, upstream)) {
val playlist = decodeBody(body, upstream.encoding)
val rewritten = HlsPlaylistRewriter.rewritePlaylist(
body = playlist,
baseUrl = target.sourceUrl,
selectedQuality = target.selectedQuality,
rewriteUrl = { nestedSourceUrl ->
relayUrlFor(nestedSourceUrl)
},
)
body = rewritten.body.toByteArray(StandardCharsets.UTF_8)
selectedQuality = rewritten.selectedQuality
mimeType = "application/vnd.apple.mpegurl"
headers["Content-Type"] = "$mimeType; charset=utf-8"
} else if (mimeType != null && headers.keys.none {
it.equals("Content-Type", ignoreCase = true)
}
) {
headers["Content-Type"] = mimeType
}
headers.putAll(corsHeaders())
headers["Content-Length"] = body.size.toString()
log(
"cast_relay action=response method=${request.method} " +
"path=$path status=${upstream.statusCode} " +
"source=${sourceDescription(target.sourceUrl)} " +
"bytes=${body.size} mime=${mimeType.orEmpty()} " +
"playlist=${isPlaylistResponse(target.sourceUrl, upstream)} " +
"selected_quality=${selectedQuality.orEmpty()}",
)
sendResponse(
socket = socket,
statusCode = upstream.statusCode,
reasonPhrase = upstream.reasonPhrase,
headers = headers,
body = body,
headOnly = headOnly,
)
}
}
private fun responseHeaders(response: StreamProxyResponse): MutableMap<String, String> {
return response.headers
.filterKeys { name ->
!hopByHopHeaders.contains(name.lowercase(Locale.US))
}
.toMutableMap()
}
private fun readRequest(socket: Socket): HttpRequest? {
val input = socket.getInputStream().buffered()
val requestLine = readAsciiLine(input) ?: return null
val parts = requestLine.split(" ", limit = 3)
if (parts.size < 2) return null
val headers = linkedMapOf<String, String>()
while (true) {
val line = readAsciiLine(input) ?: break
if (line.isEmpty()) break
val separatorIndex = line.indexOf(":")
if (separatorIndex <= 0) continue
headers[line.substring(0, separatorIndex).trim()] =
line.substring(separatorIndex + 1).trim()
}
return HttpRequest(
method = parts[0],
target = parts[1],
headers = headers,
)
}
private fun sendError(
socket: Socket,
statusCode: Int,
reasonPhrase: String,
) {
sendResponse(
socket = socket,
statusCode = statusCode,
reasonPhrase = reasonPhrase,
headers = corsHeaders() + mapOf("Content-Type" to "text/plain; charset=utf-8"),
body = reasonPhrase.toByteArray(StandardCharsets.UTF_8),
headOnly = false,
)
}
private fun sendResponse(
socket: Socket,
statusCode: Int,
reasonPhrase: String,
headers: Map<String, String>,
body: ByteArray,
headOnly: Boolean,
) {
val output = socket.getOutputStream()
val headerText = buildString {
append("HTTP/1.1 ")
append(statusCode)
append(" ")
append(reasonPhrase)
append("\r\n")
headers.forEach { (name, value) ->
append(name)
append(": ")
append(value)
append("\r\n")
}
append("Connection: close\r\n")
append("\r\n")
}
output.write(headerText.toByteArray(StandardCharsets.ISO_8859_1))
if (!headOnly) {
output.write(body)
}
output.flush()
}
private fun corsHeaders(): Map<String, String> {
return mapOf(
"Access-Control-Allow-Origin" to "*",
"Access-Control-Allow-Methods" to "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers" to "*",
)
}
private fun readAsciiLine(input: java.io.BufferedInputStream): String? {
val buffer = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
if (buffer.isEmpty()) return null
break
}
if (next == '\n'.code) break
if (next != '\r'.code) {
buffer.append(next.toChar())
}
}
return buffer.toString()
}
private fun isPlaylistResponse(
sourceUrl: String,
response: StreamProxyResponse,
): Boolean {
val mimeType = response.mimeType.orEmpty()
return mimeType.contains("mpegurl", ignoreCase = true) ||
mimeType.contains("m3u8", ignoreCase = true) ||
URI(sourceUrl).path?.endsWith(".m3u8", ignoreCase = true) == true
}
private fun decodeBody(bytes: ByteArray, encoding: String?): String {
return try {
bytes.toString(Charset.forName(encoding ?: "UTF-8"))
} catch (_: Exception) {
bytes.toString(Charsets.UTF_8)
}
}
private fun stableTargetId(key: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(key.toByteArray(StandardCharsets.UTF_8))
return digest
.take(12)
.joinToString("") { byte ->
"%02x".format(byte.toInt() and 0xff)
}
}
private fun extensionFor(sourceUrl: String): String {
val path = runCatching { URI(sourceUrl).path.orEmpty() }
.getOrDefault("")
.lowercase(Locale.US)
return when {
path.endsWith(".m3u8") -> "m3u8"
path.endsWith(".ts") -> "ts"
path.endsWith(".mp4") -> "mp4"
path.endsWith(".m4s") -> "m4s"
path.endsWith(".aac") -> "aac"
else -> "bin"
}
}
private fun sourceDescription(sourceUrl: String): String {
return runCatching {
val uri = URI(sourceUrl)
"${uri.host.orEmpty()}${uri.path.orEmpty()}"
}.getOrDefault("unknown")
}
private fun lanAddress(): String {
val interfaces = runCatching {
Collections.list(NetworkInterface.getNetworkInterfaces())
}.getOrDefault(emptyList())
val address = interfaces
.asSequence()
.filter { it.isUp && !it.isLoopback }
.flatMap { networkInterface ->
Collections.list(networkInterface.inetAddresses).asSequence()
}
.filterIsInstance<Inet4Address>()
.firstOrNull { address ->
!address.isLoopbackAddress &&
!address.isLinkLocalAddress &&
!address.isAnyLocalAddress
}
return address?.hostAddress ?: "127.0.0.1"
}
private data class HttpRequest(
val method: String,
val target: String,
val headers: Map<String, String>,
)
private data class RelayTarget(
val sourceUrl: String,
val selectedQuality: String?,
)
private companion object {
private const val SERVER_BACKLOG = 32
private val hopByHopHeaders = setOf(
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
)
}
}

View file

@ -0,0 +1,60 @@
package com.namecallfilter.glacier.streamproxy
import java.util.Locale
object CastRelayUpstreamHeaders {
fun build(
requestHeaders: Map<String, String>,
config: StreamProxyConfig,
): Map<String, String> {
val headers = linkedMapOf<String, String>()
val requestHeaderLookup = requestHeaders.entries.associateBy {
it.key.lowercase(Locale.US)
}
headers["Accept"] = acceptHeader(
requestHeaderLookup["accept"]?.value,
config,
)
headers["User-Agent"] = DEFAULT_USER_AGENT
passthroughHeaderNames.forEach { headerName ->
requestHeaderLookup[headerName.lowercase(Locale.US)]
?.value
?.takeIf(String::isNotBlank)
?.let { value -> headers[headerName] = value }
}
return headers
}
private fun acceptHeader(
requestAccept: String?,
config: StreamProxyConfig,
): String {
val accept = requestAccept
?.takeIf(String::isNotBlank)
?: DEFAULT_ACCEPT
if (!config.enabled || accept.contains(ACCEPT_FLAG, ignoreCase = true)) {
return accept
}
return "$accept,$ACCEPT_FLAG"
}
private const val ACCEPT_FLAG = "TTV-LOL-PRO"
private const val DEFAULT_ACCEPT = "application/vnd.apple.mpegurl,application/x-mpegURL,*/*"
private const val DEFAULT_USER_AGENT =
"Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
private val passthroughHeaderNames = listOf(
"Accept-Language",
"Range",
"If-Match",
"If-None-Match",
"If-Modified-Since",
"If-Unmodified-Since",
)
}

View file

@ -0,0 +1,266 @@
package com.namecallfilter.glacier.streamproxy
import java.net.URI
import java.util.Locale
import kotlin.math.roundToInt
object HlsPlaylistRewriter {
data class MasterPlaylist(
val variants: List<Variant>,
)
data class Variant(
val quality: String,
val url: String,
val streamInfoLine: String,
val streamInfoIndex: Int,
val uriIndex: Int,
val bandwidth: Long?,
val width: Int?,
val height: Int?,
val frameRate: Double?,
val score: Double?,
)
data class RewriteResult(
val body: String,
val selectedQuality: String?,
)
fun parseMasterPlaylist(body: String, baseUrl: String): MasterPlaylist {
return MasterPlaylist(parseVariants(body.lines(), baseUrl))
}
fun rewritePlaylist(
body: String,
baseUrl: String,
selectedQuality: String?,
rewriteUrl: (String) -> String,
): RewriteResult {
val lines = body.lines()
val variants = parseVariants(lines, baseUrl)
if (variants.isNotEmpty()) {
return rewriteMasterPlaylist(
lines = lines,
variants = variants,
selectedQuality = selectedQuality,
rewriteUrl = rewriteUrl,
)
}
return RewriteResult(
body = lines
.map { line -> rewriteMediaPlaylistLine(line, baseUrl, rewriteUrl) }
.joinToString("\n"),
selectedQuality = null,
)
}
private fun rewriteMasterPlaylist(
lines: List<String>,
variants: List<Variant>,
selectedQuality: String?,
rewriteUrl: (String) -> String,
): RewriteResult {
val selectedVariant = selectVariant(variants, selectedQuality)
val variantByStreamInfoIndex = variants.associateBy { it.streamInfoIndex }
val output = mutableListOf<String>()
var lineIndex = 0
while (lineIndex < lines.size) {
val variant = variantByStreamInfoIndex[lineIndex]
if (variant == null) {
output.add(lines[lineIndex])
lineIndex += 1
continue
}
if (selectedVariant == null || selectedVariant == variant) {
output.add(variant.streamInfoLine)
output.add(rewriteUrl(variant.url))
}
lineIndex = variant.uriIndex + 1
}
return RewriteResult(
body = output.joinToString("\n"),
selectedQuality = selectedVariant?.quality,
)
}
private fun selectVariant(
variants: List<Variant>,
selectedQuality: String?,
): Variant? {
val normalizedQuality = selectedQuality
?.trim()
?.lowercase(Locale.US)
?.takeIf(String::isNotEmpty)
return when (normalizedQuality) {
null,
"auto" -> null
"highest" -> variants.maxWithOrNull(
compareBy<Variant> { it.score ?: 0.0 }
.thenBy { it.height ?: 0 }
.thenBy { it.frameRate ?: 0.0 }
.thenBy { it.bandwidth ?: 0L },
)
else -> variants.firstOrNull {
it.quality.lowercase(Locale.US) == normalizedQuality
}
}
}
private fun rewriteMediaPlaylistLine(
line: String,
baseUrl: String,
rewriteUrl: (String) -> String,
): String {
val trimmed = line.trim()
if (trimmed.isEmpty()) return line
if (trimmed.startsWith("#")) {
return uriAttributeRegex.replace(line) { match ->
val originalUrl = match.groupValues[1]
"URI=\"${rewriteUrl(resolveUrl(baseUrl, originalUrl))}\""
}
}
return rewriteUrl(resolveUrl(baseUrl, trimmed))
}
private fun parseVariants(lines: List<String>, baseUrl: String): List<Variant> {
val variants = mutableListOf<Variant>()
var lineIndex = 0
while (lineIndex < lines.size) {
val line = lines[lineIndex].trim()
if (!line.startsWith("#EXT-X-STREAM-INF:", ignoreCase = true)) {
lineIndex += 1
continue
}
val uriIndex = nextUriLineIndex(lines, lineIndex + 1)
if (uriIndex == null) {
lineIndex += 1
continue
}
val attributes = parseAttributes(line.substringAfter(":"))
val resolution = attributes["RESOLUTION"]
?.split("x", limit = 2)
?.mapNotNull { it.toIntOrNull() }
?.takeIf { it.size == 2 }
val width = resolution?.getOrNull(0)
val height = resolution?.getOrNull(1)
val frameRate = attributes["FRAME-RATE"]?.toDoubleOrNull()
val bandwidth = attributes["BANDWIDTH"]?.toLongOrNull()
val score = attributes["SCORE"]?.toDoubleOrNull()
variants += Variant(
quality = qualityLabel(
height = height,
frameRate = frameRate,
bandwidth = bandwidth,
name = attributes["NAME"],
),
url = resolveUrl(baseUrl, lines[uriIndex].trim()),
streamInfoLine = lines[lineIndex],
streamInfoIndex = lineIndex,
uriIndex = uriIndex,
bandwidth = bandwidth,
width = width,
height = height,
frameRate = frameRate,
score = score,
)
lineIndex = uriIndex + 1
}
return variants
}
private fun nextUriLineIndex(lines: List<String>, startIndex: Int): Int? {
for (index in startIndex until lines.size) {
val line = lines[index].trim()
if (line.isEmpty()) continue
if (!line.startsWith("#")) return index
if (line.startsWith("#EXT-X-STREAM-INF:", ignoreCase = true)) return null
}
return null
}
private fun parseAttributes(value: String): Map<String, String> {
return splitAttributeList(value).mapNotNull { attribute ->
val separatorIndex = attribute.indexOf("=")
if (separatorIndex <= 0) return@mapNotNull null
val name = attribute.substring(0, separatorIndex).trim()
val rawValue = attribute.substring(separatorIndex + 1).trim()
name to rawValue.trim('"')
}.toMap()
}
private fun splitAttributeList(value: String): List<String> {
val attributes = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
value.forEach { char ->
when (char) {
'"' -> {
inQuotes = !inQuotes
current.append(char)
}
',' -> {
if (inQuotes) {
current.append(char)
} else {
attributes += current.toString()
current.clear()
}
}
else -> current.append(char)
}
}
if (current.isNotEmpty()) {
attributes += current.toString()
}
return attributes
}
private fun qualityLabel(
height: Int?,
frameRate: Double?,
bandwidth: Long?,
name: String?,
): String {
if (height != null) {
val roundedFrameRate = frameRate?.roundToInt()
return buildString {
append(height)
append("p")
if (roundedFrameRate != null) {
append(roundedFrameRate)
}
}
}
if (!name.isNullOrBlank()) return name
return bandwidth
?.let { "${it / 1000}k" }
?: "auto"
}
private fun resolveUrl(baseUrl: String, value: String): String {
return runCatching { URI(baseUrl).resolve(value).toString() }
.getOrDefault(value)
}
private val uriAttributeRegex = Regex("""URI="([^"]+)"""")
}

View file

@ -1,25 +1,36 @@
package com.namecallfilter.glacier.streamproxy
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.webkit.WebViewClient
import com.namecallfilter.glacier.cast.CastStreamContext
import com.namecallfilter.glacier.cast.GlacierCastController
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
import java.util.concurrent.ConcurrentHashMap
object StreamProxyChannel {
private const val CHANNEL_NAME = "frosty/stream_proxy"
fun register(flutterEngine: FlutterEngine) {
fun register(
flutterEngine: FlutterEngine,
context: Context,
) {
val mainHandler = Handler(Looper.getMainLooper())
val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL_NAME,
)
val routers = ConcurrentHashMap<Long, StreamProxyRequestRouter>()
val clients = ConcurrentHashMap<Long, StreamProxyWebViewClient>()
val castController = GlacierCastController(
context = context,
onStateChanged = { state ->
mainHandler.post {
methodChannel.invokeMethod("castStateChanged", state)
}
},
)
methodChannel.setMethodCallHandler { call, result ->
try {
@ -40,28 +51,33 @@ object StreamProxyChannel {
null,
)
val config = config(call)
val router = routers.getOrPut(webViewIdentifier) {
StreamProxyRequestRouter()
}
val client = clients[webViewIdentifier]?.also {
it.updateConfig(config)
} ?: StreamProxyWebViewClient(
initialConfig = config,
router = router,
onPageFinished = { url ->
mainHandler.post {
methodChannel.invokeMethod(
"pageFinished",
mapOf(
"webViewIdentifier" to webViewIdentifier,
"url" to url,
),
)
}
},
).also {
clients[webViewIdentifier] = it
}
val router = StreamProxySessionRegistry.getOrCreateRouter(
webViewIdentifier,
)
val client = StreamProxySessionRegistry.clientFor(webViewIdentifier)
?.also { client ->
client.updateConfig(config)
}
?: StreamProxyWebViewClient(
initialConfig = config,
router = router,
onPageFinished = { url ->
mainHandler.post {
methodChannel.invokeMethod(
"pageFinished",
mapOf(
"webViewIdentifier" to webViewIdentifier,
"url" to url,
),
)
}
},
).also { client ->
StreamProxySessionRegistry.putClient(
webViewIdentifier,
client,
)
}
webView.webViewClient = client
client.attachToWebView(webView)
@ -71,18 +87,49 @@ object StreamProxyChannel {
val config = config(call)
val webViewIdentifier = webViewIdentifier(call)
if (webViewIdentifier != null) {
clients[webViewIdentifier]?.updateConfig(config)
StreamProxySessionRegistry
.clientFor(webViewIdentifier)
?.updateConfig(config)
} else {
clients.values.forEach { it.updateConfig(config) }
StreamProxySessionRegistry.updateAllClients(config)
}
result.success(null)
}
"updateCastContext" -> {
val config = config(call)
val webViewIdentifier = webViewIdentifier(call)
?: return@setMethodCallHandler result.success(null)
castController.updateContext(
CastStreamContext(
webViewIdentifier = webViewIdentifier,
channelLogin = config.currentChannelLogin,
title = stringArgument(call, "title")
?: config.currentChannelLogin,
subtitle = stringArgument(call, "subtitle"),
quality = stringArgument(call, "quality"),
config = config,
),
)
result.success(null)
}
"showCastDialog" -> {
mainHandler.post {
castController.showCastDialog()
}
result.success(null)
}
"stopCasting" -> {
mainHandler.post {
castController.stopCasting()
}
result.success(null)
}
"detach" -> {
val webViewIdentifier = webViewIdentifier(call)
?: return@setMethodCallHandler result.success(null)
clients.remove(webViewIdentifier)
StreamProxySessionRegistry.removeClient(webViewIdentifier)
?.detachFromWebView()
routers.remove(webViewIdentifier)
StreamProxySessionRegistry.removeRouter(webViewIdentifier)
WebViewFlutterAndroidExternalApi.getWebView(
flutterEngine,
@ -117,4 +164,11 @@ object StreamProxyChannel {
val arguments = call.arguments as? Map<*, *>
return StreamProxyConfig.fromMap(arguments?.get("config") as? Map<*, *>)
}
private fun stringArgument(call: MethodCall, key: String): String? {
val arguments = call.arguments as? Map<*, *> ?: return null
return (arguments[key] as? String)
?.trim()
?.takeIf(String::isNotEmpty)
}
}

View file

@ -1,8 +1,6 @@
package com.namecallfilter.glacier.streamproxy
import android.util.Base64
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.FilterInputStream
@ -39,20 +37,33 @@ class StreamProxyFetcher(
)
fun fetch(
request: WebResourceRequest,
request: StreamProxyRequest,
decision: StreamProxyDecision,
config: StreamProxyConfig,
router: StreamProxyRequestRouter,
): WebResourceResponse? {
directWhenSkipped: Boolean = false,
): StreamProxyResponse? {
if (decision.action != StreamProxyAction.PROXY) {
return if (directWhenSkipped) {
fetchDirect(
request = request,
decision = decision,
router = router,
reason = decision.reason ?: decision.action.logName,
)
} else {
null
}
}
if (config.proxyUrls.isEmpty()) {
return if (decision.flagged) {
return if (decision.flagged || directWhenSkipped) {
fetchDirect(request, decision, router, "no_proxy_urls")
} else {
null
}
}
val requestUrl = request.url.toString()
config.proxyUrls.forEachIndexed { index, proxyUrl ->
val parsedProxy = parseProxyUrl(proxyUrl)
if (parsedProxy == null) {
@ -73,7 +84,7 @@ class StreamProxyFetcher(
"auth=${parsedProxy.hasCredentials}",
)
if (URL(requestUrl).protocol.equals("https", ignoreCase = true)) {
if (URL(request.url).protocol.equals("https", ignoreCase = true)) {
return fetchHttpsViaConnect(
request = request,
decision = decision,
@ -87,60 +98,20 @@ class StreamProxyFetcher(
Proxy.Type.HTTP,
InetSocketAddress(parsedProxy.host, parsedProxy.port),
)
connection = URL(requestUrl).openConnection(proxy) as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = true
connection.requestMethod = request.method
copyRequestHeaders(request.requestHeaders ?: emptyMap(), connection)
setProxyAuthorization(parsedProxy, connection)
connection = URL(request.url).openConnection(proxy) as HttpURLConnection
val activeConnection = connection!!
activeConnection.connectTimeout = CONNECT_TIMEOUT_MS
activeConnection.readTimeout = READ_TIMEOUT_MS
activeConnection.instanceFollowRedirects = true
activeConnection.requestMethod = request.method
copyRequestHeaders(request.headers, activeConnection)
setProxyAuthorization(parsedProxy, activeConnection)
val statusCode = connection.responseCode
val responseStream = if (request.method.equals("HEAD", ignoreCase = true)) {
ByteArrayInputStream(ByteArray(0))
} else if (statusCode >= 400) {
connection.errorStream ?: ByteArrayInputStream(ByteArray(0))
} else {
connection.inputStream
}
val contentType = connection.contentType
val responseHeaders = responseHeaders(connection)
val mimeType = mimeTypeFrom(contentType, requestUrl)
val encoding = encodingFrom(contentType)
val reason = connection.responseMessage ?: "OK"
if (
decision.requestType == StreamProxyRequestType.USHER &&
statusCode in 200..399 &&
!request.method.equals("HEAD", ignoreCase = true)
) {
val bytes = responseStream.readBytes()
val body = decodeBody(bytes, encoding)
router.rememberUsherManifest(
channel = decision.channel,
manifestUrl = requestUrl,
manifestBody = body,
)
connection.disconnect()
return@withCredentials WebResourceResponse(
mimeType,
encoding,
statusCode,
reason,
responseHeaders,
ByteArrayInputStream(bytes),
)
}
WebResourceResponse(
mimeType,
encoding,
statusCode,
reason,
responseHeaders,
responseStream,
responseFromConnection(
connection = activeConnection,
request = request,
decision = decision,
router = router,
)
}
} catch (error: Exception) {
@ -154,7 +125,7 @@ class StreamProxyFetcher(
}
}
return if (decision.flagged) {
return if (decision.flagged || directWhenSkipped) {
fetchDirect(request, decision, router, "proxy_failed")
} else {
null
@ -162,13 +133,12 @@ class StreamProxyFetcher(
}
private fun fetchDirect(
request: WebResourceRequest,
request: StreamProxyRequest,
decision: StreamProxyDecision,
router: StreamProxyRequestRouter,
reason: String,
): WebResourceResponse? {
): StreamProxyResponse? {
var connection: HttpURLConnection? = null
val requestUrl = request.url.toString()
return try {
log(
@ -176,60 +146,20 @@ class StreamProxyFetcher(
"channel=${decision.channel ?: ""} action=direct_fallback " +
"reason=$reason",
)
connection = URL(requestUrl).openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = true
connection.requestMethod = request.method
copyRequestHeaders(request.requestHeaders ?: emptyMap(), connection)
connection = URL(request.url).openConnection() as HttpURLConnection
val activeConnection = connection!!
activeConnection.connectTimeout = CONNECT_TIMEOUT_MS
activeConnection.readTimeout = READ_TIMEOUT_MS
activeConnection.instanceFollowRedirects = true
activeConnection.requestMethod = request.method
copyRequestHeaders(request.headers, activeConnection)
val statusCode = connection.responseCode
val responseStream = if (request.method.equals("HEAD", ignoreCase = true)) {
ByteArrayInputStream(ByteArray(0))
} else if (statusCode >= 400) {
connection.errorStream ?: ByteArrayInputStream(ByteArray(0))
} else {
connection.inputStream
}
val contentType = connection.contentType
val responseHeaders = responseHeaders(connection)
val mimeType = mimeTypeFrom(contentType, requestUrl)
val encoding = encodingFrom(contentType)
val reasonPhrase = connection.responseMessage ?: "OK"
if (
decision.requestType == StreamProxyRequestType.USHER &&
statusCode in 200..399 &&
!request.method.equals("HEAD", ignoreCase = true)
) {
val bytes = responseStream.readBytes()
val body = decodeBody(bytes, encoding)
router.rememberUsherManifest(
channel = decision.channel,
manifestUrl = requestUrl,
manifestBody = body,
)
connection.disconnect()
WebResourceResponse(
mimeType,
encoding,
statusCode,
reasonPhrase,
responseHeaders,
ByteArrayInputStream(bytes),
)
} else {
WebResourceResponse(
mimeType,
encoding,
statusCode,
reasonPhrase,
responseHeaders,
responseStream,
)
}
responseFromConnection(
connection = activeConnection,
request = request,
decision = decision,
router = router,
)
} catch (error: Exception) {
connection?.disconnect()
log(
@ -241,14 +171,81 @@ class StreamProxyFetcher(
}
}
private fun responseFromConnection(
connection: HttpURLConnection,
request: StreamProxyRequest,
decision: StreamProxyDecision,
router: StreamProxyRequestRouter,
): StreamProxyResponse {
val statusCode = connection.responseCode
val responseStream = if (request.method.equals("HEAD", ignoreCase = true)) {
ByteArrayInputStream(ByteArray(0))
} else if (statusCode >= 400) {
connection.errorStream ?: ByteArrayInputStream(ByteArray(0))
} else {
connection.inputStream
}
val contentType = connection.contentType
val responseHeaders = responseHeaders(connection)
val mimeType = mimeTypeFrom(contentType, request.url)
val encoding = encodingFrom(contentType)
val reason = connection.responseMessage ?: "OK"
if (request.method.equals("HEAD", ignoreCase = true)) {
connection.disconnect()
return StreamProxyResponse(
mimeType = mimeType,
encoding = encoding,
statusCode = statusCode,
reasonPhrase = reason,
headers = responseHeaders,
body = responseStream,
)
}
if (
decision.requestType == StreamProxyRequestType.USHER &&
statusCode in 200..399
) {
val bytes = responseStream.readBytes()
val body = decodeBody(bytes, encoding)
router.rememberUsherManifest(
channel = decision.channel,
manifestUrl = request.url,
manifestBody = body,
)
connection.disconnect()
return StreamProxyResponse(
mimeType = mimeType,
encoding = encoding,
statusCode = statusCode,
reasonPhrase = reason,
headers = responseHeaders,
body = ByteArrayInputStream(bytes),
)
}
return StreamProxyResponse(
mimeType = mimeType,
encoding = encoding,
statusCode = statusCode,
reasonPhrase = reason,
headers = responseHeaders,
body = ClosingCallbackInputStream(responseStream) {
connection.disconnect()
},
)
}
private fun fetchHttpsViaConnect(
request: WebResourceRequest,
request: StreamProxyRequest,
decision: StreamProxyDecision,
proxy: ParsedProxy,
router: StreamProxyRequestRouter,
): WebResourceResponse {
val requestUrl = request.url.toString()
val url = URL(requestUrl)
): StreamProxyResponse {
val url = URL(request.url)
val port = if (url.port != -1) url.port else 443
val proxySocket = Socket()
@ -300,7 +297,7 @@ class StreamProxyFetcher(
val contentType = headers.entries
.firstOrNull { it.key.equals("content-type", ignoreCase = true) }
?.value
val mimeType = mimeTypeFrom(contentType, requestUrl)
val mimeType = mimeTypeFrom(contentType, request.url)
val encoding = encodingFrom(contentType)
val responseHeaders = headers
.filterKeys { !it.equals("transfer-encoding", ignoreCase = true) }
@ -314,28 +311,28 @@ class StreamProxyFetcher(
val body = decodeBody(bytes, encoding)
router.rememberUsherManifest(
channel = decision.channel,
manifestUrl = requestUrl,
manifestUrl = request.url,
manifestBody = body,
)
sslSocket.close()
return WebResourceResponse(
mimeType,
encoding,
status.statusCode,
status.reason,
responseHeaders,
ByteArrayInputStream(bytes),
return StreamProxyResponse(
mimeType = mimeType,
encoding = encoding,
statusCode = status.statusCode,
reasonPhrase = status.reason,
headers = responseHeaders,
body = ByteArrayInputStream(bytes),
)
}
return WebResourceResponse(
mimeType,
encoding,
status.statusCode,
status.reason,
responseHeaders,
bodyStream,
return StreamProxyResponse(
mimeType = mimeType,
encoding = encoding,
statusCode = status.statusCode,
reasonPhrase = status.reason,
headers = responseHeaders,
body = bodyStream,
)
} catch (error: Exception) {
proxySocket.close()
@ -371,6 +368,8 @@ class StreamProxyFetcher(
val sanitized = value
.replace(acceptFlag, "", ignoreCase = true)
.trim()
.trim(',')
.trim()
return sanitized.takeIf(String::isNotEmpty)
}
@ -427,7 +426,7 @@ class StreamProxyFetcher(
private fun writeOriginRequest(
output: java.io.OutputStream,
request: WebResourceRequest,
request: StreamProxyRequest,
url: URL,
) {
val path = url.file.takeIf(String::isNotEmpty) ?: "/"
@ -443,7 +442,7 @@ class StreamProxyFetcher(
append(url.port)
}
append("\r\n")
copyRequestHeaders(request.requestHeaders ?: emptyMap()) { name, value ->
copyRequestHeaders(request.headers) { name, value ->
append(name)
append(": ")
append(value)
@ -606,6 +605,19 @@ private class ClosingInputStream(
}
}
private class ClosingCallbackInputStream(
delegate: InputStream,
private val onClose: () -> Unit,
) : FilterInputStream(delegate) {
override fun close() {
try {
super.close()
} finally {
onClose()
}
}
}
private class ChunkedInputStream(
private val input: java.io.BufferedInputStream,
) : InputStream() {

View file

@ -0,0 +1,28 @@
package com.namecallfilter.glacier.streamproxy
import java.io.Closeable
import java.io.InputStream
data class StreamProxyRequest(
val url: String,
val method: String,
val headers: Map<String, String>,
)
class StreamProxyResponse(
val mimeType: String?,
val encoding: String?,
val statusCode: Int,
val reasonPhrase: String,
val headers: Map<String, String>,
val body: InputStream,
private val closeHandler: (() -> Unit)? = null,
) : Closeable {
override fun close() {
try {
body.close()
} finally {
closeHandler?.invoke()
}
}
}

View file

@ -7,6 +7,7 @@ import java.util.concurrent.ConcurrentHashMap
class StreamProxyRequestRouter {
private val videoWeaverChannels = ConcurrentHashMap<String, String>()
private val proxiedVideoWeaverUrls = ConcurrentHashMap.newKeySet<String>()
private val usherManifestUrls = ConcurrentHashMap<String, String>()
private val usherChannelRegex = Regex("/hls/(.+)\\.m3u8", RegexOption.IGNORE_CASE)
private val videoWeaverUrlRegex = Regex(
"^https?://(?:[a-z0-9-]+\\.playlist\\.(?:live-video|ttvnw)\\.net|video-weaver\\.[a-z0-9-]+\\.hls\\.ttvnw\\.net)/v1/playlist/.+\\.m3u8(?:$|[?#])",
@ -101,6 +102,7 @@ class StreamProxyRequestRouter {
fun rememberUsherManifest(channel: String?, manifestUrl: String, manifestBody: String) {
val normalizedChannel = channel?.lowercase(Locale.US) ?: return
val baseUri = runCatching { URI(manifestUrl) }.getOrNull() ?: return
usherManifestUrls[normalizedChannel] = manifestUrl
manifestBody
.lineSequence()
@ -115,6 +117,21 @@ class StreamProxyRequestRouter {
}
}
fun rememberUsherRequest(url: String) {
val channel = extractUsherChannel(url) ?: return
usherManifestUrls[channel] = url
}
fun latestUsherManifestUrl(channel: String?): String? {
val normalizedChannel = channel
?.trim()
?.lowercase(Locale.US)
?.takeIf(String::isNotEmpty)
?: return null
return usherManifestUrls[normalizedChannel]
}
private fun routeUsher(
url: String,
config: StreamProxyConfig,

View file

@ -0,0 +1,43 @@
package com.namecallfilter.glacier.streamproxy
import java.util.concurrent.ConcurrentHashMap
object StreamProxySessionRegistry {
private val routers = ConcurrentHashMap<Long, StreamProxyRequestRouter>()
private val clients = ConcurrentHashMap<Long, StreamProxyWebViewClient>()
fun routerFor(webViewIdentifier: Long): StreamProxyRequestRouter? {
return routers[webViewIdentifier]
}
fun getOrCreateRouter(webViewIdentifier: Long): StreamProxyRequestRouter {
return routers.getOrPut(webViewIdentifier) {
StreamProxyRequestRouter()
}
}
fun clientFor(webViewIdentifier: Long): StreamProxyWebViewClient? {
return clients[webViewIdentifier]
}
fun putClient(
webViewIdentifier: Long,
client: StreamProxyWebViewClient,
) {
clients[webViewIdentifier] = client
}
fun removeClient(webViewIdentifier: Long): StreamProxyWebViewClient? {
return clients.remove(webViewIdentifier)
}
fun removeRouter(webViewIdentifier: Long) {
routers.remove(webViewIdentifier)
}
fun updateAllClients(config: StreamProxyConfig) {
clients.values.forEach { client ->
client.updateConfig(config)
}
}
}

View file

@ -65,6 +65,7 @@ class StreamProxyWebViewClient(
if (request == null) return null
val currentConfig = config
router.rememberUsherRequest(request.url.toString())
val decision = router.route(
url = request.url.toString(),
method = request.method,
@ -73,17 +74,19 @@ class StreamProxyWebViewClient(
)
if (decision.action != StreamProxyAction.PROXY) {
logDecision(decision)
if (currentConfig.enabled) {
logDecision(decision)
}
return null
}
return try {
val response = fetcher.fetch(
request = request,
request = request.toStreamProxyRequest(),
decision = decision,
config = currentConfig,
router = router,
)
)?.toWebResourceResponse()
if (response == null) {
log(
@ -165,3 +168,22 @@ class StreamProxyWebViewClient(
private const val LOG_TAG = "FrostyStreamProxy"
}
}
private fun WebResourceRequest.toStreamProxyRequest(): StreamProxyRequest {
return StreamProxyRequest(
url = url.toString(),
method = method,
headers = requestHeaders ?: emptyMap(),
)
}
private fun StreamProxyResponse.toWebResourceResponse(): WebResourceResponse {
return WebResourceResponse(
mimeType,
encoding,
statusCode,
reasonPhrase,
headers,
body,
)
}

View file

@ -14,6 +14,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
<item name="colorPrimary">#000000</item>
<item name="colorPrimaryDark">#000000</item>
<item name="colorAccent">#FFFFFF</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -17,6 +17,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
<item name="colorPrimary">#000000</item>
<item name="colorPrimaryDark">#000000</item>
<item name="colorAccent">#FFFFFF</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -14,6 +14,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
<item name="colorPrimary">#000000</item>
<item name="colorPrimaryDark">#000000</item>
<item name="colorAccent">#FFFFFF</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1 @@
<resources><string name="app_name">Glacier</string></resources>

View file

@ -17,6 +17,20 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
<item name="colorPrimary">#000000</item>
<item name="colorPrimaryDark">#000000</item>
<item name="colorAccent">#FFFFFF</item>
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="GlacierCastRouteButtonTheme" parent="@style/Theme.AppCompat.NoActionBar">
<item name="colorPrimary">#000000</item>
<item name="colorPrimaryDark">#000000</item>
<item name="colorAccent">#FFFFFF</item>
<item name="android:colorBackground">#000000</item>
<item name="android:colorAccent">#FFFFFF</item>
<item name="android:windowBackground">#000000</item>
<item name="android:windowIsTranslucent">false</item>
</style>
</resources>

View file

@ -0,0 +1,27 @@
package com.namecallfilter.glacier.cast
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CastReceiverMessageParserTest {
@Test
fun parsesLatencyStatusMessage() {
val status = CastReceiverMessageParser.parse(
"""{"type":"status","latencyMs":7420,"playerState":"PLAYING","currentTimeSec":12.5,"rangeStartSec":3,"rangeEndSec":19.92,"targetLatencySec":3}""",
)
assertEquals(7420L, status?.latencyMs)
assertEquals("PLAYING", status?.playerState)
assertEquals(12.5, status?.currentTimeSec ?: 0.0, 0.001)
assertEquals(3.0, status?.rangeStartSec ?: 0.0, 0.001)
assertEquals(19.92, status?.rangeEndSec ?: 0.0, 0.001)
assertEquals(3.0, status?.targetLatencySec ?: 0.0, 0.001)
}
@Test
fun ignoresMalformedMessages() {
assertNull(CastReceiverMessageParser.parse("not-json"))
assertNull(CastReceiverMessageParser.parse("""{"type":"unknown"}"""))
}
}

View file

@ -0,0 +1,76 @@
package com.namecallfilter.glacier.streamproxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CastRelayUpstreamHeadersTest {
@Test
fun stripsReceiverBrowserHeadersFromUpstreamRequests() {
val headers = CastRelayUpstreamHeaders.build(
requestHeaders = mapOf(
"Accept" to "*/*",
"User-Agent" to "CrKey/1.0",
"Origin" to "https://glacier-cast-receiver.example",
"Referer" to "https://glacier-cast-receiver.example/",
"Sec-Fetch-Site" to "cross-site",
"Accept-Encoding" to "gzip",
"Range" to "bytes=0-",
),
config = disabledConfig(),
)
assertEquals("*/*", headers["Accept"])
assertTrue(headers["User-Agent"].orEmpty().contains("Mozilla/5.0"))
assertFalse(headers["User-Agent"].orEmpty().contains("CrKey"))
assertEquals("bytes=0-", headers["Range"])
assertFalse(headers.containsKey("Origin"))
assertFalse(headers.containsKey("Referer"))
assertFalse(headers.containsKey("Sec-Fetch-Site"))
assertFalse(headers.containsKey("Accept-Encoding"))
}
@Test
fun addsProxyAcceptFlagWhenProxyConfigIsEnabled() {
val headers = CastRelayUpstreamHeaders.build(
requestHeaders = mapOf("Accept" to "application/vnd.apple.mpegurl"),
config = enabledConfig(),
)
assertEquals(
"application/vnd.apple.mpegurl,TTV-LOL-PRO",
headers["Accept"],
)
}
@Test
fun suppliesStableDefaultsWhenReceiverOmitsHeaders() {
val headers = CastRelayUpstreamHeaders.build(
requestHeaders = emptyMap(),
config = disabledConfig(),
)
assertEquals(
"application/vnd.apple.mpegurl,application/x-mpegURL,*/*",
headers["Accept"],
)
assertTrue(headers["User-Agent"].orEmpty().contains("Mozilla/5.0"))
}
private fun disabledConfig() = StreamProxyConfig(
mode = StreamProxyMode.OFF,
currentChannelLogin = "streamer",
proxyUrls = emptyList(),
whitelistedChannels = emptySet(),
debugLogging = false,
)
private fun enabledConfig() = StreamProxyConfig(
mode = StreamProxyMode.TTV_LOL_PRO,
currentChannelLogin = "streamer",
proxyUrls = listOf("proxy.example.com:3128"),
whitelistedChannels = emptySet(),
debugLogging = false,
)
}

View file

@ -0,0 +1,112 @@
package com.namecallfilter.glacier.streamproxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HlsPlaylistRewriterTest {
@Test
fun parseMasterPlaylistBuildsQualityLabelsAndAbsoluteUrls() {
val playlist = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,FRAME-RATE=60.000,SCORE=1.0
source/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2200000,RESOLUTION=1280x720,FRAME-RATE=30.000,SCORE=0.7
720/index.m3u8
""".trimIndent()
val master = HlsPlaylistRewriter.parseMasterPlaylist(
body = playlist,
baseUrl = "https://usher.ttvnw.net/api/channel/hls/streamer.m3u8?token=abc",
)
assertEquals(listOf("1080p60", "720p30"), master.variants.map { it.quality })
assertEquals(
"https://usher.ttvnw.net/api/channel/hls/source/index.m3u8",
master.variants[0].url,
)
assertEquals(
"https://usher.ttvnw.net/api/channel/hls/720/index.m3u8",
master.variants[1].url,
)
}
@Test
fun rewritePlaylistFiltersMasterPlaylistToSelectedQuality() {
val playlist = """
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,FRAME-RATE=60.000,SCORE=1.0
source/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2200000,RESOLUTION=1280x720,FRAME-RATE=60.000,SCORE=0.7
720/index.m3u8
""".trimIndent()
val result = HlsPlaylistRewriter.rewritePlaylist(
body = playlist,
baseUrl = "https://usher.ttvnw.net/api/channel/hls/streamer.m3u8",
selectedQuality = "720p60",
rewriteUrl = { originalUrl ->
"http://192.168.1.10:49200/relay/${originalUrl.substringAfterLast("/")}"
},
)
assertEquals("720p60", result.selectedQuality)
assertTrue(result.body.contains("#EXT-X-VERSION:3"))
assertTrue(result.body.contains("RESOLUTION=1280x720"))
assertTrue(result.body.contains("http://192.168.1.10:49200/relay/index.m3u8"))
assertFalse(result.body.contains("RESOLUTION=1920x1080"))
assertFalse(result.body.contains("source/index.m3u8"))
}
@Test
fun rewritePlaylistRewritesAllMediaPlaylistUrisThroughRelay() {
val playlist = """
#EXTM3U
#EXT-X-MAP:URI="init.mp4"
#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"
#EXTINF:2.000,
segment-1.ts
#EXTINF:2.000,
https://video-weaver.example.hls.ttvnw.net/v1/segment-2.ts
""".trimIndent()
val result = HlsPlaylistRewriter.rewritePlaylist(
body = playlist,
baseUrl = "https://video-weaver.example.hls.ttvnw.net/v1/playlist/live.m3u8",
selectedQuality = null,
rewriteUrl = { originalUrl ->
"http://phone/relay/${originalUrl.substringAfterLast("/")}"
},
)
assertTrue(result.body.contains("#EXT-X-MAP:URI=\"http://phone/relay/init.mp4\""))
assertTrue(result.body.contains("#EXT-X-KEY:METHOD=AES-128,URI=\"http://phone/relay/key.bin\""))
assertTrue(result.body.contains("http://phone/relay/segment-1.ts"))
assertTrue(result.body.contains("http://phone/relay/segment-2.ts"))
assertFalse(result.body.contains("\nsegment-1.ts"))
}
@Test
fun rewritePlaylistSelectsHighestVariantWhenRequested() {
val playlist = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2200000,RESOLUTION=1280x720,FRAME-RATE=60.000,SCORE=0.7
720/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,FRAME-RATE=60.000,SCORE=1.0
source/index.m3u8
""".trimIndent()
val result = HlsPlaylistRewriter.rewritePlaylist(
body = playlist,
baseUrl = "https://usher.ttvnw.net/api/channel/hls/streamer.m3u8",
selectedQuality = "highest",
rewriteUrl = { "http://phone/relay/${it.substringAfterLast("/")}" },
)
assertEquals("1080p60", result.selectedQuality)
assertTrue(result.body.contains("RESOLUTION=1920x1080"))
assertFalse(result.body.contains("RESOLUTION=1280x720"))
}
}

View file

@ -0,0 +1,60 @@
package com.namecallfilter.glacier.streamproxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StreamProxyRequestRouterTest {
@Test
fun routesFlaggedUsherRequestsThroughProxy() {
val router = StreamProxyRequestRouter()
val decision = router.route(
url = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?player_type=site",
method = "GET",
headers = mapOf("Accept" to "application/vnd.apple.mpegurl,TTV-LOL-PRO"),
config = enabledConfig(),
)
assertEquals(StreamProxyRequestType.USHER, decision.requestType)
assertEquals(StreamProxyAction.PROXY, decision.action)
assertEquals("streamer", decision.channel)
}
@Test
fun remembersLatestUsherManifestUrlFromManifestResponses() {
val router = StreamProxyRequestRouter()
val manifestUrl = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?token=abc"
val manifestBody = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=100,RESOLUTION=640x360
https://video-weaver.example.hls.ttvnw.net/v1/playlist/live.m3u8
""".trimIndent()
router.rememberUsherManifest(
channel = "Streamer",
manifestUrl = manifestUrl,
manifestBody = manifestBody,
)
assertEquals(manifestUrl, router.latestUsherManifestUrl("streamer"))
}
@Test
fun remembersLatestUsherManifestUrlFromObservedRequests() {
val router = StreamProxyRequestRouter()
val manifestUrl = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?token=abc"
router.rememberUsherRequest(manifestUrl)
assertEquals(manifestUrl, router.latestUsherManifestUrl("streamer"))
assertNull(router.latestUsherManifestUrl("other_streamer"))
}
private fun enabledConfig() = StreamProxyConfig(
mode = StreamProxyMode.TTV_LOL_PRO,
currentChannelLogin = "streamer",
proxyUrls = listOf("proxy.example.com:3128"),
whitelistedChannels = emptySet(),
debugLogging = false,
)
}

16
cast_receiver/README.md Normal file
View file

@ -0,0 +1,16 @@
# Glacier Cast Receiver
This is the custom Google Cast Web Receiver used for low-latency HLS casting.
To use it on devices:
1. Host `index.html` over HTTPS.
2. Register that URL as a Custom Receiver in the Google Cast SDK Developer Console.
3. Put the assigned receiver app ID in `.env`:
```text
CAST_RECEIVER_APP_ID=YOUR_APP_ID
```
If `CAST_RECEIVER_APP_ID` is empty, the Android app falls back to Google's
default media receiver so existing casting still works.

150
cast_receiver/index.html Normal file
View file

@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glacier Cast Receiver</title>
<style>
html,
body,
cast-media-player {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<cast-media-player></cast-media-player>
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<script>
(() => {
const MESSAGE_NAMESPACE = 'urn:x-cast:com.namecallfilter.glacier.cast';
const TARGET_LATENCY_SECONDS = 3;
const MAX_LATENCY_SECONDS = 6;
const STATUS_INTERVAL_MS = 1000;
const CHASE_INTERVAL_MS = 750;
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();
const playbackConfig = new cast.framework.PlaybackConfig();
playbackConfig.autoResumeNumberOfSegments = 1;
playbackConfig.enableSmoothLiveRefresh = false;
playbackConfig.segmentRequestRetryLimit = 1;
playbackConfig.shakaConfig = {
streaming: {
lowLatencyMode: true,
bufferingGoal: 3,
rebufferingGoal: 0.5,
bufferBehind: 6,
updateIntervalSeconds: 0.5,
startAtSegmentBoundary: false,
liveSync: {
enabled: true,
targetLatency: TARGET_LATENCY_SECONDS,
targetLatencyTolerance: 1,
maxPlaybackRate: 1.1,
minPlaybackRate: 0.95,
},
retryParameters: {
maxAttempts: 2,
baseDelay: 250,
backoffFactor: 1.4,
fuzzFactor: 0.2,
},
},
manifest: {
hls: {
ignoreTextStreamFailures: true,
},
},
};
if (typeof playerManager.setPlaybackConfig === 'function') {
playerManager.setPlaybackConfig(playbackConfig);
}
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
(loadRequestData) => {
const media = loadRequestData.media;
if (media) {
media.entity = media.entity || media.contentId;
media.contentUrl = media.contentUrl || media.contentId;
media.streamType = cast.framework.messages.StreamType.LIVE;
media.contentType =
media.contentType || 'application/x-mpegURL';
}
loadRequestData.autoplay = true;
return loadRequestData;
},
);
const finiteNumber = (value) =>
typeof value === 'number' && Number.isFinite(value);
const liveStatus = () => {
const range = playerManager.getLiveSeekableRange();
if (!range || !finiteNumber(range.end)) return null;
const currentTime = playerManager.getCurrentTimeSec();
if (!finiteNumber(currentTime)) return null;
return {
currentTime,
rangeStart: finiteNumber(range.start) ? range.start : null,
rangeEnd: range.end,
latency: Math.max(0, range.end - currentTime),
};
};
const sendStatus = () => {
const status = liveStatus();
const message = {
type: 'status',
latencyMs: status == null ? null : Math.round(status.latency * 1000),
playerState: playerManager.getPlayerState(),
currentTimeSec: status == null ? null : status.currentTime,
rangeStartSec: status == null ? null : status.rangeStart,
rangeEndSec: status == null ? null : status.rangeEnd,
targetLatencySec: TARGET_LATENCY_SECONDS,
};
for (const sender of context.getSenders()) {
context.sendCustomMessage(MESSAGE_NAMESPACE, sender.id, message);
}
};
const chaseLiveEdge = () => {
const status = liveStatus();
if (!status || status.latency <= MAX_LATENCY_SECONDS) {
return;
}
const start = status.rangeStart == null ? 0 : status.rangeStart;
const targetTime = Math.max(
start,
status.rangeEnd - TARGET_LATENCY_SECONDS,
);
playerManager.seek({
currentTime: targetTime,
resumeState: cast.framework.messages.ResumeState.PLAYBACK_START,
});
};
setInterval(sendStatus, STATUS_INTERVAL_MS);
setInterval(chaseLiveEdge, CHASE_INTERVAL_MS);
context.start({
disableIdleTimeout: true,
playbackConfig,
statusText: 'Glacier',
});
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:frosty/services/cast_state.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
class CastAwarePointerBlocker extends StatelessWidget {
final Widget child;
const CastAwarePointerBlocker({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, child) =>
AbsorbPointer(absorbing: castState.isCasting, child: child),
child: child,
);
}
}

View file

@ -0,0 +1,90 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:frosty/services/cast_state.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
class CastButton extends StatelessWidget {
final Color? color;
final List<Shadow>? shadows;
const CastButton({super.key, this.color, this.shadows});
@override
Widget build(BuildContext context) {
if (!Platform.isAndroid) return const SizedBox.shrink();
return ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) => SizedBox.square(
dimension: 48,
child: IconButton(
tooltip: castState.isCasting
? 'Casting to ${castState.receiverName ?? 'device'}'
: 'Cast',
icon: Icon(
castState.isCasting
? Icons.cast_connected_rounded
: Icons.cast_rounded,
color: castState.isCasting ? Colors.white : color,
shadows: shadows,
),
onPressed: () => _handlePressed(context, castState),
),
),
);
}
Future<void> _handlePressed(BuildContext context, CastState castState) async {
if (!castState.isCasting) {
await StreamProxyBridge.showCastDialog();
return;
}
if (!context.mounted) return;
await _showCastControls(context, castState);
}
Future<void> _showCastControls(BuildContext context, CastState castState) {
final receiverName = castState.receiverName ?? 'Cast device';
final latency = castState.latencySeconds;
return showModalBottomSheetWithProperFocus<void>(
context: context,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cast_connected_rounded),
title: Text(receiverName),
subtitle: latency == null
? const Text('Casting')
: Text('Casting - ${latency}s latency'),
trailing: IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close_rounded),
onPressed: Navigator.of(context).pop,
),
),
const SizedBox(height: 8),
FilledButton.icon(
icon: const Icon(Icons.stop_rounded),
label: const Text('Stop casting'),
onPressed: () async {
Navigator.of(context).pop();
await StreamProxyBridge.stopCasting();
},
),
],
),
),
),
);
}
}

View file

@ -3,9 +3,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/cast_button.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/cast_state.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
@ -123,16 +126,21 @@ class VideoOverlay extends StatelessWidget {
},
);
final refreshButton = Tooltip(
message: 'Refresh',
preferBelow: false,
child: IconButton(
icon: Icon(
Icons.refresh_rounded,
color: surfaceColor,
shadows: kOverlayShadow,
final refreshButton = ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) => Tooltip(
message: castState.isCasting ? 'Casting' : 'Refresh',
preferBelow: false,
child: IconButton(
icon: Icon(
Icons.refresh_rounded,
color: castState.isCasting
? surfaceColor.withValues(alpha: 0.4)
: surfaceColor,
shadows: kOverlayShadow,
),
onPressed: castState.isCasting ? null : videoStore.handleRefresh,
),
onPressed: videoStore.handleRefresh,
),
);
@ -261,6 +269,7 @@ class VideoOverlay extends StatelessWidget {
if (videoStore.settingsStore.fullScreen &&
context.isLandscape)
chatOverlayButton,
CastButton(color: surfaceColor, shadows: kOverlayShadow),
],
),
Align(
@ -330,30 +339,57 @@ class VideoOverlay extends StatelessWidget {
if (videoStore.settingsStore.fullScreen &&
context.isLandscape)
chatOverlayButton,
CastButton(color: surfaceColor, shadows: kOverlayShadow),
videoSettingsButton,
],
),
Center(
child: Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
child: ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) {
if (castState.isCasting) {
return Tooltip(
message: castState.receiverName == null
? 'Casting'
: 'Casting to ${castState.receiverName}',
preferBelow: false,
child: Icon(
Icons.cast_connected_rounded,
size: 56,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
],
),
onPressed: videoStore.handlePausePlay,
),
);
}
return Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
onPressed: videoStore.handlePausePlay,
),
);
},
),
),
Align(
@ -447,18 +483,30 @@ class VideoOverlay extends StatelessWidget {
color: surfaceColor,
shadows: kOverlayShadow,
),
Observer(
builder: (context) => Text(
videoStore.latency ?? '—',
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: const [
FontFeature.tabularFigures(),
],
shadows: kOverlayShadow,
),
),
ValueListenableBuilder<CastState>(
valueListenable:
StreamProxyBridge.castState,
builder: (context, castState, _) {
final castLatency =
castState.latencySeconds;
return Observer(
builder: (context) => Text(
castState.isCasting &&
castLatency != null
? '${castLatency}s'
: videoStore.latency ?? '—',
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: const [
FontFeature.tabularFigures(),
],
shadows: kOverlayShadow,
),
),
);
},
),
],
),
@ -467,17 +515,26 @@ class VideoOverlay extends StatelessWidget {
),
),
),
Tooltip(
message: 'Enter picture-in-picture',
preferBelow: false,
child: IconButton(
icon: Icon(
Icons.picture_in_picture_alt_rounded,
color: surfaceColor,
shadows: kOverlayShadow,
),
onPressed: videoStore.togglePictureInPicture,
),
ValueListenableBuilder<CastState>(
valueListenable: StreamProxyBridge.castState,
builder: (context, castState, _) {
if (castState.isCasting) {
return const SizedBox.shrink();
}
return Tooltip(
message: 'Enter picture-in-picture',
preferBelow: false,
child: IconButton(
icon: Icon(
Icons.picture_in_picture_alt_rounded,
color: surfaceColor,
shadows: kOverlayShadow,
),
onPressed: videoStore.togglePictureInPicture,
),
);
},
),
refreshButton,
rotateButton,

View file

@ -69,17 +69,9 @@ abstract class VideoStoreBase with Store {
_latency = receivedLatency;
if (!settingsStore.autoSyncChatDelay) return;
if (StreamProxyBridge.castState.value.isCasting) return;
// Parse latency from abbreviated format: "5s" -> 5.0
final numericPart = receivedLatency.replaceAll(
RegExp(r'[^0-9.]'),
'',
);
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
}
_syncChatDelayFromLatencyText(receivedLatency);
},
)
..addJavaScriptChannel(
@ -97,9 +89,13 @@ abstract class VideoStoreBase with Store {
}
final prefs = await SharedPreferences.getInstance();
final lastStreamQuality = prefs.getString('last_stream_quality');
if (lastStreamQuality == null) return;
setStreamQuality(lastStreamQuality);
if (lastStreamQuality == null) {
await _updateCastContext();
return;
}
await setStreamQuality(lastStreamQuality);
}
await _updateCastContext();
},
)
..addJavaScriptChannel(
@ -201,6 +197,13 @@ abstract class VideoStoreBase with Store {
String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
String get _castQuality {
final quality = streamQuality;
if (quality != 'Auto') return quality;
return settingsStore.defaultToHighestQuality ? 'highest' : 'auto';
}
@readonly
String? _latency;
@ -298,6 +301,8 @@ abstract class VideoStoreBase with Store {
),
(_) => unawaited(_syncStreamProxyBridge(refresh: true)),
);
StreamProxyBridge.castState.addListener(_handleCastStateChanged);
}
updateStreamInfo();
@ -320,12 +325,10 @@ abstract class VideoStoreBase with Store {
// If autoSync was just enabled and we already have a cached latency
// value (e.g. from showLatency tracking), apply it immediately instead
// of waiting for the next tracker cycle (up to 60s).
if (autoSync && _latency != null) {
final numericPart = _latency!.replaceAll(RegExp(r'[^0-9.]'), '');
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
}
if (autoSync &&
(_latency != null ||
StreamProxyBridge.castState.value.latency != null)) {
_syncChatDelayFromCurrentSource();
}
} else {
// Stop tracker if both settings are now disabled
@ -341,15 +344,69 @@ abstract class VideoStoreBase with Store {
);
}
bool get _shouldUseStreamProxy =>
Platform.isAndroid &&
settingsStore.streamProxyMode == StreamProxyMode.ttvLolPro;
StreamProxyConfig get _streamProxyConfig => StreamProxyConfig.fromSettings(
settingsStore: settingsStore,
currentChannelLogin: userLogin,
);
bool get isCasting => StreamProxyBridge.castState.value.isCasting;
void _handleCastStateChanged() {
final castState = StreamProxyBridge.castState.value;
if (castState.isCasting) {
unawaited(_pauseLocalPlaybackForCast());
_syncChatDelayFromCurrentSource();
return;
}
if (settingsStore.autoSyncChatDelay && _latency != null) {
_syncChatDelayFromLatencyText(_latency!);
}
}
Future<void> _pauseLocalPlaybackForCast() async {
try {
await videoWebViewController.runJavaScript(
'(window._videoEl || document.getElementsByTagName("video")[0])?.pause();',
);
} catch (e) {
debugPrint(e.toString());
} finally {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
}
}
void _syncChatDelayFromCurrentSource() {
if (!settingsStore.autoSyncChatDelay) return;
final castState = StreamProxyBridge.castState.value;
final castLatency = castState.latency;
if (castState.isCasting) {
if (castLatency != null) {
settingsStore.syncedChatDelay = (castLatency.inMilliseconds / 1000)
.ceilToDouble();
}
return;
}
final latency = _latency;
if (latency != null) {
_syncChatDelayFromLatencyText(latency);
}
}
void _syncChatDelayFromLatencyText(String latency) {
// Parse abbreviated Twitch latency format: "5s" -> 5.0.
final numericPart = latency.replaceAll(RegExp(r'[^0-9.]'), '');
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
}
}
Future<void> initializeVideoPlayer() async {
if (!settingsStore.showVideo) return;
@ -372,19 +429,6 @@ abstract class VideoStoreBase with Store {
final platformController = videoWebViewController.platform;
if (platformController is! AndroidWebViewController) return;
if (!_shouldUseStreamProxy) {
if (_streamProxyAttached) {
await _streamProxyBridge.detach(platformController);
_streamProxyAttached = false;
await videoWebViewController.setNavigationDelegate(_navigationDelegate);
}
if (refresh && settingsStore.showVideo) {
await handleRefresh(haptic: false);
}
return;
}
if (_streamProxyAttached) {
await _streamProxyBridge.updateConfig(_streamProxyConfig);
} else {
@ -396,11 +440,24 @@ abstract class VideoStoreBase with Store {
_streamProxyAttached = true;
}
await _updateCastContext();
if (refresh && settingsStore.showVideo) {
await handleRefresh(haptic: false);
}
}
Future<void> _updateCastContext() async {
if (!Platform.isAndroid || !_streamProxyAttached) return;
await _streamProxyBridge.updateCastContext(
config: _streamProxyConfig,
title: _streamInfo?.userName ?? userLogin,
subtitle: _streamInfo?.title,
quality: _castQuality,
);
}
@action
Future<void> updateStreamQualities() async {
try {
@ -437,6 +494,8 @@ abstract class VideoStoreBase with Store {
''');
} catch (e) {
debugPrint(e.toString());
} finally {
await _updateCastContext();
}
}
@ -475,6 +534,7 @@ abstract class VideoStoreBase with Store {
});
''');
_streamQualityIndex = newStreamQualityIndex;
await _updateCastContext();
} catch (e) {
debugPrint(e.toString());
}
@ -1039,6 +1099,8 @@ abstract class VideoStoreBase with Store {
if (!settingsStore.showVideo) {
_scheduleOverlayHide();
}
} finally {
await _updateCastContext();
}
}
@ -1064,6 +1126,8 @@ abstract class VideoStoreBase with Store {
/// Refreshes the stream webview and updates the stream info.
@action
Future<void> handleRefresh({bool haptic = true}) async {
if (isCasting) return;
if (haptic) {
HapticFeedback.lightImpact();
}
@ -1090,6 +1154,8 @@ abstract class VideoStoreBase with Store {
/// Play or pause the video depending on the current state of [_paused].
void handlePausePlay() {
if (isCasting) return;
try {
if (_paused) {
videoWebViewController.runJavaScript(
@ -1109,6 +1175,8 @@ abstract class VideoStoreBase with Store {
///
/// Uses the native Android PiP API.
void requestPictureInPicture() {
if (isCasting) return;
try {
if (Platform.isAndroid) {
pip.enterPipMode(autoEnter: true);
@ -1149,6 +1217,9 @@ abstract class VideoStoreBase with Store {
_disposeAndroidAutoPipReaction?.call();
_disposeLatencySettingsReaction?.call();
_disposeStreamProxyReaction?.call();
if (Platform.isAndroid) {
StreamProxyBridge.castState.removeListener(_handleCastStateChanged);
}
final platformController = videoWebViewController.platform;
if (_streamProxyAttached &&

View file

@ -0,0 +1,32 @@
class CastState {
final bool isCasting;
final String? receiverName;
final Duration? latency;
const CastState({required this.isCasting, this.receiverName, this.latency});
const CastState.disconnected()
: isCasting = false,
receiverName = null,
latency = null;
int? get latencySeconds => latency?.inSeconds;
factory CastState.fromMethodChannelPayload(Object? payload) {
if (payload is! Map) return const CastState.disconnected();
final isCasting = payload['isCasting'] == true;
final receiverName = payload['receiverName'];
final latencyMs = payload['latencyMs'];
return CastState(
isCasting: isCasting,
receiverName: receiverName is String && receiverName.trim().isNotEmpty
? receiverName.trim()
: null,
latency: latencyMs is num && latencyMs >= 0
? Duration(milliseconds: latencyMs.round())
: null,
);
}
}

View file

@ -2,17 +2,24 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:frosty/services/cast_state.dart';
import 'package:frosty/services/stream_proxy_config.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
class StreamProxyBridge {
static const _channel = MethodChannel('frosty/stream_proxy');
static final castState = ValueNotifier<CastState>(
const CastState.disconnected(),
);
static StreamProxyBridge? _activeBridge;
static var _methodCallHandlerAttached = false;
void Function(String url)? _onPageFinished;
int? _webViewIdentifier;
StreamProxyBridge() {
_channel.setMethodCallHandler(_handleMethodCall);
_activeBridge = this;
_ensureMethodCallHandler();
}
bool get isSupported => Platform.isAndroid;
@ -42,6 +49,25 @@ class StreamProxyBridge {
});
}
Future<void> updateCastContext({
required StreamProxyConfig config,
required String title,
String? subtitle,
String? quality,
}) async {
if (!isSupported) return;
final webViewIdentifier = _webViewIdentifier;
if (webViewIdentifier == null) return;
await _invoke('updateCastContext', {
'webViewIdentifier': webViewIdentifier,
'config': config.toMethodChannelPayload(),
'title': title,
'subtitle': subtitle,
'quality': quality,
});
}
Future<void> detach(AndroidWebViewController controller) async {
if (!isSupported) return;
@ -55,10 +81,29 @@ class StreamProxyBridge {
void dispose() {
_onPageFinished = null;
_webViewIdentifier = null;
_channel.setMethodCallHandler(null);
if (identical(_activeBridge, this)) {
_activeBridge = null;
}
}
Future<void> _invoke(String method, Map<String, Object?> arguments) async {
await _invokeStatic(method, arguments);
}
static Future<void> showCastDialog() async {
if (!Platform.isAndroid) return;
await _invokeStatic('showCastDialog', const {});
}
static Future<void> stopCasting() async {
if (!Platform.isAndroid) return;
await _invokeStatic('stopCasting', const {});
}
static Future<void> _invokeStatic(
String method,
Map<String, Object?> arguments,
) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} on PlatformException catch (e) {
@ -66,17 +111,27 @@ class StreamProxyBridge {
}
}
Future<void> _handleMethodCall(MethodCall call) async {
static void _ensureMethodCallHandler() {
if (_methodCallHandlerAttached) return;
_channel.setMethodCallHandler(_handleMethodCall);
_methodCallHandlerAttached = true;
}
static Future<void> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'pageFinished':
final arguments = call.arguments;
if (arguments is Map) {
final url = arguments['url'];
if (url is String) {
_onPageFinished?.call(url);
_activeBridge?._onPageFinished?.call(url);
}
}
return;
case 'castStateChanged':
castState.value = CastState.fromMethodChannelPayload(call.arguments);
return;
default:
debugPrint('Unknown stream proxy bridge method: ${call.method}');
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/channel/video/cast_aware_pointer_blocker.dart';
import 'package:frosty/services/cast_state.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
void main() {
tearDown(() {
StreamProxyBridge.castState.value = const CastState.disconnected();
});
testWidgets('absorbs child pointer events while casting', (tester) async {
StreamProxyBridge.castState.value = const CastState(isCasting: true);
await tester.pumpWidget(
const MaterialApp(home: CastAwarePointerBlocker(child: Text('video'))),
);
final blocker = _videoBlocker(tester);
expect(blocker.absorbing, isTrue);
});
testWidgets('lets child pointer events through when not casting', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: CastAwarePointerBlocker(child: Text('video'))),
);
final blocker = _videoBlocker(tester);
expect(blocker.absorbing, isFalse);
});
}
AbsorbPointer _videoBlocker(WidgetTester tester) {
return tester
.widgetList<AbsorbPointer>(find.byType(AbsorbPointer))
.singleWhere((widget) => widget.child is Text);
}

View file

@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/services/cast_state.dart';
void main() {
group('CastState', () {
test('parses active cast state from method channel payload', () {
final state = CastState.fromMethodChannelPayload({
'isCasting': true,
'receiverName': 'Jacoby\'s TV',
'latencyMs': 7420,
});
expect(state.isCasting, isTrue);
expect(state.receiverName, 'Jacoby\'s TV');
expect(state.latency, const Duration(milliseconds: 7420));
expect(state.latencySeconds, 7);
});
test('treats missing and malformed payloads as disconnected', () {
expect(CastState.fromMethodChannelPayload(null).isCasting, isFalse);
expect(CastState.fromMethodChannelPayload('bad').isCasting, isFalse);
expect(
CastState.fromMethodChannelPayload({'isCasting': false}).latency,
isNull,
);
});
});
}