diff --git a/.env.example b/.env.example
index 4d4439a4..7a8fbecd 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,3 @@
CLIENT_ID=
SECRET=
+CAST_RECEIVER_APP_ID=
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b420cbb3..72c85a8b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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")
}
diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..fe552e53
--- /dev/null
+++ b/android/app/src/debug/res/values/strings.xml
@@ -0,0 +1 @@
+Glacier Debug
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b9958f7e..f7faec66 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,67 +1,70 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/MainActivity.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/MainActivity.kt
index 4e609465..3f05c289 100644
--- a/android/app/src/main/kotlin/com/namecallfilter/glacier/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/MainActivity.kt
@@ -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)
}
}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParser.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParser.kt
new file mode 100644
index 00000000..9aad4ebf
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParser.kt
@@ -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?,
+)
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt
new file mode 100644
index 00000000..d9485d17
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastController.kt
@@ -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) -> 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 {
+ 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,
+)
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastOptionsProvider.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastOptionsProvider.kt
new file mode 100644
index 00000000..c79e23a6
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastOptionsProvider.kt
@@ -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? {
+ return null
+ }
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastReceiverConfig.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastReceiverConfig.kt
new file mode 100644
index 00000000..53ada73a
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/cast/GlacierCastReceiverConfig.kt
@@ -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
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt
new file mode 100644
index 00000000..f2616cea
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayServer.kt
@@ -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()
+ private val targetIds = ConcurrentHashMap()
+ 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 {
+ 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()
+ 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,
+ 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 {
+ 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()
+ .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,
+ )
+
+ 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",
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeaders.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeaders.kt
new file mode 100644
index 00000000..ef61d488
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeaders.kt
@@ -0,0 +1,60 @@
+package com.namecallfilter.glacier.streamproxy
+
+import java.util.Locale
+
+object CastRelayUpstreamHeaders {
+ fun build(
+ requestHeaders: Map,
+ config: StreamProxyConfig,
+ ): Map {
+ val headers = linkedMapOf()
+ 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",
+ )
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriter.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriter.kt
new file mode 100644
index 00000000..81d5cbbd
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriter.kt
@@ -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,
+ )
+
+ 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,
+ variants: List,
+ selectedQuality: String?,
+ rewriteUrl: (String) -> String,
+ ): RewriteResult {
+ val selectedVariant = selectVariant(variants, selectedQuality)
+ val variantByStreamInfoIndex = variants.associateBy { it.streamInfoIndex }
+ val output = mutableListOf()
+ 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,
+ selectedQuality: String?,
+ ): Variant? {
+ val normalizedQuality = selectedQuality
+ ?.trim()
+ ?.lowercase(Locale.US)
+ ?.takeIf(String::isNotEmpty)
+
+ return when (normalizedQuality) {
+ null,
+ "auto" -> null
+ "highest" -> variants.maxWithOrNull(
+ compareBy { 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, baseUrl: String): List {
+ val variants = mutableListOf()
+ 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, 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 {
+ 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 {
+ val attributes = mutableListOf()
+ 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="([^"]+)"""")
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyChannel.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyChannel.kt
index 101008b0..c2f728ec 100644
--- a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyChannel.kt
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyChannel.kt
@@ -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()
- val clients = ConcurrentHashMap()
+ 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)
+ }
}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyFetcher.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyFetcher.kt
index 03147725..b8933119 100644
--- a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyFetcher.kt
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyFetcher.kt
@@ -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() {
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyModels.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyModels.kt
new file mode 100644
index 00000000..edc22336
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyModels.kt
@@ -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,
+)
+
+class StreamProxyResponse(
+ val mimeType: String?,
+ val encoding: String?,
+ val statusCode: Int,
+ val reasonPhrase: String,
+ val headers: Map,
+ val body: InputStream,
+ private val closeHandler: (() -> Unit)? = null,
+) : Closeable {
+ override fun close() {
+ try {
+ body.close()
+ } finally {
+ closeHandler?.invoke()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouter.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouter.kt
index 044bbe25..d04634ce 100644
--- a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouter.kt
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouter.kt
@@ -7,6 +7,7 @@ import java.util.concurrent.ConcurrentHashMap
class StreamProxyRequestRouter {
private val videoWeaverChannels = ConcurrentHashMap()
private val proxiedVideoWeaverUrls = ConcurrentHashMap.newKeySet()
+ private val usherManifestUrls = ConcurrentHashMap()
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,
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxySessionRegistry.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxySessionRegistry.kt
new file mode 100644
index 00000000..401fb051
--- /dev/null
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxySessionRegistry.kt
@@ -0,0 +1,43 @@
+package com.namecallfilter.glacier.streamproxy
+
+import java.util.concurrent.ConcurrentHashMap
+
+object StreamProxySessionRegistry {
+ private val routers = ConcurrentHashMap()
+ private val clients = ConcurrentHashMap()
+
+ 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)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyWebViewClient.kt b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyWebViewClient.kt
index 3d2e1478..018f08bf 100644
--- a/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyWebViewClient.kt
+++ b/android/app/src/main/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyWebViewClient.kt
@@ -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,
+ )
+}
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
index 75578815..62f33a10 100644
--- a/android/app/src/main/res/values-night-v31/styles.xml
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -14,6 +14,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index a26965ce..2a6c03f6 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -17,6 +17,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
index cd4a9d67..0ea9731b 100644
--- a/android/app/src/main/res/values-v31/styles.xml
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -14,6 +14,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..76fdb177
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1 @@
+Glacier
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index ac038c9e..9baa171c 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -17,6 +17,20 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
+
+
diff --git a/android/app/src/test/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParserTest.kt b/android/app/src/test/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParserTest.kt
new file mode 100644
index 00000000..52ae2121
--- /dev/null
+++ b/android/app/src/test/kotlin/com/namecallfilter/glacier/cast/CastReceiverMessageParserTest.kt
@@ -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"}"""))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeadersTest.kt b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeadersTest.kt
new file mode 100644
index 00000000..069784a0
--- /dev/null
+++ b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/CastRelayUpstreamHeadersTest.kt
@@ -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,
+ )
+}
diff --git a/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriterTest.kt b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriterTest.kt
new file mode 100644
index 00000000..707745d0
--- /dev/null
+++ b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/HlsPlaylistRewriterTest.kt
@@ -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"))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouterTest.kt b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouterTest.kt
new file mode 100644
index 00000000..d8d07861
--- /dev/null
+++ b/android/app/src/test/kotlin/com/namecallfilter/glacier/streamproxy/StreamProxyRequestRouterTest.kt
@@ -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,
+ )
+}
diff --git a/cast_receiver/README.md b/cast_receiver/README.md
new file mode 100644
index 00000000..bcc5cd90
--- /dev/null
+++ b/cast_receiver/README.md
@@ -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.
diff --git a/cast_receiver/index.html b/cast_receiver/index.html
new file mode 100644
index 00000000..59d83625
--- /dev/null
+++ b/cast_receiver/index.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+ Glacier Cast Receiver
+
+
+
+
+
+
+
+
diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart
index 3d24bb26..87eff754 100644
--- a/lib/screens/channel/channel.dart
+++ b/lib/screens/channel/channel.dart
@@ -1,744 +1,747 @@
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_mobx/flutter_mobx.dart';
-import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
-import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
-import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
-import 'package:frosty/screens/channel/video/stream_info_bar.dart';
-import 'package:frosty/screens/channel/video/video.dart';
-import 'package:frosty/screens/channel/video/video_overlay.dart';
-import 'package:frosty/screens/channel/video/video_store.dart';
-import 'package:frosty/screens/settings/stores/settings_store.dart';
-import 'package:frosty/theme.dart';
-import 'package:frosty/utils/context_extensions.dart';
-import 'package:frosty/widgets/blurred_container.dart';
-import 'package:frosty/widgets/draggable_divider.dart';
-import 'package:frosty/widgets/frosty_notification.dart';
-import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
-import 'package:simple_pip_mode/pip_widget.dart';
-
-/// Creates a widget that shows the video stream (if live) and chat of the given user.
-class VideoChat extends StatefulWidget {
- static const routeName = 'VideoChat';
-
- final String userId;
- final String userName;
- final String userLogin;
-
- const VideoChat({
- super.key,
- required this.userId,
- required this.userName,
- required this.userLogin,
- });
-
- @override
- State createState() => _VideoChatState();
-}
-
-class _VideoChatState extends State
- with SingleTickerProviderStateMixin, WidgetsBindingObserver {
- final _videoKey = GlobalKey();
- final _chatKey = GlobalKey();
-
- // PiP drag state - essential only
- double _pipDragDistance = 0;
- bool _isPipDragging = false;
- bool _isInPipTriggerZone =
- false; // Track when in trigger zone for haptic feedback
-
- // Divider drag state for synchronizing animation
- bool _isDividerDragging = false;
-
- // Essential constants for good UX balance
- static const double _pipTriggerDistance = 80;
- static const double _pipMaxDragDistance = 150;
-
- // Animation controller for smooth spring-back
- late AnimationController _animationController;
- late Animation _springBackAnimation;
-
- bool? _isImmersiveSystemUiEnabled;
-
- late final ChatTabsStore _chatTabsStore = ChatTabsStore(
- twitchApi: context.twitchApi,
- bttvApi: context.bttvApi,
- ffzApi: context.ffzApi,
- sevenTVApi: context.sevenTVApi,
- authStore: context.authStore,
- settingsStore: context.settingsStore,
- globalAssetsStore: context.globalAssetsStore,
- primaryChannelId: widget.userId,
- primaryChannelLogin: widget.userLogin,
- primaryDisplayName: widget.userName,
- );
-
- late final VideoStore _videoStore = VideoStore(
- userLogin: widget.userLogin,
- userId: widget.userId,
- twitchApi: context.twitchApi,
- authStore: context.authStore,
- settingsStore: context.settingsStore,
- );
-
- @override
- void initState() {
- super.initState();
-
- // Initialize animation controller for smooth drag interactions
- _animationController = AnimationController(
- duration: const Duration(milliseconds: 250),
- vsync: this,
- );
-
- // Spring-back animation with smooth easing
- _springBackAnimation = Tween(begin: 0, end: 0).animate(
- CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
- );
-
- // Attach listener to the controller (not the derived animation) so it
- // survives _springBackAnimation being reassigned in _animateSpringBack.
- _animationController.addListener(() {
- setState(() {
- _pipDragDistance = _springBackAnimation.value;
- });
- });
-
- // Register as observer for app lifecycle events
- WidgetsBinding.instance.addObserver(this);
- }
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- super.didChangeAppLifecycleState(state);
-
- if (state == AppLifecycleState.resumed) {
- _videoStore.handleAppResume();
- }
- }
-
- void _handlePipDragStart(DragStartDetails details) {
- // Disable drag gesture when already in PiP mode or video is not playing
- if (_videoStore.isInPipMode || _videoStore.paused) return;
-
- _animationController.stop(); // Stop any ongoing animation
- setState(() {
- _isPipDragging = true;
- _pipDragDistance = 0;
- _isInPipTriggerZone = false;
- });
- }
-
- void _handlePipDragUpdate(DragUpdateDetails details) {
- if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
- return;
- }
-
- setState(() {
- _pipDragDistance += details.delta.dy;
- _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
-
- // Check if we've entered or exited the trigger zone for haptic feedback
- final wasInTriggerZone = _isInPipTriggerZone;
- _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
-
- // Provide haptic feedback when entering the trigger zone
- if (!wasInTriggerZone && _isInPipTriggerZone) {
- HapticFeedback.mediumImpact(); // Entering trigger zone
- }
- // Provide subtle haptic feedback when exiting the trigger zone
- else if (wasInTriggerZone && !_isInPipTriggerZone) {
- HapticFeedback.lightImpact(); // Exiting trigger zone
- }
- });
- }
-
- void _handlePipDragEnd(DragEndDetails details) {
- if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
- return;
- }
-
- final velocity = details.velocity.pixelsPerSecond.dy;
- final shouldTriggerPip =
- _pipDragDistance >= _pipTriggerDistance ||
- velocity > 600; // Simple velocity threshold
-
- if (shouldTriggerPip) {
- // Simple haptic feedback on success
- HapticFeedback.mediumImpact();
- _videoStore.requestPictureInPicture();
- _resetDragState();
- } else {
- // Animate back to original position
- _animateSpringBack();
- }
- }
-
- void _handlePipDragCancel() {
- if (!_isPipDragging) return;
- _animateSpringBack();
- }
-
- void _resetDragState() {
- setState(() {
- _isPipDragging = false;
- _pipDragDistance = 0;
- _isInPipTriggerZone = false;
- });
- }
-
- void _animateSpringBack() {
- _springBackAnimation = Tween(begin: _pipDragDistance, end: 0)
- .animate(
- CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
- );
-
- _animationController.reset();
- _animationController.forward().then((_) {
- _resetDragState();
- });
- }
-
- void _scheduleSystemUiModeSync({required bool immersive}) {
- if (_isImmersiveSystemUiEnabled == immersive) return;
-
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
-
- _isImmersiveSystemUiEnabled = immersive;
-
- if (immersive) {
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
- } else {
- SystemChrome.setEnabledSystemUIMode(
- SystemUiMode.manual,
- overlays: SystemUiOverlay.values,
- );
- }
- });
- }
-
- /// Wraps a video widget with PiP swipe-down gesture handling.
- ///
- /// Provides visual feedback (translate + scale), haptic feedback,
- /// and an instructional overlay during the drag gesture.
- Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
- final wrappedChild = aspectRatio != null
- ? AspectRatio(aspectRatio: aspectRatio, child: child)
- : child;
-
- return AnimatedBuilder(
- animation: _animationController,
- child: wrappedChild,
- builder: (context, animatedChild) {
- final currentDragDistance = _isPipDragging
- ? _pipDragDistance
- : _springBackAnimation.value;
-
- final scaleFactor =
- 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
-
- return Transform.translate(
- offset: Offset(0, currentDragDistance),
- child: Transform.scale(
- scale: scaleFactor.clamp(0.9, 1.0),
- child: Stack(
- children: [
- GestureDetector(
- onPanStart: _handlePipDragStart,
- onPanUpdate: _handlePipDragUpdate,
- onPanEnd: _handlePipDragEnd,
- onPanCancel: _handlePipDragCancel,
- child: animatedChild,
- ),
- if (!_videoStore.isInPipMode)
- Positioned.fill(
- child: IgnorePointer(
- ignoring: !(_isPipDragging && _pipDragDistance > 0),
- child: AnimatedOpacity(
- opacity: (_isPipDragging && _pipDragDistance > 0)
- ? 1.0
- : 0.0,
- duration: const Duration(milliseconds: 150),
- curve: Curves.easeOut,
- child: Container(
- color: Colors.black.withValues(alpha: 0.4),
- child: const Center(
- child: Text(
- 'Swipe down to enter picture-in-picture',
- textAlign: TextAlign.center,
- style: TextStyle(
- color: Colors.white,
- fontSize: 16,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- );
- },
- );
- }
-
- /// Convenience getter for the currently active chat store.
- ChatStore get _chatStore => _chatTabsStore.activeChatStore;
-
- @override
- Widget build(BuildContext context) {
- final settingsStore = _chatTabsStore.settingsStore;
-
- final player = GestureDetector(
- onLongPress: _videoStore.handleToggleOverlay,
- child: Video(key: _videoKey, videoStore: _videoStore),
- );
-
- final overlay = GestureDetector(
- onLongPress: _videoStore.handleToggleOverlay,
- onDoubleTap: context.isLandscape
- ? () => settingsStore.fullScreen = !settingsStore.fullScreen
- : null,
- onTap: () {
- if (_chatStore.assetsStore.showEmoteMenu) {
- _chatStore.assetsStore.showEmoteMenu = false;
- } else {
- if (_chatStore.textFieldFocusNode.hasFocus) {
- _chatStore.unfocusInput();
- } else {
- _videoStore.handleVideoTap();
- }
- }
- },
- child: Observer(
- builder: (_) {
- final videoOverlay = VideoOverlay(
- videoStore: _videoStore,
- chatStore: _chatStore,
- settingsStore: settingsStore,
- );
-
- if (_videoStore.paused || _videoStore.streamInfo == null) {
- return videoOverlay;
- }
-
- return AnimatedOpacity(
- opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
- curve: Curves.ease,
- duration: const Duration(milliseconds: 200),
- child: ColoredBox(
- color: Colors.transparent,
- child: IgnorePointer(
- ignoring: !_videoStore.overlayVisible,
- child: videoOverlay,
- ),
- ),
- );
- },
- ),
- );
-
- final video = Observer(
- builder: (context) {
- return Stack(
- children: [
- player,
- if (_videoStore.settingsStore.showOverlay) overlay,
- ],
- );
- },
- );
-
- final chat = Observer(
- builder: (context) {
- final bool chatOnly = !settingsStore.showVideo;
-
- return Stack(
- children: [
- ChatTabs(
- key: _chatKey,
- chatTabsStore: _chatTabsStore,
- // In chat-only mode, account for the blurred AppBar height
- listPadding: chatOnly
- ? EdgeInsets.only(top: context.safePaddingTop)
- : null,
- ),
- Observer(
- builder: (_) => AnimatedSwitcher(
- duration: const Duration(milliseconds: 200),
- switchInCurve: Curves.easeOut,
- switchOutCurve: Curves.easeIn,
- child: _chatStore.notification != null
- ? Align(
- alignment: Alignment.topCenter,
- child: Padding(
- padding: EdgeInsets.only(
- top: chatOnly ? context.safePaddingTop : 0,
- ),
- child: FrostyNotification(
- message: _chatStore.notification!,
- onDismissed: _chatStore.clearNotification,
- ),
- ),
- )
- : null,
- ),
- ),
- ],
- );
- },
- );
-
- final videoChat = Observer(
- builder: (context) {
- _scheduleSystemUiModeSync(
- immersive:
- context.isLandscape && !settingsStore.landscapeForceVerticalChat,
- );
-
- // Build a blurred AppBar when in chat-only mode (no video)
- PreferredSizeWidget? chatOnlyBlurredAppBar;
- if (!settingsStore.showVideo) {
- final streamInfo = _videoStore.streamInfo;
-
- chatOnlyBlurredAppBar = AppBar(
- centerTitle: false,
- elevation: 0,
- backgroundColor: Colors.transparent,
- surfaceTintColor: Colors.transparent,
- systemOverlayStyle: SystemUiOverlayStyle(
- statusBarColor: Colors.transparent,
- statusBarIconBrightness:
- context.theme.brightness == Brightness.dark
- ? Brightness.light
- : Brightness.dark,
- ),
- title: StreamInfoBar(
- streamInfo: streamInfo,
- offlineChannelInfo: _videoStore.offlineChannelInfo,
- displayName: _chatStore.displayName,
- isCompact: true,
- isOffline: streamInfo == null,
- isInSharedChatMode: _chatStore.isInSharedChatMode,
- showTextShadows: false,
- ),
- flexibleSpace: BlurredContainer(
- gradientDirection: GradientDirection.up,
- child: const SizedBox.expand(),
- ),
- );
- }
-
- return Scaffold(
- extendBody: true,
- extendBodyBehindAppBar: true,
- appBar: chatOnlyBlurredAppBar,
- body: Observer(
- builder: (context) {
- if (context.isLandscape &&
- !settingsStore.landscapeForceVerticalChat) {
- final landscapeChat = AnimatedContainer(
- curve: Curves.ease,
- duration: _isDividerDragging
- ? Duration.zero
- : const Duration(milliseconds: 200),
- width: _chatStore.expandChat
- ? context.screenWidth / 2
- : context.screenWidth * settingsStore.chatWidth,
- color: settingsStore.fullScreen
- ? Colors.black.withValues(
- alpha: settingsStore.fullScreenChatOverlayOpacity,
- )
- : context.scaffoldColor,
- child: chat,
- );
-
- final overlayChat = Visibility(
- visible: settingsStore.fullScreenChatOverlay,
- maintainState: true,
- child: Theme(
- data: FrostyThemes(
- colorSchemeSeed: Color(settingsStore.accentColor),
- ).dark,
- child: DefaultTextStyle(
- style: context.defaultTextStyle.copyWith(
- color: context.frostyColors.overlayOnSurface,
- ),
- child: landscapeChat,
- ),
- ),
- );
-
- return ColoredBox(
- color: settingsStore.showVideo
- ? Colors.black
- : context.scaffoldColor,
- child: settingsStore.showVideo
- ? settingsStore.fullScreen
- ? Stack(
- children: [
- _buildPipGestureWrapper(child: player),
- if (settingsStore.showOverlay)
- LayoutBuilder(
- builder: (context, constraints) {
- final totalWidth = constraints.maxWidth;
- final chatWidth = _chatStore.expandChat
- ? 0.5
- : settingsStore.chatWidth;
-
- final draggableDivider = Observer(
- builder: (_) => DraggableDivider(
- currentWidth: chatWidth,
- maxWidth: 0.6,
- isResizableOnLeft: settingsStore
- .landscapeChatLeftSide,
- showHandle:
- _videoStore.overlayVisible &&
- settingsStore
- .fullScreenChatOverlay,
- onDragStart: () {
- setState(() {
- _isDividerDragging = true;
- });
- },
- onDrag: (newWidth) {
- if (!_chatStore.expandChat) {
- settingsStore.chatWidth =
- newWidth;
- }
- },
- onDragEnd: () {
- setState(() {
- _isDividerDragging = false;
- });
- },
- ),
- );
-
- return Stack(
- children: [
- Row(
- children:
- settingsStore
- .landscapeChatLeftSide
- ? [
- overlayChat,
- Expanded(child: overlay),
- ]
- : [
- Expanded(child: overlay),
- overlayChat,
- ],
- ),
- if (settingsStore
- .fullScreenChatOverlay)
- Positioned(
- top: 0,
- bottom: 0,
- left:
- settingsStore
- .landscapeChatLeftSide
- ? (totalWidth * chatWidth) -
- 12
- : null,
- right:
- !settingsStore
- .landscapeChatLeftSide
- ? (totalWidth * chatWidth) -
- 12
- : null,
- child: draggableDivider,
- ),
- ],
- );
- },
- ),
- ],
- )
- : SafeArea(
- bottom: false,
- left:
- settingsStore.landscapeCutout !=
- LandscapeCutoutType.left &&
- settingsStore.landscapeCutout !=
- LandscapeCutoutType.both,
- right:
- settingsStore.landscapeCutout !=
- LandscapeCutoutType.right &&
- settingsStore.landscapeCutout !=
- LandscapeCutoutType.both,
- child: LayoutBuilder(
- builder: (context, constraints) {
- final availableWidth = constraints.maxWidth;
- final chatWidth = _chatStore.expandChat
- ? 0.5
- : settingsStore.chatWidth;
-
- // Create the landscape chat container with proper styling
- final chatContainer = AnimatedContainer(
- curve: Curves.ease,
- duration: _isDividerDragging
- ? Duration.zero
- : const Duration(milliseconds: 200),
- width: availableWidth * chatWidth,
- color: settingsStore.fullScreen
- ? Colors.black.withValues(
- alpha: settingsStore
- .fullScreenChatOverlayOpacity,
- )
- : context.scaffoldColor,
- child: chat,
- );
-
- final draggableDivider = Observer(
- builder: (_) => DraggableDivider(
- currentWidth: chatWidth,
- maxWidth: 0.6,
- isResizableOnLeft:
- settingsStore.landscapeChatLeftSide,
- showHandle: _videoStore.overlayVisible,
- onDragStart: () {
- setState(() {
- _isDividerDragging = true;
- });
- },
- onDrag: (newWidth) {
- if (!_chatStore.expandChat) {
- settingsStore.chatWidth = newWidth;
- }
- },
- onDragEnd: () {
- setState(() {
- _isDividerDragging = false;
- });
- },
- ),
- );
-
- return Stack(
- children: [
- Row(
- children:
- settingsStore
- .landscapeChatLeftSide
- ? [
- chatContainer,
- Expanded(
- child:
- _buildPipGestureWrapper(
- child: video,
- ),
- ),
- ]
- : [
- Expanded(
- child:
- _buildPipGestureWrapper(
- child: video,
- ),
- ),
- chatContainer,
- ],
- ),
- Positioned(
- top: 0,
- bottom: 0,
- left:
- settingsStore
- .landscapeChatLeftSide
- ? (availableWidth * chatWidth) -
- 12
- : null,
- right:
- !settingsStore
- .landscapeChatLeftSide
- ? (availableWidth * chatWidth) -
- 12
- : null,
- child: draggableDivider,
- ),
- ],
- );
- },
- ),
- )
- : SafeArea(child: chat),
- );
- }
- return SafeArea(
- // Keep chat-only scrolling under the blurred app bar, but
- // ensure video does not bleed into the system status bar.
- top: settingsStore.showVideo,
- bottom: false,
- child: Stack(
- children: [
- Column(
- children: [
- if (settingsStore.showVideo) ...[
- AspectRatio(
- aspectRatio: 16 / 9,
- child: Container(), // Placeholder for video space
- ),
- ],
- Expanded(child: chat),
- ],
- ),
- if (settingsStore.showVideo)
- Positioned(
- top: 0,
- left: 0,
- right: 0,
- child: _buildPipGestureWrapper(
- child: video,
- aspectRatio: 16 / 9,
- ),
- ),
- ],
- ),
- );
- },
- ),
- );
- },
- );
-
- // If on Android, use PiPSwitcher to enable PiP functionality.
- if (Platform.isAndroid) {
- return PipWidget(
- pipLayout: PipActionsLayout.mediaOnlyPause,
- onPipAction: (_) => _videoStore.handlePausePlay(),
- pipChild: player,
- child: videoChat,
- );
- }
-
- return videoChat;
- }
-
- @override
- void dispose() {
- // Remove observer for app lifecycle events
- WidgetsBinding.instance.removeObserver(this);
-
- _chatTabsStore.dispose();
-
- _videoStore.dispose();
-
- _animationController.dispose();
-
- SystemChrome.setEnabledSystemUIMode(
- SystemUiMode.manual,
- overlays: SystemUiOverlay.values,
- );
-
- SystemChrome.setPreferredOrientations([]);
-
- super.dispose();
- }
-}
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
+import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
+import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
+import 'package:frosty/screens/channel/video/cast_aware_pointer_blocker.dart';
+import 'package:frosty/screens/channel/video/stream_info_bar.dart';
+import 'package:frosty/screens/channel/video/video.dart';
+import 'package:frosty/screens/channel/video/video_overlay.dart';
+import 'package:frosty/screens/channel/video/video_store.dart';
+import 'package:frosty/screens/settings/stores/settings_store.dart';
+import 'package:frosty/theme.dart';
+import 'package:frosty/utils/context_extensions.dart';
+import 'package:frosty/widgets/blurred_container.dart';
+import 'package:frosty/widgets/draggable_divider.dart';
+import 'package:frosty/widgets/frosty_notification.dart';
+import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
+import 'package:simple_pip_mode/pip_widget.dart';
+
+/// Creates a widget that shows the video stream (if live) and chat of the given user.
+class VideoChat extends StatefulWidget {
+ static const routeName = 'VideoChat';
+
+ final String userId;
+ final String userName;
+ final String userLogin;
+
+ const VideoChat({
+ super.key,
+ required this.userId,
+ required this.userName,
+ required this.userLogin,
+ });
+
+ @override
+ State createState() => _VideoChatState();
+}
+
+class _VideoChatState extends State
+ with SingleTickerProviderStateMixin, WidgetsBindingObserver {
+ final _videoKey = GlobalKey();
+ final _chatKey = GlobalKey();
+
+ // PiP drag state - essential only
+ double _pipDragDistance = 0;
+ bool _isPipDragging = false;
+ bool _isInPipTriggerZone =
+ false; // Track when in trigger zone for haptic feedback
+
+ // Divider drag state for synchronizing animation
+ bool _isDividerDragging = false;
+
+ // Essential constants for good UX balance
+ static const double _pipTriggerDistance = 80;
+ static const double _pipMaxDragDistance = 150;
+
+ // Animation controller for smooth spring-back
+ late AnimationController _animationController;
+ late Animation _springBackAnimation;
+
+ bool? _isImmersiveSystemUiEnabled;
+
+ late final ChatTabsStore _chatTabsStore = ChatTabsStore(
+ twitchApi: context.twitchApi,
+ bttvApi: context.bttvApi,
+ ffzApi: context.ffzApi,
+ sevenTVApi: context.sevenTVApi,
+ authStore: context.authStore,
+ settingsStore: context.settingsStore,
+ globalAssetsStore: context.globalAssetsStore,
+ primaryChannelId: widget.userId,
+ primaryChannelLogin: widget.userLogin,
+ primaryDisplayName: widget.userName,
+ );
+
+ late final VideoStore _videoStore = VideoStore(
+ userLogin: widget.userLogin,
+ userId: widget.userId,
+ twitchApi: context.twitchApi,
+ authStore: context.authStore,
+ settingsStore: context.settingsStore,
+ );
+
+ @override
+ void initState() {
+ super.initState();
+
+ // Initialize animation controller for smooth drag interactions
+ _animationController = AnimationController(
+ duration: const Duration(milliseconds: 250),
+ vsync: this,
+ );
+
+ // Spring-back animation with smooth easing
+ _springBackAnimation = Tween(begin: 0, end: 0).animate(
+ CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
+ );
+
+ // Attach listener to the controller (not the derived animation) so it
+ // survives _springBackAnimation being reassigned in _animateSpringBack.
+ _animationController.addListener(() {
+ setState(() {
+ _pipDragDistance = _springBackAnimation.value;
+ });
+ });
+
+ // Register as observer for app lifecycle events
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ super.didChangeAppLifecycleState(state);
+
+ if (state == AppLifecycleState.resumed) {
+ _videoStore.handleAppResume();
+ }
+ }
+
+ void _handlePipDragStart(DragStartDetails details) {
+ // Disable drag gesture when already in PiP mode or video is not playing
+ if (_videoStore.isInPipMode || _videoStore.paused) return;
+
+ _animationController.stop(); // Stop any ongoing animation
+ setState(() {
+ _isPipDragging = true;
+ _pipDragDistance = 0;
+ _isInPipTriggerZone = false;
+ });
+ }
+
+ void _handlePipDragUpdate(DragUpdateDetails details) {
+ if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
+ return;
+ }
+
+ setState(() {
+ _pipDragDistance += details.delta.dy;
+ _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
+
+ // Check if we've entered or exited the trigger zone for haptic feedback
+ final wasInTriggerZone = _isInPipTriggerZone;
+ _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
+
+ // Provide haptic feedback when entering the trigger zone
+ if (!wasInTriggerZone && _isInPipTriggerZone) {
+ HapticFeedback.mediumImpact(); // Entering trigger zone
+ }
+ // Provide subtle haptic feedback when exiting the trigger zone
+ else if (wasInTriggerZone && !_isInPipTriggerZone) {
+ HapticFeedback.lightImpact(); // Exiting trigger zone
+ }
+ });
+ }
+
+ void _handlePipDragEnd(DragEndDetails details) {
+ if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
+ return;
+ }
+
+ final velocity = details.velocity.pixelsPerSecond.dy;
+ final shouldTriggerPip =
+ _pipDragDistance >= _pipTriggerDistance ||
+ velocity > 600; // Simple velocity threshold
+
+ if (shouldTriggerPip) {
+ // Simple haptic feedback on success
+ HapticFeedback.mediumImpact();
+ _videoStore.requestPictureInPicture();
+ _resetDragState();
+ } else {
+ // Animate back to original position
+ _animateSpringBack();
+ }
+ }
+
+ void _handlePipDragCancel() {
+ if (!_isPipDragging) return;
+ _animateSpringBack();
+ }
+
+ void _resetDragState() {
+ setState(() {
+ _isPipDragging = false;
+ _pipDragDistance = 0;
+ _isInPipTriggerZone = false;
+ });
+ }
+
+ void _animateSpringBack() {
+ _springBackAnimation = Tween(begin: _pipDragDistance, end: 0)
+ .animate(
+ CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
+ );
+
+ _animationController.reset();
+ _animationController.forward().then((_) {
+ _resetDragState();
+ });
+ }
+
+ void _scheduleSystemUiModeSync({required bool immersive}) {
+ if (_isImmersiveSystemUiEnabled == immersive) return;
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
+
+ _isImmersiveSystemUiEnabled = immersive;
+
+ if (immersive) {
+ SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ } else {
+ SystemChrome.setEnabledSystemUIMode(
+ SystemUiMode.manual,
+ overlays: SystemUiOverlay.values,
+ );
+ }
+ });
+ }
+
+ /// Wraps a video widget with PiP swipe-down gesture handling.
+ ///
+ /// Provides visual feedback (translate + scale), haptic feedback,
+ /// and an instructional overlay during the drag gesture.
+ Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
+ final wrappedChild = aspectRatio != null
+ ? AspectRatio(aspectRatio: aspectRatio, child: child)
+ : child;
+
+ return AnimatedBuilder(
+ animation: _animationController,
+ child: wrappedChild,
+ builder: (context, animatedChild) {
+ final currentDragDistance = _isPipDragging
+ ? _pipDragDistance
+ : _springBackAnimation.value;
+
+ final scaleFactor =
+ 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
+
+ return Transform.translate(
+ offset: Offset(0, currentDragDistance),
+ child: Transform.scale(
+ scale: scaleFactor.clamp(0.9, 1.0),
+ child: Stack(
+ children: [
+ GestureDetector(
+ onPanStart: _handlePipDragStart,
+ onPanUpdate: _handlePipDragUpdate,
+ onPanEnd: _handlePipDragEnd,
+ onPanCancel: _handlePipDragCancel,
+ child: animatedChild,
+ ),
+ if (!_videoStore.isInPipMode)
+ Positioned.fill(
+ child: IgnorePointer(
+ ignoring: !(_isPipDragging && _pipDragDistance > 0),
+ child: AnimatedOpacity(
+ opacity: (_isPipDragging && _pipDragDistance > 0)
+ ? 1.0
+ : 0.0,
+ duration: const Duration(milliseconds: 150),
+ curve: Curves.easeOut,
+ child: Container(
+ color: Colors.black.withValues(alpha: 0.4),
+ child: const Center(
+ child: Text(
+ 'Swipe down to enter picture-in-picture',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ /// Convenience getter for the currently active chat store.
+ ChatStore get _chatStore => _chatTabsStore.activeChatStore;
+
+ @override
+ Widget build(BuildContext context) {
+ final settingsStore = _chatTabsStore.settingsStore;
+
+ final player = GestureDetector(
+ onLongPress: _videoStore.handleToggleOverlay,
+ child: CastAwarePointerBlocker(
+ child: Video(key: _videoKey, videoStore: _videoStore),
+ ),
+ );
+
+ final overlay = GestureDetector(
+ onLongPress: _videoStore.handleToggleOverlay,
+ onDoubleTap: context.isLandscape
+ ? () => settingsStore.fullScreen = !settingsStore.fullScreen
+ : null,
+ onTap: () {
+ if (_chatStore.assetsStore.showEmoteMenu) {
+ _chatStore.assetsStore.showEmoteMenu = false;
+ } else {
+ if (_chatStore.textFieldFocusNode.hasFocus) {
+ _chatStore.unfocusInput();
+ } else {
+ _videoStore.handleVideoTap();
+ }
+ }
+ },
+ child: Observer(
+ builder: (_) {
+ final videoOverlay = VideoOverlay(
+ videoStore: _videoStore,
+ chatStore: _chatStore,
+ settingsStore: settingsStore,
+ );
+
+ if (_videoStore.paused || _videoStore.streamInfo == null) {
+ return videoOverlay;
+ }
+
+ return AnimatedOpacity(
+ opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
+ curve: Curves.ease,
+ duration: const Duration(milliseconds: 200),
+ child: ColoredBox(
+ color: Colors.transparent,
+ child: IgnorePointer(
+ ignoring: !_videoStore.overlayVisible,
+ child: videoOverlay,
+ ),
+ ),
+ );
+ },
+ ),
+ );
+
+ final video = Observer(
+ builder: (context) {
+ return Stack(
+ children: [
+ player,
+ if (_videoStore.settingsStore.showOverlay) overlay,
+ ],
+ );
+ },
+ );
+
+ final chat = Observer(
+ builder: (context) {
+ final bool chatOnly = !settingsStore.showVideo;
+
+ return Stack(
+ children: [
+ ChatTabs(
+ key: _chatKey,
+ chatTabsStore: _chatTabsStore,
+ // In chat-only mode, account for the blurred AppBar height
+ listPadding: chatOnly
+ ? EdgeInsets.only(top: context.safePaddingTop)
+ : null,
+ ),
+ Observer(
+ builder: (_) => AnimatedSwitcher(
+ duration: const Duration(milliseconds: 200),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ child: _chatStore.notification != null
+ ? Align(
+ alignment: Alignment.topCenter,
+ child: Padding(
+ padding: EdgeInsets.only(
+ top: chatOnly ? context.safePaddingTop : 0,
+ ),
+ child: FrostyNotification(
+ message: _chatStore.notification!,
+ onDismissed: _chatStore.clearNotification,
+ ),
+ ),
+ )
+ : null,
+ ),
+ ),
+ ],
+ );
+ },
+ );
+
+ final videoChat = Observer(
+ builder: (context) {
+ _scheduleSystemUiModeSync(
+ immersive:
+ context.isLandscape && !settingsStore.landscapeForceVerticalChat,
+ );
+
+ // Build a blurred AppBar when in chat-only mode (no video)
+ PreferredSizeWidget? chatOnlyBlurredAppBar;
+ if (!settingsStore.showVideo) {
+ final streamInfo = _videoStore.streamInfo;
+
+ chatOnlyBlurredAppBar = AppBar(
+ centerTitle: false,
+ elevation: 0,
+ backgroundColor: Colors.transparent,
+ surfaceTintColor: Colors.transparent,
+ systemOverlayStyle: SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness:
+ context.theme.brightness == Brightness.dark
+ ? Brightness.light
+ : Brightness.dark,
+ ),
+ title: StreamInfoBar(
+ streamInfo: streamInfo,
+ offlineChannelInfo: _videoStore.offlineChannelInfo,
+ displayName: _chatStore.displayName,
+ isCompact: true,
+ isOffline: streamInfo == null,
+ isInSharedChatMode: _chatStore.isInSharedChatMode,
+ showTextShadows: false,
+ ),
+ flexibleSpace: BlurredContainer(
+ gradientDirection: GradientDirection.up,
+ child: const SizedBox.expand(),
+ ),
+ );
+ }
+
+ return Scaffold(
+ extendBody: true,
+ extendBodyBehindAppBar: true,
+ appBar: chatOnlyBlurredAppBar,
+ body: Observer(
+ builder: (context) {
+ if (context.isLandscape &&
+ !settingsStore.landscapeForceVerticalChat) {
+ final landscapeChat = AnimatedContainer(
+ curve: Curves.ease,
+ duration: _isDividerDragging
+ ? Duration.zero
+ : const Duration(milliseconds: 200),
+ width: _chatStore.expandChat
+ ? context.screenWidth / 2
+ : context.screenWidth * settingsStore.chatWidth,
+ color: settingsStore.fullScreen
+ ? Colors.black.withValues(
+ alpha: settingsStore.fullScreenChatOverlayOpacity,
+ )
+ : context.scaffoldColor,
+ child: chat,
+ );
+
+ final overlayChat = Visibility(
+ visible: settingsStore.fullScreenChatOverlay,
+ maintainState: true,
+ child: Theme(
+ data: FrostyThemes(
+ colorSchemeSeed: Color(settingsStore.accentColor),
+ ).dark,
+ child: DefaultTextStyle(
+ style: context.defaultTextStyle.copyWith(
+ color: context.frostyColors.overlayOnSurface,
+ ),
+ child: landscapeChat,
+ ),
+ ),
+ );
+
+ return ColoredBox(
+ color: settingsStore.showVideo
+ ? Colors.black
+ : context.scaffoldColor,
+ child: settingsStore.showVideo
+ ? settingsStore.fullScreen
+ ? Stack(
+ children: [
+ _buildPipGestureWrapper(child: player),
+ if (settingsStore.showOverlay)
+ LayoutBuilder(
+ builder: (context, constraints) {
+ final totalWidth = constraints.maxWidth;
+ final chatWidth = _chatStore.expandChat
+ ? 0.5
+ : settingsStore.chatWidth;
+
+ final draggableDivider = Observer(
+ builder: (_) => DraggableDivider(
+ currentWidth: chatWidth,
+ maxWidth: 0.6,
+ isResizableOnLeft: settingsStore
+ .landscapeChatLeftSide,
+ showHandle:
+ _videoStore.overlayVisible &&
+ settingsStore
+ .fullScreenChatOverlay,
+ onDragStart: () {
+ setState(() {
+ _isDividerDragging = true;
+ });
+ },
+ onDrag: (newWidth) {
+ if (!_chatStore.expandChat) {
+ settingsStore.chatWidth =
+ newWidth;
+ }
+ },
+ onDragEnd: () {
+ setState(() {
+ _isDividerDragging = false;
+ });
+ },
+ ),
+ );
+
+ return Stack(
+ children: [
+ Row(
+ children:
+ settingsStore
+ .landscapeChatLeftSide
+ ? [
+ overlayChat,
+ Expanded(child: overlay),
+ ]
+ : [
+ Expanded(child: overlay),
+ overlayChat,
+ ],
+ ),
+ if (settingsStore
+ .fullScreenChatOverlay)
+ Positioned(
+ top: 0,
+ bottom: 0,
+ left:
+ settingsStore
+ .landscapeChatLeftSide
+ ? (totalWidth * chatWidth) -
+ 12
+ : null,
+ right:
+ !settingsStore
+ .landscapeChatLeftSide
+ ? (totalWidth * chatWidth) -
+ 12
+ : null,
+ child: draggableDivider,
+ ),
+ ],
+ );
+ },
+ ),
+ ],
+ )
+ : SafeArea(
+ bottom: false,
+ left:
+ settingsStore.landscapeCutout !=
+ LandscapeCutoutType.left &&
+ settingsStore.landscapeCutout !=
+ LandscapeCutoutType.both,
+ right:
+ settingsStore.landscapeCutout !=
+ LandscapeCutoutType.right &&
+ settingsStore.landscapeCutout !=
+ LandscapeCutoutType.both,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final availableWidth = constraints.maxWidth;
+ final chatWidth = _chatStore.expandChat
+ ? 0.5
+ : settingsStore.chatWidth;
+
+ // Create the landscape chat container with proper styling
+ final chatContainer = AnimatedContainer(
+ curve: Curves.ease,
+ duration: _isDividerDragging
+ ? Duration.zero
+ : const Duration(milliseconds: 200),
+ width: availableWidth * chatWidth,
+ color: settingsStore.fullScreen
+ ? Colors.black.withValues(
+ alpha: settingsStore
+ .fullScreenChatOverlayOpacity,
+ )
+ : context.scaffoldColor,
+ child: chat,
+ );
+
+ final draggableDivider = Observer(
+ builder: (_) => DraggableDivider(
+ currentWidth: chatWidth,
+ maxWidth: 0.6,
+ isResizableOnLeft:
+ settingsStore.landscapeChatLeftSide,
+ showHandle: _videoStore.overlayVisible,
+ onDragStart: () {
+ setState(() {
+ _isDividerDragging = true;
+ });
+ },
+ onDrag: (newWidth) {
+ if (!_chatStore.expandChat) {
+ settingsStore.chatWidth = newWidth;
+ }
+ },
+ onDragEnd: () {
+ setState(() {
+ _isDividerDragging = false;
+ });
+ },
+ ),
+ );
+
+ return Stack(
+ children: [
+ Row(
+ children:
+ settingsStore
+ .landscapeChatLeftSide
+ ? [
+ chatContainer,
+ Expanded(
+ child:
+ _buildPipGestureWrapper(
+ child: video,
+ ),
+ ),
+ ]
+ : [
+ Expanded(
+ child:
+ _buildPipGestureWrapper(
+ child: video,
+ ),
+ ),
+ chatContainer,
+ ],
+ ),
+ Positioned(
+ top: 0,
+ bottom: 0,
+ left:
+ settingsStore
+ .landscapeChatLeftSide
+ ? (availableWidth * chatWidth) -
+ 12
+ : null,
+ right:
+ !settingsStore
+ .landscapeChatLeftSide
+ ? (availableWidth * chatWidth) -
+ 12
+ : null,
+ child: draggableDivider,
+ ),
+ ],
+ );
+ },
+ ),
+ )
+ : SafeArea(child: chat),
+ );
+ }
+ return SafeArea(
+ // Keep chat-only scrolling under the blurred app bar, but
+ // ensure video does not bleed into the system status bar.
+ top: settingsStore.showVideo,
+ bottom: false,
+ child: Stack(
+ children: [
+ Column(
+ children: [
+ if (settingsStore.showVideo) ...[
+ AspectRatio(
+ aspectRatio: 16 / 9,
+ child: Container(), // Placeholder for video space
+ ),
+ ],
+ Expanded(child: chat),
+ ],
+ ),
+ if (settingsStore.showVideo)
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: _buildPipGestureWrapper(
+ child: video,
+ aspectRatio: 16 / 9,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ },
+ );
+
+ // If on Android, use PiPSwitcher to enable PiP functionality.
+ if (Platform.isAndroid) {
+ return PipWidget(
+ pipLayout: PipActionsLayout.mediaOnlyPause,
+ onPipAction: (_) => _videoStore.handlePausePlay(),
+ pipChild: player,
+ child: videoChat,
+ );
+ }
+
+ return videoChat;
+ }
+
+ @override
+ void dispose() {
+ // Remove observer for app lifecycle events
+ WidgetsBinding.instance.removeObserver(this);
+
+ _chatTabsStore.dispose();
+
+ _videoStore.dispose();
+
+ _animationController.dispose();
+
+ SystemChrome.setEnabledSystemUIMode(
+ SystemUiMode.manual,
+ overlays: SystemUiOverlay.values,
+ );
+
+ SystemChrome.setPreferredOrientations([]);
+
+ super.dispose();
+ }
+}
diff --git a/lib/screens/channel/video/cast_aware_pointer_blocker.dart b/lib/screens/channel/video/cast_aware_pointer_blocker.dart
new file mode 100644
index 00000000..3c51a1c1
--- /dev/null
+++ b/lib/screens/channel/video/cast_aware_pointer_blocker.dart
@@ -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(
+ valueListenable: StreamProxyBridge.castState,
+ builder: (context, castState, child) =>
+ AbsorbPointer(absorbing: castState.isCasting, child: child),
+ child: child,
+ );
+ }
+}
diff --git a/lib/screens/channel/video/cast_button.dart b/lib/screens/channel/video/cast_button.dart
new file mode 100644
index 00000000..9e2d6fcb
--- /dev/null
+++ b/lib/screens/channel/video/cast_button.dart
@@ -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? shadows;
+
+ const CastButton({super.key, this.color, this.shadows});
+
+ @override
+ Widget build(BuildContext context) {
+ if (!Platform.isAndroid) return const SizedBox.shrink();
+
+ return ValueListenableBuilder(
+ 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 _handlePressed(BuildContext context, CastState castState) async {
+ if (!castState.isCasting) {
+ await StreamProxyBridge.showCastDialog();
+ return;
+ }
+
+ if (!context.mounted) return;
+ await _showCastControls(context, castState);
+ }
+
+ Future _showCastControls(BuildContext context, CastState castState) {
+ final receiverName = castState.receiverName ?? 'Cast device';
+ final latency = castState.latencySeconds;
+
+ return showModalBottomSheetWithProperFocus(
+ 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();
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart
index ed44af48..fe3773e6 100644
--- a/lib/screens/channel/video/video_overlay.dart
+++ b/lib/screens/channel/video/video_overlay.dart
@@ -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(
+ 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(
+ 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(
+ 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(
+ 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,
diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart
index 2708f8d1..51d90928 100644
--- a/lib/screens/channel/video/video_store.dart
+++ b/lib/screens/channel/video/video_store.dart
@@ -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 _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 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 _updateCastContext() async {
+ if (!Platform.isAndroid || !_streamProxyAttached) return;
+
+ await _streamProxyBridge.updateCastContext(
+ config: _streamProxyConfig,
+ title: _streamInfo?.userName ?? userLogin,
+ subtitle: _streamInfo?.title,
+ quality: _castQuality,
+ );
+ }
+
@action
Future 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 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 &&
diff --git a/lib/services/cast_state.dart b/lib/services/cast_state.dart
new file mode 100644
index 00000000..9a26d628
--- /dev/null
+++ b/lib/services/cast_state.dart
@@ -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,
+ );
+ }
+}
diff --git a/lib/services/stream_proxy_bridge.dart b/lib/services/stream_proxy_bridge.dart
index a40da78b..4d4b292a 100644
--- a/lib/services/stream_proxy_bridge.dart
+++ b/lib/services/stream_proxy_bridge.dart
@@ -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(
+ 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 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 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 _invoke(String method, Map arguments) async {
+ await _invokeStatic(method, arguments);
+ }
+
+ static Future showCastDialog() async {
+ if (!Platform.isAndroid) return;
+ await _invokeStatic('showCastDialog', const {});
+ }
+
+ static Future stopCasting() async {
+ if (!Platform.isAndroid) return;
+ await _invokeStatic('stopCasting', const {});
+ }
+
+ static Future _invokeStatic(
+ String method,
+ Map arguments,
+ ) async {
try {
await _channel.invokeMethod(method, arguments);
} on PlatformException catch (e) {
@@ -66,17 +111,27 @@ class StreamProxyBridge {
}
}
- Future _handleMethodCall(MethodCall call) async {
+ static void _ensureMethodCallHandler() {
+ if (_methodCallHandlerAttached) return;
+
+ _channel.setMethodCallHandler(_handleMethodCall);
+ _methodCallHandlerAttached = true;
+ }
+
+ static Future _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}');
}
diff --git a/test/screens/channel/video/cast_aware_pointer_blocker_test.dart b/test/screens/channel/video/cast_aware_pointer_blocker_test.dart
new file mode 100644
index 00000000..ac1de0dd
--- /dev/null
+++ b/test/screens/channel/video/cast_aware_pointer_blocker_test.dart
@@ -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(find.byType(AbsorbPointer))
+ .singleWhere((widget) => widget.child is Text);
+}
diff --git a/test/services/cast_state_test.dart b/test/services/cast_state_test.dart
new file mode 100644
index 00000000..0fa04554
--- /dev/null
+++ b/test/services/cast_state_test.dart
@@ -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,
+ );
+ });
+ });
+}