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