Add Google Cast support with local HLS relay
- Add Cast receiver configuration and Android glue - Proxy and rewrite HLS playback for Chromecast - Surface cast state and controls in the Flutter UI
This commit is contained in:
parent
9b7decab98
commit
dbf606c1da
38 changed files with 3485 additions and 1076 deletions
|
|
@ -1,2 +1,3 @@
|
|||
CLIENT_ID=
|
||||
SECRET=
|
||||
CAST_RECEIVER_APP_ID=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
1
android/app/src/debug/res/values/strings.xml
Normal file
1
android/app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<resources><string name="app_name">Glacier Debug</string></resources>
|
||||
|
|
@ -1,67 +1,70 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="Glacier"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"/>
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"/>
|
||||
<data android:host="twitch.tv" />
|
||||
<data android:host="www.twitch.tv" />
|
||||
<data android:host="m.twitch.tv" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.namecallfilter.glacier.cast.GlacierCastOptionsProvider" />
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
package com.namecallfilter.glacier.cast
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.mediarouter.app.MediaRouteButton
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaLoadRequestData
|
||||
import com.google.android.gms.cast.MediaMetadata
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||
import com.google.android.gms.cast.framework.CastButtonFactory
|
||||
import com.namecallfilter.glacier.R
|
||||
import com.namecallfilter.glacier.streamproxy.CastRelayServer
|
||||
import com.namecallfilter.glacier.streamproxy.StreamProxyConfig
|
||||
import com.namecallfilter.glacier.streamproxy.StreamProxySessionRegistry
|
||||
|
||||
class GlacierCastController(
|
||||
context: Context,
|
||||
private val onStateChanged: (Map<String, Any?>) -> Unit = {},
|
||||
) {
|
||||
private val activity = context as? Activity
|
||||
private val applicationContext = context.applicationContext
|
||||
private val receiverApplicationIdDescription =
|
||||
GlacierCastReceiverConfig.maskedReceiverApplicationId(applicationContext)
|
||||
private val relayServer = CastRelayServer(::log)
|
||||
private val sessionListener = CastSessionListener()
|
||||
|
||||
@Volatile
|
||||
private var castContext: CastContext? = null
|
||||
|
||||
@Volatile
|
||||
private var streamContext: CastStreamContext? = null
|
||||
|
||||
@Volatile
|
||||
private var pendingLoad = false
|
||||
|
||||
@Volatile
|
||||
private var receiverLatencyMs: Long? = null
|
||||
|
||||
@Volatile
|
||||
private var routeButton: MediaRouteButton? = null
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
CastContext.getSharedInstance(applicationContext)
|
||||
}.onSuccess { context ->
|
||||
castContext = context
|
||||
context.sessionManager.addSessionManagerListener(
|
||||
sessionListener,
|
||||
CastSession::class.java,
|
||||
)
|
||||
emitState(context.sessionManager.currentCastSession)
|
||||
}.onFailure { error ->
|
||||
Log.d(LOG_TAG, "cast action=init_failed reason=${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContext(context: CastStreamContext) {
|
||||
streamContext = context
|
||||
updateRelay(context)
|
||||
|
||||
if (pendingLoad && currentSession()?.isConnected == true) {
|
||||
loadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareLoad() {
|
||||
pendingLoad = true
|
||||
if (currentSession()?.isConnected == true) {
|
||||
loadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastDialog() {
|
||||
val session = currentSession()
|
||||
if (session?.isConnected == true) {
|
||||
emitState(session)
|
||||
return
|
||||
}
|
||||
|
||||
prepareLoad()
|
||||
|
||||
val button = routeButton ?: ensureRouteButton()
|
||||
if (button == null) {
|
||||
Log.d(LOG_TAG, "cast_button action=show_failed reason=route_button_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
button.post {
|
||||
runCatching {
|
||||
button.performClick()
|
||||
}.onFailure { error ->
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_button action=show_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCasting() {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
castContext?.sessionManager?.endCurrentSession(true)
|
||||
relayServer.close()
|
||||
emitDisconnected()
|
||||
}
|
||||
|
||||
private fun ensureRouteButton(): MediaRouteButton? {
|
||||
routeButton?.let { button ->
|
||||
if (button.isAttachedToWindow) return button
|
||||
}
|
||||
|
||||
val activity = activity ?: return null
|
||||
return try {
|
||||
val buttonContext = ContextThemeWrapper(
|
||||
activity,
|
||||
R.style.GlacierCastRouteButtonTheme,
|
||||
)
|
||||
val button = object : MediaRouteButton(buttonContext) {
|
||||
override fun performClick(): Boolean {
|
||||
prepareLoad()
|
||||
return super.performClick()
|
||||
}
|
||||
}
|
||||
button.alpha = 0f
|
||||
@Suppress("DEPRECATION")
|
||||
button.setAlwaysVisible(true)
|
||||
|
||||
CastButtonFactory.setUpMediaRouteButton(activity, button)
|
||||
activity.addContentView(
|
||||
button,
|
||||
FrameLayout.LayoutParams(
|
||||
1,
|
||||
1,
|
||||
Gravity.TOP or Gravity.START,
|
||||
),
|
||||
)
|
||||
routeButton = button
|
||||
Log.d(LOG_TAG, "cast_button action=attached")
|
||||
button
|
||||
} catch (error: Exception) {
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_button action=setup_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCurrent() {
|
||||
val context = streamContext ?: return
|
||||
val router = StreamProxySessionRegistry.routerFor(context.webViewIdentifier)
|
||||
if (router == null) {
|
||||
log("cast action=load_failed reason=missing_router")
|
||||
return
|
||||
}
|
||||
|
||||
updateRelay(context)
|
||||
|
||||
val manifestUrl = router.latestUsherManifestUrl(context.channelLogin)
|
||||
if (manifestUrl == null) {
|
||||
log("cast action=load_failed reason=missing_manifest channel=${context.channelLogin}")
|
||||
return
|
||||
}
|
||||
|
||||
val relayUrl = relayServer.relayUrlFor(
|
||||
sourceUrl = manifestUrl,
|
||||
selectedQuality = context.quality,
|
||||
)
|
||||
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC).apply {
|
||||
putString(MediaMetadata.KEY_TITLE, context.title)
|
||||
context.subtitle
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { subtitle ->
|
||||
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
|
||||
}
|
||||
}
|
||||
val mediaInfo = MediaInfo.Builder(relayUrl, relayUrl)
|
||||
.setStreamType(MediaInfo.STREAM_TYPE_LIVE)
|
||||
.setContentType(HLS_CONTENT_TYPE)
|
||||
.setContentUrl(relayUrl)
|
||||
.setEntity(relayUrl)
|
||||
.setMetadata(metadata)
|
||||
.build()
|
||||
val request = MediaLoadRequestData.Builder()
|
||||
.setMediaInfo(mediaInfo)
|
||||
.setAutoplay(true)
|
||||
.build()
|
||||
|
||||
val remoteMediaClient = currentSession()?.remoteMediaClient
|
||||
if (remoteMediaClient == null) {
|
||||
log("cast action=load_failed reason=missing_remote_media_client")
|
||||
return
|
||||
}
|
||||
|
||||
remoteMediaClient.load(request).setResultCallback { result ->
|
||||
val status = result.status
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=load_result success=${status.isSuccess} " +
|
||||
"status=${status.statusCode} " +
|
||||
"message=${status.statusMessage ?: ""} " +
|
||||
"relay=$relayUrl",
|
||||
)
|
||||
}
|
||||
pendingLoad = false
|
||||
emitState(currentSession())
|
||||
log("cast action=load relay=$relayUrl quality=${context.quality}")
|
||||
}
|
||||
|
||||
private fun updateRelay(context: CastStreamContext) {
|
||||
val router = StreamProxySessionRegistry.getOrCreateRouter(context.webViewIdentifier)
|
||||
relayServer.update(
|
||||
router = router,
|
||||
config = context.config,
|
||||
)
|
||||
}
|
||||
|
||||
private fun currentSession(): CastSession? {
|
||||
return castContext?.sessionManager?.currentCastSession
|
||||
}
|
||||
|
||||
private fun attachReceiverChannel(session: CastSession) {
|
||||
runCatching {
|
||||
session.setMessageReceivedCallbacks(CAST_NAMESPACE) { _, _, message ->
|
||||
val status = CastReceiverMessageParser.parse(message)
|
||||
?: return@setMessageReceivedCallbacks
|
||||
|
||||
receiverLatencyMs = status.latencyMs ?: receiverLatencyMs
|
||||
logReceiverStatus(status)
|
||||
emitState(session)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
log("cast action=receiver_channel_failed reason=${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun logReceiverStatus(status: CastReceiverStatus) {
|
||||
log(
|
||||
"cast action=receiver_status " +
|
||||
"state=${status.playerState ?: ""} " +
|
||||
"latency_ms=${status.latencyMs ?: -1} " +
|
||||
"current_sec=${status.currentTimeSec ?: -1.0} " +
|
||||
"range_start_sec=${status.rangeStartSec ?: -1.0} " +
|
||||
"range_end_sec=${status.rangeEndSec ?: -1.0} " +
|
||||
"target_sec=${status.targetLatencySec ?: -1.0}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun detachReceiverChannel(session: CastSession) {
|
||||
runCatching {
|
||||
session.removeMessageReceivedCallbacks(CAST_NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitState(session: CastSession? = currentSession()) {
|
||||
val isCasting = session?.isConnected == true
|
||||
onStateChanged(
|
||||
mapOf(
|
||||
"isCasting" to isCasting,
|
||||
"receiverName" to session
|
||||
?.takeIf { isCasting }
|
||||
?.castDevice
|
||||
?.friendlyName,
|
||||
"latencyMs" to receiverLatencyMs.takeIf { isCasting },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitDisconnected() {
|
||||
onStateChanged(
|
||||
mapOf(
|
||||
"isCasting" to false,
|
||||
"receiverName" to null,
|
||||
"latencyMs" to null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
if (streamContext?.config?.debugLogging == true) {
|
||||
Log.d(LOG_TAG, message)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CastSessionListener : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(session: CastSession) = Unit
|
||||
|
||||
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
||||
receiverLatencyMs = null
|
||||
attachReceiverChannel(session)
|
||||
emitState(session)
|
||||
if (pendingLoad) {
|
||||
loadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
emitDisconnected()
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_start_failed error=$error " +
|
||||
"status=${castStatusName(error)} " +
|
||||
"receiver=$receiverApplicationIdDescription",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSessionEnding(session: CastSession) {
|
||||
detachReceiverChannel(session)
|
||||
}
|
||||
|
||||
override fun onSessionEnded(session: CastSession, error: Int) {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
relayServer.close()
|
||||
emitDisconnected()
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_ended error=$error " +
|
||||
"status=${castStatusName(error)} " +
|
||||
"receiver=$receiverApplicationIdDescription",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSessionResuming(session: CastSession, sessionId: String) = Unit
|
||||
|
||||
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
||||
attachReceiverChannel(session)
|
||||
emitState(session)
|
||||
if (pendingLoad) {
|
||||
loadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
||||
receiverLatencyMs = null
|
||||
emitDisconnected()
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_resume_failed error=$error " +
|
||||
"status=${castStatusName(error)} " +
|
||||
"receiver=$receiverApplicationIdDescription",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSessionSuspended(session: CastSession, reason: Int) {
|
||||
emitState(session)
|
||||
log("cast action=session_suspended reason=$reason")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LOG_TAG = "GlacierCast"
|
||||
private const val CAST_NAMESPACE = "urn:x-cast:com.namecallfilter.glacier.cast"
|
||||
private const val HLS_CONTENT_TYPE = "application/x-mpegURL"
|
||||
|
||||
private fun castStatusName(error: Int): String {
|
||||
return when (error) {
|
||||
0 -> "SUCCESS"
|
||||
2001 -> "TIMEOUT"
|
||||
2002 -> "CANCELED"
|
||||
2003 -> "INTERRUPTED"
|
||||
2004 -> "APPLICATION_NOT_FOUND"
|
||||
2005 -> "APPLICATION_NOT_RUNNING"
|
||||
2100 -> "AUTHENTICATION_FAILED"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CastStreamContext(
|
||||
val webViewIdentifier: Long,
|
||||
val channelLogin: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val quality: String?,
|
||||
val config: StreamProxyConfig,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.namecallfilter.glacier.cast
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.cast.framework.CastOptions
|
||||
import com.google.android.gms.cast.framework.OptionsProvider
|
||||
import com.google.android.gms.cast.framework.SessionProvider
|
||||
import com.google.android.gms.cast.framework.media.CastMediaOptions
|
||||
|
||||
class GlacierCastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(GlacierCastReceiverConfig.receiverApplicationId(context))
|
||||
.setCastMediaOptions(CastMediaOptions.Builder().build())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import java.io.Closeable
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class CastRelayServer(
|
||||
private val log: (String) -> Unit,
|
||||
) : Closeable {
|
||||
private val targets = ConcurrentHashMap<String, RelayTarget>()
|
||||
private val targetIds = ConcurrentHashMap<String, String>()
|
||||
private val fetcher = StreamProxyFetcher(log)
|
||||
|
||||
@Volatile
|
||||
private var router = StreamProxyRequestRouter()
|
||||
|
||||
@Volatile
|
||||
private var config = StreamProxyConfig(
|
||||
mode = StreamProxyMode.OFF,
|
||||
currentChannelLogin = "",
|
||||
proxyUrls = emptyList(),
|
||||
whitelistedChannels = emptySet(),
|
||||
debugLogging = false,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var serverSocket: ServerSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var baseUrl: String? = null
|
||||
|
||||
fun update(
|
||||
router: StreamProxyRequestRouter,
|
||||
config: StreamProxyConfig,
|
||||
) {
|
||||
this.router = router
|
||||
this.config = config
|
||||
}
|
||||
|
||||
fun relayUrlFor(
|
||||
sourceUrl: String,
|
||||
selectedQuality: String? = null,
|
||||
): String {
|
||||
ensureStarted()
|
||||
val relayBaseUrl = baseUrl ?: error("Cast relay did not start")
|
||||
val key = "$sourceUrl\n${selectedQuality.orEmpty()}"
|
||||
val id = targetIds.getOrPut(key) {
|
||||
stableTargetId(key)
|
||||
}
|
||||
targets[id] = RelayTarget(
|
||||
sourceUrl = sourceUrl,
|
||||
selectedQuality = selectedQuality,
|
||||
)
|
||||
|
||||
return "$relayBaseUrl/relay/$id.${extensionFor(sourceUrl)}"
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
serverSocket?.close()
|
||||
serverSocket = null
|
||||
baseUrl = null
|
||||
targets.clear()
|
||||
targetIds.clear()
|
||||
}
|
||||
|
||||
private fun ensureStarted() {
|
||||
if (serverSocket != null) return
|
||||
|
||||
synchronized(this) {
|
||||
if (serverSocket != null) return
|
||||
|
||||
val socket = ServerSocket(
|
||||
0,
|
||||
SERVER_BACKLOG,
|
||||
InetAddress.getByName("0.0.0.0"),
|
||||
)
|
||||
serverSocket = socket
|
||||
baseUrl = "http://${lanAddress()}:${socket.localPort}"
|
||||
|
||||
Thread {
|
||||
acceptLoop(socket)
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "GlacierCastRelay"
|
||||
start()
|
||||
}
|
||||
|
||||
log("cast_relay action=started url=$baseUrl")
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptLoop(socket: ServerSocket) {
|
||||
while (!socket.isClosed) {
|
||||
try {
|
||||
val client = socket.accept()
|
||||
Thread {
|
||||
handleClient(client)
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "GlacierCastRelayClient"
|
||||
start()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (!socket.isClosed) {
|
||||
log("cast_relay action=accept_failed reason=${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClient(socket: Socket) {
|
||||
socket.use { client ->
|
||||
try {
|
||||
val request = readRequest(client)
|
||||
?: return
|
||||
log(
|
||||
"cast_relay action=request method=${request.method} " +
|
||||
"path=${request.target.substringBefore("?")} " +
|
||||
"from=${client.inetAddress.hostAddress}",
|
||||
)
|
||||
|
||||
when (request.method.uppercase(Locale.US)) {
|
||||
"OPTIONS" -> sendResponse(
|
||||
socket = client,
|
||||
statusCode = 204,
|
||||
reasonPhrase = "No Content",
|
||||
headers = corsHeaders(),
|
||||
body = ByteArray(0),
|
||||
headOnly = true,
|
||||
)
|
||||
"GET",
|
||||
"HEAD" -> handleRelayRequest(client, request)
|
||||
else -> sendError(client, 405, "Method Not Allowed")
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
log("cast_relay action=request_failed reason=${error.javaClass.simpleName}")
|
||||
runCatching { sendError(client, 500, "Internal Server Error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRelayRequest(socket: Socket, request: HttpRequest) {
|
||||
val path = request.target.substringBefore("?")
|
||||
if (!path.startsWith("/relay/")) {
|
||||
sendError(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
val id = path
|
||||
.removePrefix("/relay/")
|
||||
.substringBefore(".")
|
||||
.takeIf(String::isNotEmpty)
|
||||
val target = targets[id]
|
||||
if (target == null) {
|
||||
log("cast_relay action=response path=$path status=404 reason=missing_target")
|
||||
sendError(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
val currentConfig = config
|
||||
val requestHeaders = CastRelayUpstreamHeaders.build(
|
||||
requestHeaders = request.headers,
|
||||
config = currentConfig,
|
||||
)
|
||||
val upstreamMethod = if (request.method.equals("HEAD", ignoreCase = true)) {
|
||||
"HEAD"
|
||||
} else {
|
||||
"GET"
|
||||
}
|
||||
val upstreamRequest = StreamProxyRequest(
|
||||
url = target.sourceUrl,
|
||||
method = upstreamMethod,
|
||||
headers = requestHeaders,
|
||||
)
|
||||
val decision = router.route(
|
||||
url = target.sourceUrl,
|
||||
method = upstreamMethod,
|
||||
headers = requestHeaders,
|
||||
config = currentConfig,
|
||||
)
|
||||
val response = fetcher.fetch(
|
||||
request = upstreamRequest,
|
||||
decision = decision,
|
||||
config = currentConfig,
|
||||
router = router,
|
||||
directWhenSkipped = true,
|
||||
)
|
||||
|
||||
if (response == null) {
|
||||
log("cast_relay action=response path=$path status=502 reason=upstream_null")
|
||||
sendError(socket, 502, "Bad Gateway")
|
||||
return
|
||||
}
|
||||
|
||||
response.use { upstream ->
|
||||
val headOnly = request.method.equals("HEAD", ignoreCase = true)
|
||||
var body = if (headOnly) {
|
||||
ByteArray(0)
|
||||
} else {
|
||||
upstream.body.readBytes()
|
||||
}
|
||||
val headers = responseHeaders(upstream)
|
||||
var mimeType = upstream.mimeType
|
||||
var selectedQuality: String? = null
|
||||
|
||||
if (!headOnly && isPlaylistResponse(target.sourceUrl, upstream)) {
|
||||
val playlist = decodeBody(body, upstream.encoding)
|
||||
val rewritten = HlsPlaylistRewriter.rewritePlaylist(
|
||||
body = playlist,
|
||||
baseUrl = target.sourceUrl,
|
||||
selectedQuality = target.selectedQuality,
|
||||
rewriteUrl = { nestedSourceUrl ->
|
||||
relayUrlFor(nestedSourceUrl)
|
||||
},
|
||||
)
|
||||
body = rewritten.body.toByteArray(StandardCharsets.UTF_8)
|
||||
selectedQuality = rewritten.selectedQuality
|
||||
mimeType = "application/vnd.apple.mpegurl"
|
||||
headers["Content-Type"] = "$mimeType; charset=utf-8"
|
||||
} else if (mimeType != null && headers.keys.none {
|
||||
it.equals("Content-Type", ignoreCase = true)
|
||||
}
|
||||
) {
|
||||
headers["Content-Type"] = mimeType
|
||||
}
|
||||
|
||||
headers.putAll(corsHeaders())
|
||||
headers["Content-Length"] = body.size.toString()
|
||||
log(
|
||||
"cast_relay action=response method=${request.method} " +
|
||||
"path=$path status=${upstream.statusCode} " +
|
||||
"source=${sourceDescription(target.sourceUrl)} " +
|
||||
"bytes=${body.size} mime=${mimeType.orEmpty()} " +
|
||||
"playlist=${isPlaylistResponse(target.sourceUrl, upstream)} " +
|
||||
"selected_quality=${selectedQuality.orEmpty()}",
|
||||
)
|
||||
|
||||
sendResponse(
|
||||
socket = socket,
|
||||
statusCode = upstream.statusCode,
|
||||
reasonPhrase = upstream.reasonPhrase,
|
||||
headers = headers,
|
||||
body = body,
|
||||
headOnly = headOnly,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun responseHeaders(response: StreamProxyResponse): MutableMap<String, String> {
|
||||
return response.headers
|
||||
.filterKeys { name ->
|
||||
!hopByHopHeaders.contains(name.lowercase(Locale.US))
|
||||
}
|
||||
.toMutableMap()
|
||||
}
|
||||
|
||||
private fun readRequest(socket: Socket): HttpRequest? {
|
||||
val input = socket.getInputStream().buffered()
|
||||
val requestLine = readAsciiLine(input) ?: return null
|
||||
val parts = requestLine.split(" ", limit = 3)
|
||||
if (parts.size < 2) return null
|
||||
|
||||
val headers = linkedMapOf<String, String>()
|
||||
while (true) {
|
||||
val line = readAsciiLine(input) ?: break
|
||||
if (line.isEmpty()) break
|
||||
|
||||
val separatorIndex = line.indexOf(":")
|
||||
if (separatorIndex <= 0) continue
|
||||
|
||||
headers[line.substring(0, separatorIndex).trim()] =
|
||||
line.substring(separatorIndex + 1).trim()
|
||||
}
|
||||
|
||||
return HttpRequest(
|
||||
method = parts[0],
|
||||
target = parts[1],
|
||||
headers = headers,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendError(
|
||||
socket: Socket,
|
||||
statusCode: Int,
|
||||
reasonPhrase: String,
|
||||
) {
|
||||
sendResponse(
|
||||
socket = socket,
|
||||
statusCode = statusCode,
|
||||
reasonPhrase = reasonPhrase,
|
||||
headers = corsHeaders() + mapOf("Content-Type" to "text/plain; charset=utf-8"),
|
||||
body = reasonPhrase.toByteArray(StandardCharsets.UTF_8),
|
||||
headOnly = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendResponse(
|
||||
socket: Socket,
|
||||
statusCode: Int,
|
||||
reasonPhrase: String,
|
||||
headers: Map<String, String>,
|
||||
body: ByteArray,
|
||||
headOnly: Boolean,
|
||||
) {
|
||||
val output = socket.getOutputStream()
|
||||
val headerText = buildString {
|
||||
append("HTTP/1.1 ")
|
||||
append(statusCode)
|
||||
append(" ")
|
||||
append(reasonPhrase)
|
||||
append("\r\n")
|
||||
headers.forEach { (name, value) ->
|
||||
append(name)
|
||||
append(": ")
|
||||
append(value)
|
||||
append("\r\n")
|
||||
}
|
||||
append("Connection: close\r\n")
|
||||
append("\r\n")
|
||||
}
|
||||
|
||||
output.write(headerText.toByteArray(StandardCharsets.ISO_8859_1))
|
||||
if (!headOnly) {
|
||||
output.write(body)
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun corsHeaders(): Map<String, String> {
|
||||
return mapOf(
|
||||
"Access-Control-Allow-Origin" to "*",
|
||||
"Access-Control-Allow-Methods" to "GET, HEAD, OPTIONS",
|
||||
"Access-Control-Allow-Headers" to "*",
|
||||
)
|
||||
}
|
||||
|
||||
private fun readAsciiLine(input: java.io.BufferedInputStream): String? {
|
||||
val buffer = StringBuilder()
|
||||
while (true) {
|
||||
val next = input.read()
|
||||
if (next == -1) {
|
||||
if (buffer.isEmpty()) return null
|
||||
break
|
||||
}
|
||||
if (next == '\n'.code) break
|
||||
if (next != '\r'.code) {
|
||||
buffer.append(next.toChar())
|
||||
}
|
||||
}
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
private fun isPlaylistResponse(
|
||||
sourceUrl: String,
|
||||
response: StreamProxyResponse,
|
||||
): Boolean {
|
||||
val mimeType = response.mimeType.orEmpty()
|
||||
return mimeType.contains("mpegurl", ignoreCase = true) ||
|
||||
mimeType.contains("m3u8", ignoreCase = true) ||
|
||||
URI(sourceUrl).path?.endsWith(".m3u8", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
private fun decodeBody(bytes: ByteArray, encoding: String?): String {
|
||||
return try {
|
||||
bytes.toString(Charset.forName(encoding ?: "UTF-8"))
|
||||
} catch (_: Exception) {
|
||||
bytes.toString(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableTargetId(key: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(key.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
return digest
|
||||
.take(12)
|
||||
.joinToString("") { byte ->
|
||||
"%02x".format(byte.toInt() and 0xff)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extensionFor(sourceUrl: String): String {
|
||||
val path = runCatching { URI(sourceUrl).path.orEmpty() }
|
||||
.getOrDefault("")
|
||||
.lowercase(Locale.US)
|
||||
|
||||
return when {
|
||||
path.endsWith(".m3u8") -> "m3u8"
|
||||
path.endsWith(".ts") -> "ts"
|
||||
path.endsWith(".mp4") -> "mp4"
|
||||
path.endsWith(".m4s") -> "m4s"
|
||||
path.endsWith(".aac") -> "aac"
|
||||
else -> "bin"
|
||||
}
|
||||
}
|
||||
|
||||
private fun sourceDescription(sourceUrl: String): String {
|
||||
return runCatching {
|
||||
val uri = URI(sourceUrl)
|
||||
"${uri.host.orEmpty()}${uri.path.orEmpty()}"
|
||||
}.getOrDefault("unknown")
|
||||
}
|
||||
|
||||
private fun lanAddress(): String {
|
||||
val interfaces = runCatching {
|
||||
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val address = interfaces
|
||||
.asSequence()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { networkInterface ->
|
||||
Collections.list(networkInterface.inetAddresses).asSequence()
|
||||
}
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { address ->
|
||||
!address.isLoopbackAddress &&
|
||||
!address.isLinkLocalAddress &&
|
||||
!address.isAnyLocalAddress
|
||||
}
|
||||
|
||||
return address?.hostAddress ?: "127.0.0.1"
|
||||
}
|
||||
|
||||
private data class HttpRequest(
|
||||
val method: String,
|
||||
val target: String,
|
||||
val headers: Map<String, String>,
|
||||
)
|
||||
|
||||
private data class RelayTarget(
|
||||
val sourceUrl: String,
|
||||
val selectedQuality: String?,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
private const val SERVER_BACKLOG = 32
|
||||
|
||||
private val hopByHopHeaders = setOf(
|
||||
"connection",
|
||||
"content-length",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
object CastRelayUpstreamHeaders {
|
||||
fun build(
|
||||
requestHeaders: Map<String, String>,
|
||||
config: StreamProxyConfig,
|
||||
): Map<String, String> {
|
||||
val headers = linkedMapOf<String, String>()
|
||||
val requestHeaderLookup = requestHeaders.entries.associateBy {
|
||||
it.key.lowercase(Locale.US)
|
||||
}
|
||||
|
||||
headers["Accept"] = acceptHeader(
|
||||
requestHeaderLookup["accept"]?.value,
|
||||
config,
|
||||
)
|
||||
headers["User-Agent"] = DEFAULT_USER_AGENT
|
||||
|
||||
passthroughHeaderNames.forEach { headerName ->
|
||||
requestHeaderLookup[headerName.lowercase(Locale.US)]
|
||||
?.value
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { value -> headers[headerName] = value }
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private fun acceptHeader(
|
||||
requestAccept: String?,
|
||||
config: StreamProxyConfig,
|
||||
): String {
|
||||
val accept = requestAccept
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: DEFAULT_ACCEPT
|
||||
|
||||
if (!config.enabled || accept.contains(ACCEPT_FLAG, ignoreCase = true)) {
|
||||
return accept
|
||||
}
|
||||
|
||||
return "$accept,$ACCEPT_FLAG"
|
||||
}
|
||||
|
||||
private const val ACCEPT_FLAG = "TTV-LOL-PRO"
|
||||
private const val DEFAULT_ACCEPT = "application/vnd.apple.mpegurl,application/x-mpegURL,*/*"
|
||||
private const val DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
private val passthroughHeaderNames = listOf(
|
||||
"Accept-Language",
|
||||
"Range",
|
||||
"If-Match",
|
||||
"If-None-Match",
|
||||
"If-Modified-Since",
|
||||
"If-Unmodified-Since",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object HlsPlaylistRewriter {
|
||||
data class MasterPlaylist(
|
||||
val variants: List<Variant>,
|
||||
)
|
||||
|
||||
data class Variant(
|
||||
val quality: String,
|
||||
val url: String,
|
||||
val streamInfoLine: String,
|
||||
val streamInfoIndex: Int,
|
||||
val uriIndex: Int,
|
||||
val bandwidth: Long?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val frameRate: Double?,
|
||||
val score: Double?,
|
||||
)
|
||||
|
||||
data class RewriteResult(
|
||||
val body: String,
|
||||
val selectedQuality: String?,
|
||||
)
|
||||
|
||||
fun parseMasterPlaylist(body: String, baseUrl: String): MasterPlaylist {
|
||||
return MasterPlaylist(parseVariants(body.lines(), baseUrl))
|
||||
}
|
||||
|
||||
fun rewritePlaylist(
|
||||
body: String,
|
||||
baseUrl: String,
|
||||
selectedQuality: String?,
|
||||
rewriteUrl: (String) -> String,
|
||||
): RewriteResult {
|
||||
val lines = body.lines()
|
||||
val variants = parseVariants(lines, baseUrl)
|
||||
|
||||
if (variants.isNotEmpty()) {
|
||||
return rewriteMasterPlaylist(
|
||||
lines = lines,
|
||||
variants = variants,
|
||||
selectedQuality = selectedQuality,
|
||||
rewriteUrl = rewriteUrl,
|
||||
)
|
||||
}
|
||||
|
||||
return RewriteResult(
|
||||
body = lines
|
||||
.map { line -> rewriteMediaPlaylistLine(line, baseUrl, rewriteUrl) }
|
||||
.joinToString("\n"),
|
||||
selectedQuality = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rewriteMasterPlaylist(
|
||||
lines: List<String>,
|
||||
variants: List<Variant>,
|
||||
selectedQuality: String?,
|
||||
rewriteUrl: (String) -> String,
|
||||
): RewriteResult {
|
||||
val selectedVariant = selectVariant(variants, selectedQuality)
|
||||
val variantByStreamInfoIndex = variants.associateBy { it.streamInfoIndex }
|
||||
val output = mutableListOf<String>()
|
||||
var lineIndex = 0
|
||||
|
||||
while (lineIndex < lines.size) {
|
||||
val variant = variantByStreamInfoIndex[lineIndex]
|
||||
if (variant == null) {
|
||||
output.add(lines[lineIndex])
|
||||
lineIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (selectedVariant == null || selectedVariant == variant) {
|
||||
output.add(variant.streamInfoLine)
|
||||
output.add(rewriteUrl(variant.url))
|
||||
}
|
||||
lineIndex = variant.uriIndex + 1
|
||||
}
|
||||
|
||||
return RewriteResult(
|
||||
body = output.joinToString("\n"),
|
||||
selectedQuality = selectedVariant?.quality,
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectVariant(
|
||||
variants: List<Variant>,
|
||||
selectedQuality: String?,
|
||||
): Variant? {
|
||||
val normalizedQuality = selectedQuality
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
?.takeIf(String::isNotEmpty)
|
||||
|
||||
return when (normalizedQuality) {
|
||||
null,
|
||||
"auto" -> null
|
||||
"highest" -> variants.maxWithOrNull(
|
||||
compareBy<Variant> { it.score ?: 0.0 }
|
||||
.thenBy { it.height ?: 0 }
|
||||
.thenBy { it.frameRate ?: 0.0 }
|
||||
.thenBy { it.bandwidth ?: 0L },
|
||||
)
|
||||
else -> variants.firstOrNull {
|
||||
it.quality.lowercase(Locale.US) == normalizedQuality
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteMediaPlaylistLine(
|
||||
line: String,
|
||||
baseUrl: String,
|
||||
rewriteUrl: (String) -> String,
|
||||
): String {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) return line
|
||||
|
||||
if (trimmed.startsWith("#")) {
|
||||
return uriAttributeRegex.replace(line) { match ->
|
||||
val originalUrl = match.groupValues[1]
|
||||
"URI=\"${rewriteUrl(resolveUrl(baseUrl, originalUrl))}\""
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteUrl(resolveUrl(baseUrl, trimmed))
|
||||
}
|
||||
|
||||
private fun parseVariants(lines: List<String>, baseUrl: String): List<Variant> {
|
||||
val variants = mutableListOf<Variant>()
|
||||
var lineIndex = 0
|
||||
|
||||
while (lineIndex < lines.size) {
|
||||
val line = lines[lineIndex].trim()
|
||||
if (!line.startsWith("#EXT-X-STREAM-INF:", ignoreCase = true)) {
|
||||
lineIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
val uriIndex = nextUriLineIndex(lines, lineIndex + 1)
|
||||
if (uriIndex == null) {
|
||||
lineIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
val attributes = parseAttributes(line.substringAfter(":"))
|
||||
val resolution = attributes["RESOLUTION"]
|
||||
?.split("x", limit = 2)
|
||||
?.mapNotNull { it.toIntOrNull() }
|
||||
?.takeIf { it.size == 2 }
|
||||
val width = resolution?.getOrNull(0)
|
||||
val height = resolution?.getOrNull(1)
|
||||
val frameRate = attributes["FRAME-RATE"]?.toDoubleOrNull()
|
||||
val bandwidth = attributes["BANDWIDTH"]?.toLongOrNull()
|
||||
val score = attributes["SCORE"]?.toDoubleOrNull()
|
||||
|
||||
variants += Variant(
|
||||
quality = qualityLabel(
|
||||
height = height,
|
||||
frameRate = frameRate,
|
||||
bandwidth = bandwidth,
|
||||
name = attributes["NAME"],
|
||||
),
|
||||
url = resolveUrl(baseUrl, lines[uriIndex].trim()),
|
||||
streamInfoLine = lines[lineIndex],
|
||||
streamInfoIndex = lineIndex,
|
||||
uriIndex = uriIndex,
|
||||
bandwidth = bandwidth,
|
||||
width = width,
|
||||
height = height,
|
||||
frameRate = frameRate,
|
||||
score = score,
|
||||
)
|
||||
lineIndex = uriIndex + 1
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
private fun nextUriLineIndex(lines: List<String>, startIndex: Int): Int? {
|
||||
for (index in startIndex until lines.size) {
|
||||
val line = lines[index].trim()
|
||||
if (line.isEmpty()) continue
|
||||
if (!line.startsWith("#")) return index
|
||||
if (line.startsWith("#EXT-X-STREAM-INF:", ignoreCase = true)) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseAttributes(value: String): Map<String, String> {
|
||||
return splitAttributeList(value).mapNotNull { attribute ->
|
||||
val separatorIndex = attribute.indexOf("=")
|
||||
if (separatorIndex <= 0) return@mapNotNull null
|
||||
|
||||
val name = attribute.substring(0, separatorIndex).trim()
|
||||
val rawValue = attribute.substring(separatorIndex + 1).trim()
|
||||
name to rawValue.trim('"')
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun splitAttributeList(value: String): List<String> {
|
||||
val attributes = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
|
||||
value.forEach { char ->
|
||||
when (char) {
|
||||
'"' -> {
|
||||
inQuotes = !inQuotes
|
||||
current.append(char)
|
||||
}
|
||||
',' -> {
|
||||
if (inQuotes) {
|
||||
current.append(char)
|
||||
} else {
|
||||
attributes += current.toString()
|
||||
current.clear()
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isNotEmpty()) {
|
||||
attributes += current.toString()
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private fun qualityLabel(
|
||||
height: Int?,
|
||||
frameRate: Double?,
|
||||
bandwidth: Long?,
|
||||
name: String?,
|
||||
): String {
|
||||
if (height != null) {
|
||||
val roundedFrameRate = frameRate?.roundToInt()
|
||||
return buildString {
|
||||
append(height)
|
||||
append("p")
|
||||
if (roundedFrameRate != null) {
|
||||
append(roundedFrameRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!name.isNullOrBlank()) return name
|
||||
|
||||
return bandwidth
|
||||
?.let { "${it / 1000}k" }
|
||||
?: "auto"
|
||||
}
|
||||
|
||||
private fun resolveUrl(baseUrl: String, value: String): String {
|
||||
return runCatching { URI(baseUrl).resolve(value).toString() }
|
||||
.getOrDefault(value)
|
||||
}
|
||||
|
||||
private val uriAttributeRegex = Regex("""URI="([^"]+)"""")
|
||||
}
|
||||
|
|
@ -1,25 +1,36 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebViewClient
|
||||
import com.namecallfilter.glacier.cast.CastStreamContext
|
||||
import com.namecallfilter.glacier.cast.GlacierCastController
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object StreamProxyChannel {
|
||||
private const val CHANNEL_NAME = "frosty/stream_proxy"
|
||||
|
||||
fun register(flutterEngine: FlutterEngine) {
|
||||
fun register(
|
||||
flutterEngine: FlutterEngine,
|
||||
context: Context,
|
||||
) {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
val methodChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
CHANNEL_NAME,
|
||||
)
|
||||
val routers = ConcurrentHashMap<Long, StreamProxyRequestRouter>()
|
||||
val clients = ConcurrentHashMap<Long, StreamProxyWebViewClient>()
|
||||
val castController = GlacierCastController(
|
||||
context = context,
|
||||
onStateChanged = { state ->
|
||||
mainHandler.post {
|
||||
methodChannel.invokeMethod("castStateChanged", state)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
methodChannel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
|
|
@ -40,28 +51,33 @@ object StreamProxyChannel {
|
|||
null,
|
||||
)
|
||||
val config = config(call)
|
||||
val router = routers.getOrPut(webViewIdentifier) {
|
||||
StreamProxyRequestRouter()
|
||||
}
|
||||
val client = clients[webViewIdentifier]?.also {
|
||||
it.updateConfig(config)
|
||||
} ?: StreamProxyWebViewClient(
|
||||
initialConfig = config,
|
||||
router = router,
|
||||
onPageFinished = { url ->
|
||||
mainHandler.post {
|
||||
methodChannel.invokeMethod(
|
||||
"pageFinished",
|
||||
mapOf(
|
||||
"webViewIdentifier" to webViewIdentifier,
|
||||
"url" to url,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
).also {
|
||||
clients[webViewIdentifier] = it
|
||||
}
|
||||
val router = StreamProxySessionRegistry.getOrCreateRouter(
|
||||
webViewIdentifier,
|
||||
)
|
||||
val client = StreamProxySessionRegistry.clientFor(webViewIdentifier)
|
||||
?.also { client ->
|
||||
client.updateConfig(config)
|
||||
}
|
||||
?: StreamProxyWebViewClient(
|
||||
initialConfig = config,
|
||||
router = router,
|
||||
onPageFinished = { url ->
|
||||
mainHandler.post {
|
||||
methodChannel.invokeMethod(
|
||||
"pageFinished",
|
||||
mapOf(
|
||||
"webViewIdentifier" to webViewIdentifier,
|
||||
"url" to url,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
).also { client ->
|
||||
StreamProxySessionRegistry.putClient(
|
||||
webViewIdentifier,
|
||||
client,
|
||||
)
|
||||
}
|
||||
|
||||
webView.webViewClient = client
|
||||
client.attachToWebView(webView)
|
||||
|
|
@ -71,18 +87,49 @@ object StreamProxyChannel {
|
|||
val config = config(call)
|
||||
val webViewIdentifier = webViewIdentifier(call)
|
||||
if (webViewIdentifier != null) {
|
||||
clients[webViewIdentifier]?.updateConfig(config)
|
||||
StreamProxySessionRegistry
|
||||
.clientFor(webViewIdentifier)
|
||||
?.updateConfig(config)
|
||||
} else {
|
||||
clients.values.forEach { it.updateConfig(config) }
|
||||
StreamProxySessionRegistry.updateAllClients(config)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"updateCastContext" -> {
|
||||
val config = config(call)
|
||||
val webViewIdentifier = webViewIdentifier(call)
|
||||
?: return@setMethodCallHandler result.success(null)
|
||||
castController.updateContext(
|
||||
CastStreamContext(
|
||||
webViewIdentifier = webViewIdentifier,
|
||||
channelLogin = config.currentChannelLogin,
|
||||
title = stringArgument(call, "title")
|
||||
?: config.currentChannelLogin,
|
||||
subtitle = stringArgument(call, "subtitle"),
|
||||
quality = stringArgument(call, "quality"),
|
||||
config = config,
|
||||
),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
"showCastDialog" -> {
|
||||
mainHandler.post {
|
||||
castController.showCastDialog()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"stopCasting" -> {
|
||||
mainHandler.post {
|
||||
castController.stopCasting()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"detach" -> {
|
||||
val webViewIdentifier = webViewIdentifier(call)
|
||||
?: return@setMethodCallHandler result.success(null)
|
||||
clients.remove(webViewIdentifier)
|
||||
StreamProxySessionRegistry.removeClient(webViewIdentifier)
|
||||
?.detachFromWebView()
|
||||
routers.remove(webViewIdentifier)
|
||||
StreamProxySessionRegistry.removeRouter(webViewIdentifier)
|
||||
|
||||
WebViewFlutterAndroidExternalApi.getWebView(
|
||||
flutterEngine,
|
||||
|
|
@ -117,4 +164,11 @@ object StreamProxyChannel {
|
|||
val arguments = call.arguments as? Map<*, *>
|
||||
return StreamProxyConfig.fromMap(arguments?.get("config") as? Map<*, *>)
|
||||
}
|
||||
|
||||
private fun stringArgument(call: MethodCall, key: String): String? {
|
||||
val arguments = call.arguments as? Map<*, *> ?: return null
|
||||
return (arguments[key] as? String)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import java.io.Closeable
|
||||
import java.io.InputStream
|
||||
|
||||
data class StreamProxyRequest(
|
||||
val url: String,
|
||||
val method: String,
|
||||
val headers: Map<String, String>,
|
||||
)
|
||||
|
||||
class StreamProxyResponse(
|
||||
val mimeType: String?,
|
||||
val encoding: String?,
|
||||
val statusCode: Int,
|
||||
val reasonPhrase: String,
|
||||
val headers: Map<String, String>,
|
||||
val body: InputStream,
|
||||
private val closeHandler: (() -> Unit)? = null,
|
||||
) : Closeable {
|
||||
override fun close() {
|
||||
try {
|
||||
body.close()
|
||||
} finally {
|
||||
closeHandler?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import java.util.concurrent.ConcurrentHashMap
|
|||
class StreamProxyRequestRouter {
|
||||
private val videoWeaverChannels = ConcurrentHashMap<String, String>()
|
||||
private val proxiedVideoWeaverUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
private val usherManifestUrls = ConcurrentHashMap<String, String>()
|
||||
private val usherChannelRegex = Regex("/hls/(.+)\\.m3u8", RegexOption.IGNORE_CASE)
|
||||
private val videoWeaverUrlRegex = Regex(
|
||||
"^https?://(?:[a-z0-9-]+\\.playlist\\.(?:live-video|ttvnw)\\.net|video-weaver\\.[a-z0-9-]+\\.hls\\.ttvnw\\.net)/v1/playlist/.+\\.m3u8(?:$|[?#])",
|
||||
|
|
@ -101,6 +102,7 @@ class StreamProxyRequestRouter {
|
|||
fun rememberUsherManifest(channel: String?, manifestUrl: String, manifestBody: String) {
|
||||
val normalizedChannel = channel?.lowercase(Locale.US) ?: return
|
||||
val baseUri = runCatching { URI(manifestUrl) }.getOrNull() ?: return
|
||||
usherManifestUrls[normalizedChannel] = manifestUrl
|
||||
|
||||
manifestBody
|
||||
.lineSequence()
|
||||
|
|
@ -115,6 +117,21 @@ class StreamProxyRequestRouter {
|
|||
}
|
||||
}
|
||||
|
||||
fun rememberUsherRequest(url: String) {
|
||||
val channel = extractUsherChannel(url) ?: return
|
||||
usherManifestUrls[channel] = url
|
||||
}
|
||||
|
||||
fun latestUsherManifestUrl(channel: String?): String? {
|
||||
val normalizedChannel = channel
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: return null
|
||||
|
||||
return usherManifestUrls[normalizedChannel]
|
||||
}
|
||||
|
||||
private fun routeUsher(
|
||||
url: String,
|
||||
config: StreamProxyConfig,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object StreamProxySessionRegistry {
|
||||
private val routers = ConcurrentHashMap<Long, StreamProxyRequestRouter>()
|
||||
private val clients = ConcurrentHashMap<Long, StreamProxyWebViewClient>()
|
||||
|
||||
fun routerFor(webViewIdentifier: Long): StreamProxyRequestRouter? {
|
||||
return routers[webViewIdentifier]
|
||||
}
|
||||
|
||||
fun getOrCreateRouter(webViewIdentifier: Long): StreamProxyRequestRouter {
|
||||
return routers.getOrPut(webViewIdentifier) {
|
||||
StreamProxyRequestRouter()
|
||||
}
|
||||
}
|
||||
|
||||
fun clientFor(webViewIdentifier: Long): StreamProxyWebViewClient? {
|
||||
return clients[webViewIdentifier]
|
||||
}
|
||||
|
||||
fun putClient(
|
||||
webViewIdentifier: Long,
|
||||
client: StreamProxyWebViewClient,
|
||||
) {
|
||||
clients[webViewIdentifier] = client
|
||||
}
|
||||
|
||||
fun removeClient(webViewIdentifier: Long): StreamProxyWebViewClient? {
|
||||
return clients.remove(webViewIdentifier)
|
||||
}
|
||||
|
||||
fun removeRouter(webViewIdentifier: Long) {
|
||||
routers.remove(webViewIdentifier)
|
||||
}
|
||||
|
||||
fun updateAllClients(config: StreamProxyConfig) {
|
||||
clients.values.forEach { client ->
|
||||
client.updateConfig(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
1
android/app/src/main/res/values/strings.xml
Normal file
1
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<resources><string name="app_name">Glacier</string></resources>
|
||||
|
|
@ -17,6 +17,20 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="GlacierCastRouteButtonTheme" parent="@style/Theme.AppCompat.NoActionBar">
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:colorBackground">#000000</item>
|
||||
<item name="android:colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">#000000</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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"}"""))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.namecallfilter.glacier.streamproxy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class StreamProxyRequestRouterTest {
|
||||
@Test
|
||||
fun routesFlaggedUsherRequestsThroughProxy() {
|
||||
val router = StreamProxyRequestRouter()
|
||||
val decision = router.route(
|
||||
url = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?player_type=site",
|
||||
method = "GET",
|
||||
headers = mapOf("Accept" to "application/vnd.apple.mpegurl,TTV-LOL-PRO"),
|
||||
config = enabledConfig(),
|
||||
)
|
||||
|
||||
assertEquals(StreamProxyRequestType.USHER, decision.requestType)
|
||||
assertEquals(StreamProxyAction.PROXY, decision.action)
|
||||
assertEquals("streamer", decision.channel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remembersLatestUsherManifestUrlFromManifestResponses() {
|
||||
val router = StreamProxyRequestRouter()
|
||||
val manifestUrl = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?token=abc"
|
||||
val manifestBody = """
|
||||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=100,RESOLUTION=640x360
|
||||
https://video-weaver.example.hls.ttvnw.net/v1/playlist/live.m3u8
|
||||
""".trimIndent()
|
||||
|
||||
router.rememberUsherManifest(
|
||||
channel = "Streamer",
|
||||
manifestUrl = manifestUrl,
|
||||
manifestBody = manifestBody,
|
||||
)
|
||||
|
||||
assertEquals(manifestUrl, router.latestUsherManifestUrl("streamer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remembersLatestUsherManifestUrlFromObservedRequests() {
|
||||
val router = StreamProxyRequestRouter()
|
||||
val manifestUrl = "https://usher.ttvnw.net/api/channel/hls/Streamer.m3u8?token=abc"
|
||||
|
||||
router.rememberUsherRequest(manifestUrl)
|
||||
|
||||
assertEquals(manifestUrl, router.latestUsherManifestUrl("streamer"))
|
||||
assertNull(router.latestUsherManifestUrl("other_streamer"))
|
||||
}
|
||||
|
||||
private fun enabledConfig() = StreamProxyConfig(
|
||||
mode = StreamProxyMode.TTV_LOL_PRO,
|
||||
currentChannelLogin = "streamer",
|
||||
proxyUrls = listOf("proxy.example.com:3128"),
|
||||
whitelistedChannels = emptySet(),
|
||||
debugLogging = false,
|
||||
)
|
||||
}
|
||||
16
cast_receiver/README.md
Normal file
16
cast_receiver/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Glacier Cast Receiver
|
||||
|
||||
This is the custom Google Cast Web Receiver used for low-latency HLS casting.
|
||||
|
||||
To use it on devices:
|
||||
|
||||
1. Host `index.html` over HTTPS.
|
||||
2. Register that URL as a Custom Receiver in the Google Cast SDK Developer Console.
|
||||
3. Put the assigned receiver app ID in `.env`:
|
||||
|
||||
```text
|
||||
CAST_RECEIVER_APP_ID=YOUR_APP_ID
|
||||
```
|
||||
|
||||
If `CAST_RECEIVER_APP_ID` is empty, the Android app falls back to Google's
|
||||
default media receiver so existing casting still works.
|
||||
150
cast_receiver/index.html
Normal file
150
cast_receiver/index.html
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Glacier Cast Receiver</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
cast-media-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<cast-media-player></cast-media-player>
|
||||
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const MESSAGE_NAMESPACE = 'urn:x-cast:com.namecallfilter.glacier.cast';
|
||||
const TARGET_LATENCY_SECONDS = 3;
|
||||
const MAX_LATENCY_SECONDS = 6;
|
||||
const STATUS_INTERVAL_MS = 1000;
|
||||
const CHASE_INTERVAL_MS = 750;
|
||||
|
||||
const context = cast.framework.CastReceiverContext.getInstance();
|
||||
const playerManager = context.getPlayerManager();
|
||||
const playbackConfig = new cast.framework.PlaybackConfig();
|
||||
|
||||
playbackConfig.autoResumeNumberOfSegments = 1;
|
||||
playbackConfig.enableSmoothLiveRefresh = false;
|
||||
playbackConfig.segmentRequestRetryLimit = 1;
|
||||
playbackConfig.shakaConfig = {
|
||||
streaming: {
|
||||
lowLatencyMode: true,
|
||||
bufferingGoal: 3,
|
||||
rebufferingGoal: 0.5,
|
||||
bufferBehind: 6,
|
||||
updateIntervalSeconds: 0.5,
|
||||
startAtSegmentBoundary: false,
|
||||
liveSync: {
|
||||
enabled: true,
|
||||
targetLatency: TARGET_LATENCY_SECONDS,
|
||||
targetLatencyTolerance: 1,
|
||||
maxPlaybackRate: 1.1,
|
||||
minPlaybackRate: 0.95,
|
||||
},
|
||||
retryParameters: {
|
||||
maxAttempts: 2,
|
||||
baseDelay: 250,
|
||||
backoffFactor: 1.4,
|
||||
fuzzFactor: 0.2,
|
||||
},
|
||||
},
|
||||
manifest: {
|
||||
hls: {
|
||||
ignoreTextStreamFailures: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof playerManager.setPlaybackConfig === 'function') {
|
||||
playerManager.setPlaybackConfig(playbackConfig);
|
||||
}
|
||||
|
||||
playerManager.setMessageInterceptor(
|
||||
cast.framework.messages.MessageType.LOAD,
|
||||
(loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
if (media) {
|
||||
media.entity = media.entity || media.contentId;
|
||||
media.contentUrl = media.contentUrl || media.contentId;
|
||||
media.streamType = cast.framework.messages.StreamType.LIVE;
|
||||
media.contentType =
|
||||
media.contentType || 'application/x-mpegURL';
|
||||
}
|
||||
loadRequestData.autoplay = true;
|
||||
return loadRequestData;
|
||||
},
|
||||
);
|
||||
|
||||
const finiteNumber = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const liveStatus = () => {
|
||||
const range = playerManager.getLiveSeekableRange();
|
||||
if (!range || !finiteNumber(range.end)) return null;
|
||||
|
||||
const currentTime = playerManager.getCurrentTimeSec();
|
||||
if (!finiteNumber(currentTime)) return null;
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
rangeStart: finiteNumber(range.start) ? range.start : null,
|
||||
rangeEnd: range.end,
|
||||
latency: Math.max(0, range.end - currentTime),
|
||||
};
|
||||
};
|
||||
|
||||
const sendStatus = () => {
|
||||
const status = liveStatus();
|
||||
const message = {
|
||||
type: 'status',
|
||||
latencyMs: status == null ? null : Math.round(status.latency * 1000),
|
||||
playerState: playerManager.getPlayerState(),
|
||||
currentTimeSec: status == null ? null : status.currentTime,
|
||||
rangeStartSec: status == null ? null : status.rangeStart,
|
||||
rangeEndSec: status == null ? null : status.rangeEnd,
|
||||
targetLatencySec: TARGET_LATENCY_SECONDS,
|
||||
};
|
||||
|
||||
for (const sender of context.getSenders()) {
|
||||
context.sendCustomMessage(MESSAGE_NAMESPACE, sender.id, message);
|
||||
}
|
||||
};
|
||||
|
||||
const chaseLiveEdge = () => {
|
||||
const status = liveStatus();
|
||||
if (!status || status.latency <= MAX_LATENCY_SECONDS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = status.rangeStart == null ? 0 : status.rangeStart;
|
||||
const targetTime = Math.max(
|
||||
start,
|
||||
status.rangeEnd - TARGET_LATENCY_SECONDS,
|
||||
);
|
||||
|
||||
playerManager.seek({
|
||||
currentTime: targetTime,
|
||||
resumeState: cast.framework.messages.ResumeState.PLAYBACK_START,
|
||||
});
|
||||
};
|
||||
|
||||
setInterval(sendStatus, STATUS_INTERVAL_MS);
|
||||
setInterval(chaseLiveEdge, CHASE_INTERVAL_MS);
|
||||
|
||||
context.start({
|
||||
disableIdleTimeout: true,
|
||||
playbackConfig,
|
||||
statusText: 'Glacier',
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load diff
19
lib/screens/channel/video/cast_aware_pointer_blocker.dart
Normal file
19
lib/screens/channel/video/cast_aware_pointer_blocker.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
|
||||
class CastAwarePointerBlocker extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const CastAwarePointerBlocker({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, child) =>
|
||||
AbsorbPointer(absorbing: castState.isCasting, child: child),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/screens/channel/video/cast_button.dart
Normal file
90
lib/screens/channel/video/cast_button.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
|
||||
class CastButton extends StatelessWidget {
|
||||
final Color? color;
|
||||
final List<Shadow>? shadows;
|
||||
|
||||
const CastButton({super.key, this.color, this.shadows});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Platform.isAndroid) return const SizedBox.shrink();
|
||||
|
||||
return ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) => SizedBox.square(
|
||||
dimension: 48,
|
||||
child: IconButton(
|
||||
tooltip: castState.isCasting
|
||||
? 'Casting to ${castState.receiverName ?? 'device'}'
|
||||
: 'Cast',
|
||||
icon: Icon(
|
||||
castState.isCasting
|
||||
? Icons.cast_connected_rounded
|
||||
: Icons.cast_rounded,
|
||||
color: castState.isCasting ? Colors.white : color,
|
||||
shadows: shadows,
|
||||
),
|
||||
onPressed: () => _handlePressed(context, castState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePressed(BuildContext context, CastState castState) async {
|
||||
if (!castState.isCasting) {
|
||||
await StreamProxyBridge.showCastDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await _showCastControls(context, castState);
|
||||
}
|
||||
|
||||
Future<void> _showCastControls(BuildContext context, CastState castState) {
|
||||
final receiverName = castState.receiverName ?? 'Cast device';
|
||||
final latency = castState.latencySeconds;
|
||||
|
||||
return showModalBottomSheetWithProperFocus<void>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.cast_connected_rounded),
|
||||
title: Text(receiverName),
|
||||
subtitle: latency == null
|
||||
? const Text('Casting')
|
||||
: Text('Casting - ${latency}s latency'),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
label: const Text('Stop casting'),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await StreamProxyBridge.stopCasting();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,12 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/video/cast_button.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
|
|
@ -123,16 +126,21 @@ class VideoOverlay extends StatelessWidget {
|
|||
},
|
||||
);
|
||||
|
||||
final refreshButton = Tooltip(
|
||||
message: 'Refresh',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: kOverlayShadow,
|
||||
final refreshButton = ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) => Tooltip(
|
||||
message: castState.isCasting ? 'Casting' : 'Refresh',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: castState.isCasting
|
||||
? surfaceColor.withValues(alpha: 0.4)
|
||||
: surfaceColor,
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
onPressed: castState.isCasting ? null : videoStore.handleRefresh,
|
||||
),
|
||||
onPressed: videoStore.handleRefresh,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -261,6 +269,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
if (videoStore.settingsStore.fullScreen &&
|
||||
context.isLandscape)
|
||||
chatOverlayButton,
|
||||
CastButton(color: surfaceColor, shadows: kOverlayShadow),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
|
|
@ -330,30 +339,57 @@ class VideoOverlay extends StatelessWidget {
|
|||
if (videoStore.settingsStore.fullScreen &&
|
||||
context.isLandscape)
|
||||
chatOverlayButton,
|
||||
CastButton(color: surfaceColor, shadows: kOverlayShadow),
|
||||
videoSettingsButton,
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: Tooltip(
|
||||
message: videoStore.paused ? 'Play' : 'Pause',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(
|
||||
videoStore.paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
child: ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) {
|
||||
return Tooltip(
|
||||
message: castState.receiverName == null
|
||||
? 'Casting'
|
||||
: 'Casting to ${castState.receiverName}',
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
Icons.cast_connected_rounded,
|
||||
size: 56,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: videoStore.handlePausePlay,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: videoStore.paused ? 'Play' : 'Pause',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(
|
||||
videoStore.paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: videoStore.handlePausePlay,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Align(
|
||||
|
|
@ -447,18 +483,30 @@ class VideoOverlay extends StatelessWidget {
|
|||
color: surfaceColor,
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
Observer(
|
||||
builder: (context) => Text(
|
||||
videoStore.latency ?? '—',
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<CastState>(
|
||||
valueListenable:
|
||||
StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
final castLatency =
|
||||
castState.latencySeconds;
|
||||
|
||||
return Observer(
|
||||
builder: (context) => Text(
|
||||
castState.isCasting &&
|
||||
castLatency != null
|
||||
? '${castLatency}s'
|
||||
: videoStore.latency ?? '—',
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -467,17 +515,26 @@ class VideoOverlay extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Enter picture-in-picture',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.picture_in_picture_alt_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
onPressed: videoStore.togglePictureInPicture,
|
||||
),
|
||||
ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: 'Enter picture-in-picture',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.picture_in_picture_alt_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: kOverlayShadow,
|
||||
),
|
||||
onPressed: videoStore.togglePictureInPicture,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
refreshButton,
|
||||
rotateButton,
|
||||
|
|
|
|||
|
|
@ -69,17 +69,9 @@ abstract class VideoStoreBase with Store {
|
|||
_latency = receivedLatency;
|
||||
|
||||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
if (StreamProxyBridge.castState.value.isCasting) return;
|
||||
|
||||
// Parse latency from abbreviated format: "5s" -> 5.0
|
||||
final numericPart = receivedLatency.replaceAll(
|
||||
RegExp(r'[^0-9.]'),
|
||||
'',
|
||||
);
|
||||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
|
||||
if (latencyAsDouble != null) {
|
||||
settingsStore.syncedChatDelay = latencyAsDouble;
|
||||
}
|
||||
_syncChatDelayFromLatencyText(receivedLatency);
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
|
|
@ -97,9 +89,13 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastStreamQuality = prefs.getString('last_stream_quality');
|
||||
if (lastStreamQuality == null) return;
|
||||
setStreamQuality(lastStreamQuality);
|
||||
if (lastStreamQuality == null) {
|
||||
await _updateCastContext();
|
||||
return;
|
||||
}
|
||||
await setStreamQuality(lastStreamQuality);
|
||||
}
|
||||
await _updateCastContext();
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
|
|
@ -201,6 +197,13 @@ abstract class VideoStoreBase with Store {
|
|||
String get streamQuality =>
|
||||
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
|
||||
|
||||
String get _castQuality {
|
||||
final quality = streamQuality;
|
||||
if (quality != 'Auto') return quality;
|
||||
|
||||
return settingsStore.defaultToHighestQuality ? 'highest' : 'auto';
|
||||
}
|
||||
|
||||
@readonly
|
||||
String? _latency;
|
||||
|
||||
|
|
@ -298,6 +301,8 @@ abstract class VideoStoreBase with Store {
|
|||
),
|
||||
(_) => unawaited(_syncStreamProxyBridge(refresh: true)),
|
||||
);
|
||||
|
||||
StreamProxyBridge.castState.addListener(_handleCastStateChanged);
|
||||
}
|
||||
|
||||
updateStreamInfo();
|
||||
|
|
@ -320,12 +325,10 @@ abstract class VideoStoreBase with Store {
|
|||
// If autoSync was just enabled and we already have a cached latency
|
||||
// value (e.g. from showLatency tracking), apply it immediately instead
|
||||
// of waiting for the next tracker cycle (up to 60s).
|
||||
if (autoSync && _latency != null) {
|
||||
final numericPart = _latency!.replaceAll(RegExp(r'[^0-9.]'), '');
|
||||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
if (latencyAsDouble != null) {
|
||||
settingsStore.syncedChatDelay = latencyAsDouble;
|
||||
}
|
||||
if (autoSync &&
|
||||
(_latency != null ||
|
||||
StreamProxyBridge.castState.value.latency != null)) {
|
||||
_syncChatDelayFromCurrentSource();
|
||||
}
|
||||
} else {
|
||||
// Stop tracker if both settings are now disabled
|
||||
|
|
@ -341,15 +344,69 @@ abstract class VideoStoreBase with Store {
|
|||
);
|
||||
}
|
||||
|
||||
bool get _shouldUseStreamProxy =>
|
||||
Platform.isAndroid &&
|
||||
settingsStore.streamProxyMode == StreamProxyMode.ttvLolPro;
|
||||
|
||||
StreamProxyConfig get _streamProxyConfig => StreamProxyConfig.fromSettings(
|
||||
settingsStore: settingsStore,
|
||||
currentChannelLogin: userLogin,
|
||||
);
|
||||
|
||||
bool get isCasting => StreamProxyBridge.castState.value.isCasting;
|
||||
|
||||
void _handleCastStateChanged() {
|
||||
final castState = StreamProxyBridge.castState.value;
|
||||
|
||||
if (castState.isCasting) {
|
||||
unawaited(_pauseLocalPlaybackForCast());
|
||||
_syncChatDelayFromCurrentSource();
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsStore.autoSyncChatDelay && _latency != null) {
|
||||
_syncChatDelayFromLatencyText(_latency!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pauseLocalPlaybackForCast() async {
|
||||
try {
|
||||
await videoWebViewController.runJavaScript(
|
||||
'(window._videoEl || document.getElementsByTagName("video")[0])?.pause();',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
} finally {
|
||||
_paused = true;
|
||||
if (Platform.isAndroid) pip.setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncChatDelayFromCurrentSource() {
|
||||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
|
||||
final castState = StreamProxyBridge.castState.value;
|
||||
final castLatency = castState.latency;
|
||||
if (castState.isCasting) {
|
||||
if (castLatency != null) {
|
||||
settingsStore.syncedChatDelay = (castLatency.inMilliseconds / 1000)
|
||||
.ceilToDouble();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final latency = _latency;
|
||||
if (latency != null) {
|
||||
_syncChatDelayFromLatencyText(latency);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncChatDelayFromLatencyText(String latency) {
|
||||
// Parse abbreviated Twitch latency format: "5s" -> 5.0.
|
||||
final numericPart = latency.replaceAll(RegExp(r'[^0-9.]'), '');
|
||||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
|
||||
if (latencyAsDouble != null) {
|
||||
settingsStore.syncedChatDelay = latencyAsDouble;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initializeVideoPlayer() async {
|
||||
if (!settingsStore.showVideo) return;
|
||||
|
||||
|
|
@ -372,19 +429,6 @@ abstract class VideoStoreBase with Store {
|
|||
final platformController = videoWebViewController.platform;
|
||||
if (platformController is! AndroidWebViewController) return;
|
||||
|
||||
if (!_shouldUseStreamProxy) {
|
||||
if (_streamProxyAttached) {
|
||||
await _streamProxyBridge.detach(platformController);
|
||||
_streamProxyAttached = false;
|
||||
await videoWebViewController.setNavigationDelegate(_navigationDelegate);
|
||||
}
|
||||
|
||||
if (refresh && settingsStore.showVideo) {
|
||||
await handleRefresh(haptic: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamProxyAttached) {
|
||||
await _streamProxyBridge.updateConfig(_streamProxyConfig);
|
||||
} else {
|
||||
|
|
@ -396,11 +440,24 @@ abstract class VideoStoreBase with Store {
|
|||
_streamProxyAttached = true;
|
||||
}
|
||||
|
||||
await _updateCastContext();
|
||||
|
||||
if (refresh && settingsStore.showVideo) {
|
||||
await handleRefresh(haptic: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateCastContext() async {
|
||||
if (!Platform.isAndroid || !_streamProxyAttached) return;
|
||||
|
||||
await _streamProxyBridge.updateCastContext(
|
||||
config: _streamProxyConfig,
|
||||
title: _streamInfo?.userName ?? userLogin,
|
||||
subtitle: _streamInfo?.title,
|
||||
quality: _castQuality,
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> updateStreamQualities() async {
|
||||
try {
|
||||
|
|
@ -437,6 +494,8 @@ abstract class VideoStoreBase with Store {
|
|||
''');
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
} finally {
|
||||
await _updateCastContext();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +534,7 @@ abstract class VideoStoreBase with Store {
|
|||
});
|
||||
''');
|
||||
_streamQualityIndex = newStreamQualityIndex;
|
||||
await _updateCastContext();
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
|
|
@ -1039,6 +1099,8 @@ abstract class VideoStoreBase with Store {
|
|||
if (!settingsStore.showVideo) {
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
} finally {
|
||||
await _updateCastContext();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1064,6 +1126,8 @@ abstract class VideoStoreBase with Store {
|
|||
/// Refreshes the stream webview and updates the stream info.
|
||||
@action
|
||||
Future<void> handleRefresh({bool haptic = true}) async {
|
||||
if (isCasting) return;
|
||||
|
||||
if (haptic) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
|
@ -1090,6 +1154,8 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
/// Play or pause the video depending on the current state of [_paused].
|
||||
void handlePausePlay() {
|
||||
if (isCasting) return;
|
||||
|
||||
try {
|
||||
if (_paused) {
|
||||
videoWebViewController.runJavaScript(
|
||||
|
|
@ -1109,6 +1175,8 @@ abstract class VideoStoreBase with Store {
|
|||
///
|
||||
/// Uses the native Android PiP API.
|
||||
void requestPictureInPicture() {
|
||||
if (isCasting) return;
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
pip.enterPipMode(autoEnter: true);
|
||||
|
|
@ -1149,6 +1217,9 @@ abstract class VideoStoreBase with Store {
|
|||
_disposeAndroidAutoPipReaction?.call();
|
||||
_disposeLatencySettingsReaction?.call();
|
||||
_disposeStreamProxyReaction?.call();
|
||||
if (Platform.isAndroid) {
|
||||
StreamProxyBridge.castState.removeListener(_handleCastStateChanged);
|
||||
}
|
||||
|
||||
final platformController = videoWebViewController.platform;
|
||||
if (_streamProxyAttached &&
|
||||
|
|
|
|||
32
lib/services/cast_state.dart
Normal file
32
lib/services/cast_state.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
class CastState {
|
||||
final bool isCasting;
|
||||
final String? receiverName;
|
||||
final Duration? latency;
|
||||
|
||||
const CastState({required this.isCasting, this.receiverName, this.latency});
|
||||
|
||||
const CastState.disconnected()
|
||||
: isCasting = false,
|
||||
receiverName = null,
|
||||
latency = null;
|
||||
|
||||
int? get latencySeconds => latency?.inSeconds;
|
||||
|
||||
factory CastState.fromMethodChannelPayload(Object? payload) {
|
||||
if (payload is! Map) return const CastState.disconnected();
|
||||
|
||||
final isCasting = payload['isCasting'] == true;
|
||||
final receiverName = payload['receiverName'];
|
||||
final latencyMs = payload['latencyMs'];
|
||||
|
||||
return CastState(
|
||||
isCasting: isCasting,
|
||||
receiverName: receiverName is String && receiverName.trim().isNotEmpty
|
||||
? receiverName.trim()
|
||||
: null,
|
||||
latency: latencyMs is num && latencyMs >= 0
|
||||
? Duration(milliseconds: latencyMs.round())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,24 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_config.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
class StreamProxyBridge {
|
||||
static const _channel = MethodChannel('frosty/stream_proxy');
|
||||
static final castState = ValueNotifier<CastState>(
|
||||
const CastState.disconnected(),
|
||||
);
|
||||
static StreamProxyBridge? _activeBridge;
|
||||
static var _methodCallHandlerAttached = false;
|
||||
|
||||
void Function(String url)? _onPageFinished;
|
||||
int? _webViewIdentifier;
|
||||
|
||||
StreamProxyBridge() {
|
||||
_channel.setMethodCallHandler(_handleMethodCall);
|
||||
_activeBridge = this;
|
||||
_ensureMethodCallHandler();
|
||||
}
|
||||
|
||||
bool get isSupported => Platform.isAndroid;
|
||||
|
|
@ -42,6 +49,25 @@ class StreamProxyBridge {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> updateCastContext({
|
||||
required StreamProxyConfig config,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? quality,
|
||||
}) async {
|
||||
if (!isSupported) return;
|
||||
final webViewIdentifier = _webViewIdentifier;
|
||||
if (webViewIdentifier == null) return;
|
||||
|
||||
await _invoke('updateCastContext', {
|
||||
'webViewIdentifier': webViewIdentifier,
|
||||
'config': config.toMethodChannelPayload(),
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
'quality': quality,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> detach(AndroidWebViewController controller) async {
|
||||
if (!isSupported) return;
|
||||
|
||||
|
|
@ -55,10 +81,29 @@ class StreamProxyBridge {
|
|||
void dispose() {
|
||||
_onPageFinished = null;
|
||||
_webViewIdentifier = null;
|
||||
_channel.setMethodCallHandler(null);
|
||||
if (identical(_activeBridge, this)) {
|
||||
_activeBridge = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _invoke(String method, Map<String, Object?> arguments) async {
|
||||
await _invokeStatic(method, arguments);
|
||||
}
|
||||
|
||||
static Future<void> showCastDialog() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _invokeStatic('showCastDialog', const {});
|
||||
}
|
||||
|
||||
static Future<void> stopCasting() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _invokeStatic('stopCasting', const {});
|
||||
}
|
||||
|
||||
static Future<void> _invokeStatic(
|
||||
String method,
|
||||
Map<String, Object?> arguments,
|
||||
) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>(method, arguments);
|
||||
} on PlatformException catch (e) {
|
||||
|
|
@ -66,17 +111,27 @@ class StreamProxyBridge {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _handleMethodCall(MethodCall call) async {
|
||||
static void _ensureMethodCallHandler() {
|
||||
if (_methodCallHandlerAttached) return;
|
||||
|
||||
_channel.setMethodCallHandler(_handleMethodCall);
|
||||
_methodCallHandlerAttached = true;
|
||||
}
|
||||
|
||||
static Future<void> _handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'pageFinished':
|
||||
final arguments = call.arguments;
|
||||
if (arguments is Map) {
|
||||
final url = arguments['url'];
|
||||
if (url is String) {
|
||||
_onPageFinished?.call(url);
|
||||
_activeBridge?._onPageFinished?.call(url);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'castStateChanged':
|
||||
castState.value = CastState.fromMethodChannelPayload(call.arguments);
|
||||
return;
|
||||
default:
|
||||
debugPrint('Unknown stream proxy bridge method: ${call.method}');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/video/cast_aware_pointer_blocker.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
StreamProxyBridge.castState.value = const CastState.disconnected();
|
||||
});
|
||||
|
||||
testWidgets('absorbs child pointer events while casting', (tester) async {
|
||||
StreamProxyBridge.castState.value = const CastState(isCasting: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: CastAwarePointerBlocker(child: Text('video'))),
|
||||
);
|
||||
|
||||
final blocker = _videoBlocker(tester);
|
||||
|
||||
expect(blocker.absorbing, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('lets child pointer events through when not casting', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: CastAwarePointerBlocker(child: Text('video'))),
|
||||
);
|
||||
|
||||
final blocker = _videoBlocker(tester);
|
||||
|
||||
expect(blocker.absorbing, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
AbsorbPointer _videoBlocker(WidgetTester tester) {
|
||||
return tester
|
||||
.widgetList<AbsorbPointer>(find.byType(AbsorbPointer))
|
||||
.singleWhere((widget) => widget.child is Text);
|
||||
}
|
||||
28
test/services/cast_state_test.dart
Normal file
28
test/services/cast_state_test.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
|
||||
void main() {
|
||||
group('CastState', () {
|
||||
test('parses active cast state from method channel payload', () {
|
||||
final state = CastState.fromMethodChannelPayload({
|
||||
'isCasting': true,
|
||||
'receiverName': 'Jacoby\'s TV',
|
||||
'latencyMs': 7420,
|
||||
});
|
||||
|
||||
expect(state.isCasting, isTrue);
|
||||
expect(state.receiverName, 'Jacoby\'s TV');
|
||||
expect(state.latency, const Duration(milliseconds: 7420));
|
||||
expect(state.latencySeconds, 7);
|
||||
});
|
||||
|
||||
test('treats missing and malformed payloads as disconnected', () {
|
||||
expect(CastState.fromMethodChannelPayload(null).isCasting, isFalse);
|
||||
expect(CastState.fromMethodChannelPayload('bad').isCasting, isFalse);
|
||||
expect(
|
||||
CastState.fromMethodChannelPayload({'isCasting': false}).latency,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue