add ad proxying

This commit is contained in:
kaoru 2026-06-08 20:40:37 -04:00
parent 2b81f2cb31
commit b45080dcc8
28 changed files with 4366 additions and 1601 deletions

3
.fvmrc Normal file
View file

@ -0,0 +1,3 @@
{
"flutter": "3.38.9"
}

145
.gitignore vendored
View file

@ -1,71 +1,74 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
# .vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Folder containing random testing files
random/
# fastlane specific
**/fastlane/report.xml
# deliver temporary files
**/fastlane/Preview.html
# snapshot generated screenshots
**/fastlane/screenshots
**/fastlane/metadata/android/en-US/images
# scan temporary files
**/fastlane/test_output
# potentially sensitive review information
**/fastlane/metadata/review_information
# secrets
.env
# ai
.claude/settings.local.json
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
# .vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Folder containing random testing files
random/
# fastlane specific
**/fastlane/report.xml
# deliver temporary files
**/fastlane/Preview.html
# snapshot generated screenshots
**/fastlane/screenshots
**/fastlane/metadata/android/en-US/images
# scan temporary files
**/fastlane/test_output
# potentially sensitive review information
**/fastlane/metadata/review_information
# secrets
.env
# ai
.claude/settings.local.json
# FVM Version Cache
.fvm/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "references/ttv-lol-pro"]
path = references/ttv-lol-pro
url = https://github.com/younesaassila/ttv-lol-pro

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.38.9"
}

View file

@ -58,3 +58,8 @@ android {
flutter {
source = "../.."
}
dependencies {
implementation("androidx.webkit:webkit:1.15.0")
testImplementation("junit:junit:4.13.2")
}

View file

@ -1,6 +1,12 @@
package com.tommychow.frosty
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
import com.tommychow.frosty.streamproxy.StreamProxyChannel
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: PipCallbackHelperActivityWrapper() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
StreamProxyChannel.register(flutterEngine)
}
}

View file

@ -0,0 +1,120 @@
package com.tommychow.frosty.streamproxy
import android.os.Handler
import android.os.Looper
import android.webkit.WebViewClient
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) {
val mainHandler = Handler(Looper.getMainLooper())
val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL_NAME,
)
val routers = ConcurrentHashMap<Long, StreamProxyRequestRouter>()
val clients = ConcurrentHashMap<Long, StreamProxyWebViewClient>()
methodChannel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"attach" -> {
val webViewIdentifier = webViewIdentifier(call)
?: return@setMethodCallHandler result.error(
"missing_webview_identifier",
"Missing webViewIdentifier",
null,
)
val webView = WebViewFlutterAndroidExternalApi.getWebView(
flutterEngine,
webViewIdentifier,
) ?: return@setMethodCallHandler result.error(
"webview_not_found",
"No WebView found for identifier $webViewIdentifier",
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
}
webView.webViewClient = client
client.attachToWebView(webView)
result.success(null)
}
"updateConfig" -> {
val config = config(call)
val webViewIdentifier = webViewIdentifier(call)
if (webViewIdentifier != null) {
clients[webViewIdentifier]?.updateConfig(config)
} else {
clients.values.forEach { it.updateConfig(config) }
}
result.success(null)
}
"detach" -> {
val webViewIdentifier = webViewIdentifier(call)
?: return@setMethodCallHandler result.success(null)
clients.remove(webViewIdentifier)
?.detachFromWebView()
routers.remove(webViewIdentifier)
WebViewFlutterAndroidExternalApi.getWebView(
flutterEngine,
webViewIdentifier,
)?.webViewClient = WebViewClient()
result.success(null)
}
else -> result.notImplemented()
}
} catch (error: Exception) {
result.error(
"stream_proxy_error",
error.message,
error.javaClass.simpleName,
)
}
}
}
private fun webViewIdentifier(call: MethodCall): Long? {
val arguments = call.arguments as? Map<*, *> ?: return null
return when (val value = arguments["webViewIdentifier"]) {
is Int -> value.toLong()
is Long -> value
is Number -> value.toLong()
else -> null
}
}
private fun config(call: MethodCall): StreamProxyConfig {
val arguments = call.arguments as? Map<*, *>
return StreamProxyConfig.fromMap(arguments?.get("config") as? Map<*, *>)
}
}

View file

@ -0,0 +1,114 @@
package com.tommychow.frosty.streamproxy
import java.net.URI
import java.net.PasswordAuthentication
import java.util.Locale
enum class StreamProxyMode {
OFF,
TTV_LOL_PRO,
}
enum class StreamProxyRequestType(val logName: String) {
PASSPORT("passport"),
USHER("usher"),
VIDEO_WEAVER("videoWeaver"),
GRAPHQL("graphQL"),
TWITCH_WEBPAGE("twitchWebpage"),
UNKNOWN("unknown"),
}
enum class StreamProxyAction(val logName: String) {
PROXY("proxy"),
SKIP("skip"),
DIRECT("direct"),
}
data class StreamProxyDecision(
val requestType: StreamProxyRequestType,
val action: StreamProxyAction,
val channel: String? = null,
val reason: String? = null,
val flagged: Boolean = false,
)
data class StreamProxyConfig(
val mode: StreamProxyMode,
val currentChannelLogin: String,
val proxyUrls: List<String>,
val whitelistedChannels: Set<String>,
val debugLogging: Boolean,
) {
val enabled: Boolean
get() = mode == StreamProxyMode.TTV_LOL_PRO
companion object {
fun fromMap(map: Map<*, *>?): StreamProxyConfig {
val mode = when (map?.get("mode") as? String) {
"ttvLolPro" -> StreamProxyMode.TTV_LOL_PRO
else -> StreamProxyMode.OFF
}
return StreamProxyConfig(
mode = mode,
currentChannelLogin = (map?.get("currentChannelLogin") as? String)
?.trim()
?.lowercase(Locale.US)
.orEmpty(),
proxyUrls = stringList(map, "proxyUrls"),
whitelistedChannels = stringList(map, "whitelistedChannels")
.map { it.lowercase(Locale.US) }
.toSet(),
debugLogging = (map?.get("debugLogging") as? Boolean) ?: false,
)
}
private fun stringList(map: Map<*, *>?, key: String): List<String> {
return (map?.get(key) as? List<*>)
?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) }
?: emptyList()
}
}
}
data class ParsedProxy(
val scheme: String,
val host: String,
val port: Int,
val username: String?,
val password: String?,
) {
val displayHost: String
get() = "$host:$port"
val hasCredentials: Boolean
get() = username != null
val passwordAuthentication: PasswordAuthentication?
get() {
val user = username ?: return null
return PasswordAuthentication(user, (password ?: "").toCharArray())
}
}
fun parseProxyUrl(rawUrl: String): ParsedProxy? {
val trimmed = rawUrl.trim()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.contains("://")) trimmed else "http://$trimmed"
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase(Locale.US) ?: return null
if (scheme != "http" && scheme != "https") return null
val host = uri.host?.takeIf(String::isNotEmpty) ?: return null
val port = uri.port.takeIf { it in 1..65535 } ?: return null
val credentials = uri.userInfo?.split(":", limit = 2)
return ParsedProxy(
scheme = scheme,
host = host,
port = port,
username = credentials?.getOrNull(0),
password = credentials?.getOrNull(1),
)
}

View file

@ -0,0 +1,712 @@
package com.tommychow.frosty.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
import java.io.InputStream
import java.net.Authenticator
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.PasswordAuthentication
import java.net.Proxy
import java.net.Socket
import java.net.URL
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.Locale
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
class StreamProxyFetcher(
private val log: (String) -> Unit,
) {
private val acceptFlag = "TTV-LOL-PRO"
private val hopByHopHeaders = setOf(
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"accept-encoding",
)
fun fetch(
request: WebResourceRequest,
decision: StreamProxyDecision,
config: StreamProxyConfig,
router: StreamProxyRequestRouter,
): WebResourceResponse? {
if (config.proxyUrls.isEmpty()) {
return if (decision.flagged) {
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) {
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=skip " +
"proxy=${index + 1} reason=invalid_proxy",
)
return@forEachIndexed
}
var connection: HttpURLConnection? = null
try {
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=proxy " +
"proxy=${index + 1} host=${parsedProxy.displayHost} " +
"auth=${parsedProxy.hasCredentials}",
)
if (URL(requestUrl).protocol.equals("https", ignoreCase = true)) {
return fetchHttpsViaConnect(
request = request,
decision = decision,
proxy = parsedProxy,
router = router,
)
}
return ProxyAuthenticator.withCredentials(parsedProxy) {
val proxy = Proxy(
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)
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,
)
}
} catch (error: Exception) {
connection?.disconnect()
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=proxy_failed " +
"proxy=${index + 1} host=${parsedProxy.displayHost} " +
"reason=${formatError(error)}",
)
}
}
return if (decision.flagged) {
fetchDirect(request, decision, router, "proxy_failed")
} else {
null
}
}
private fun fetchDirect(
request: WebResourceRequest,
decision: StreamProxyDecision,
router: StreamProxyRequestRouter,
reason: String,
): WebResourceResponse? {
var connection: HttpURLConnection? = null
val requestUrl = request.url.toString()
return try {
log(
"matched type=${decision.requestType.logName} " +
"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)
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,
)
}
} catch (error: Exception) {
connection?.disconnect()
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=direct_failed " +
"reason=${formatError(error)}",
)
null
}
}
private fun fetchHttpsViaConnect(
request: WebResourceRequest,
decision: StreamProxyDecision,
proxy: ParsedProxy,
router: StreamProxyRequestRouter,
): WebResourceResponse {
val requestUrl = request.url.toString()
val url = URL(requestUrl)
val port = if (url.port != -1) url.port else 443
val proxySocket = Socket()
proxySocket.connect(
InetSocketAddress(proxy.host, proxy.port),
CONNECT_TIMEOUT_MS,
)
proxySocket.soTimeout = READ_TIMEOUT_MS
try {
val proxyInput = proxySocket.getInputStream().buffered()
val proxyOutput = proxySocket.getOutputStream()
writeConnectRequest(proxyOutput, proxy, url.host, port)
val connectStatus = readStatusLine(proxyInput, "proxy CONNECT")
val connectHeaders = readHeaders(proxyInput)
if (connectStatus.statusCode !in 200..299) {
throw java.io.IOException(
"proxy CONNECT ${connectStatus.statusCode} " +
"${connectStatus.reason} headers=${connectHeaders.keys.joinToString(",")}",
)
}
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
val sslSocket = sslSocketFactory.createSocket(
proxySocket,
url.host,
port,
true,
) as SSLSocket
sslSocket.soTimeout = READ_TIMEOUT_MS
sslSocket.startHandshake()
val responseInput = sslSocket.getInputStream().buffered()
val responseOutput = sslSocket.getOutputStream()
writeOriginRequest(
output = responseOutput,
request = request,
url = url,
)
val status = readStatusLine(responseInput, "origin response")
val headers = readHeaders(responseInput)
val bodyStream = responseBodyStream(
input = responseInput,
headers = headers,
closeSocket = sslSocket,
isHead = request.method.equals("HEAD", ignoreCase = true),
)
val contentType = headers.entries
.firstOrNull { it.key.equals("content-type", ignoreCase = true) }
?.value
val mimeType = mimeTypeFrom(contentType, requestUrl)
val encoding = encodingFrom(contentType)
val responseHeaders = headers
.filterKeys { !it.equals("transfer-encoding", ignoreCase = true) }
if (
decision.requestType == StreamProxyRequestType.USHER &&
status.statusCode in 200..399 &&
!request.method.equals("HEAD", ignoreCase = true)
) {
val bytes = bodyStream.readBytes()
val body = decodeBody(bytes, encoding)
router.rememberUsherManifest(
channel = decision.channel,
manifestUrl = requestUrl,
manifestBody = body,
)
sslSocket.close()
return WebResourceResponse(
mimeType,
encoding,
status.statusCode,
status.reason,
responseHeaders,
ByteArrayInputStream(bytes),
)
}
return WebResourceResponse(
mimeType,
encoding,
status.statusCode,
status.reason,
responseHeaders,
bodyStream,
)
} catch (error: Exception) {
proxySocket.close()
throw error
}
}
private fun copyRequestHeaders(
headers: Map<String, String>,
connection: HttpURLConnection,
) {
copyRequestHeaders(headers) { name, value ->
connection.setRequestProperty(name, value)
}
}
private fun copyRequestHeaders(
headers: Map<String, String>,
appendHeader: (String, String) -> Unit,
) {
headers.forEach { (name, value) ->
if (!hopByHopHeaders.contains(name.lowercase(Locale.US))) {
sanitizedHeaderValue(name, value)?.let { sanitizedValue ->
appendHeader(name, sanitizedValue)
}
}
}
}
private fun sanitizedHeaderValue(name: String, value: String): String? {
if (!name.equals("accept", ignoreCase = true)) return value
val sanitized = value
.replace(acceptFlag, "", ignoreCase = true)
.trim()
return sanitized.takeIf(String::isNotEmpty)
}
private fun setProxyAuthorization(
proxy: ParsedProxy,
connection: HttpURLConnection,
) {
connection.setRequestProperty(
"Proxy-Authorization",
proxyAuthorizationHeader(proxy) ?: return,
)
}
private fun proxyAuthorizationHeader(proxy: ParsedProxy): String? {
val username = proxy.username ?: return null
val password = proxy.password ?: ""
val credentials = "$username:$password"
val encodedCredentials = Base64.encodeToString(
credentials.toByteArray(Charsets.UTF_8),
Base64.NO_WRAP,
)
return "Basic $encodedCredentials"
}
private fun writeConnectRequest(
output: java.io.OutputStream,
proxy: ParsedProxy,
host: String,
port: Int,
) {
val request = buildString {
append("CONNECT ")
append(host)
append(":")
append(port)
append(" HTTP/1.1\r\n")
append("Host: ")
append(host)
append(":")
append(port)
append("\r\n")
append("Connection: keep-alive\r\n")
proxyAuthorizationHeader(proxy)?.let { authorization ->
append("Proxy-Authorization: ")
append(authorization)
append("\r\n")
}
append("\r\n")
}
output.write(request.toByteArray(StandardCharsets.ISO_8859_1))
output.flush()
}
private fun writeOriginRequest(
output: java.io.OutputStream,
request: WebResourceRequest,
url: URL,
) {
val path = url.file.takeIf(String::isNotEmpty) ?: "/"
val requestText = buildString {
append(request.method.uppercase(Locale.US))
append(" ")
append(path)
append(" HTTP/1.1\r\n")
append("Host: ")
append(url.host)
if (url.port != -1 && url.port != 443) {
append(":")
append(url.port)
}
append("\r\n")
copyRequestHeaders(request.requestHeaders ?: emptyMap()) { name, value ->
append(name)
append(": ")
append(value)
append("\r\n")
}
append("Connection: close\r\n")
append("\r\n")
}
output.write(requestText.toByteArray(StandardCharsets.ISO_8859_1))
output.flush()
}
private fun responseHeaders(connection: HttpURLConnection): Map<String, String> {
return connection.headerFields
.mapNotNull { (name, values) ->
val value = values?.firstOrNull()
if (name != null && value != null) {
name to value
} else {
null
}
}
.toMap()
}
private fun readStatusLine(
input: java.io.BufferedInputStream,
context: String,
): HttpStatus {
val statusLine = readAsciiLine(input)
?: throw java.io.IOException("$context missing status line")
val parts = statusLine.split(" ", limit = 3)
val statusCode = parts.getOrNull(1)?.toIntOrNull()
?: throw java.io.IOException("$context invalid status line '$statusLine'")
return HttpStatus(
statusCode = statusCode,
reason = parts.getOrNull(2)?.takeIf(String::isNotEmpty) ?: "OK",
)
}
private fun readHeaders(input: java.io.BufferedInputStream): Map<String, String> {
val headers = linkedMapOf<String, String>()
while (true) {
val line = readAsciiLine(input) ?: break
if (line.isEmpty()) break
val separatorIndex = line.indexOf(":")
if (separatorIndex <= 0) continue
val name = line.substring(0, separatorIndex).trim()
val value = line.substring(separatorIndex + 1).trim()
headers[name] = value
}
return headers
}
private fun readAsciiLine(input: java.io.BufferedInputStream): String? {
val buffer = ByteArrayOutputStream()
while (true) {
val next = input.read()
if (next == -1) {
if (buffer.size() == 0) return null
break
}
if (next == '\n'.code) break
if (next != '\r'.code) {
buffer.write(next)
}
}
return buffer.toString(StandardCharsets.ISO_8859_1.name())
}
private fun responseBodyStream(
input: java.io.BufferedInputStream,
headers: Map<String, String>,
closeSocket: Socket,
isHead: Boolean,
): InputStream {
if (isHead) {
closeSocket.close()
return ByteArrayInputStream(ByteArray(0))
}
val transferEncoding = headers.entries
.firstOrNull { it.key.equals("transfer-encoding", ignoreCase = true) }
?.value
val bodyStream = if (transferEncoding?.contains("chunked", ignoreCase = true) == true) {
ChunkedInputStream(input)
} else {
input
}
return ClosingInputStream(bodyStream, closeSocket)
}
private fun mimeTypeFrom(contentType: String?, url: String): String? {
val mimeType = contentType
?.substringBefore(";")
?.trim()
?.takeIf(String::isNotEmpty)
if (mimeType != null) return mimeType
return when {
url.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl"
url.endsWith(".ts", ignoreCase = true) -> "video/mp2t"
url.endsWith(".mp4", ignoreCase = true) -> "video/mp4"
else -> null
}
}
private fun encodingFrom(contentType: String?): String? {
return contentType
?.split(";")
?.map(String::trim)
?.firstOrNull { it.startsWith("charset=", ignoreCase = true) }
?.substringAfter("=")
?.takeIf(String::isNotEmpty)
}
private fun decodeBody(bytes: ByteArray, encoding: String?): String {
return try {
bytes.toString(Charset.forName(encoding ?: "UTF-8"))
} catch (_: Exception) {
bytes.toString(Charsets.UTF_8)
}
}
companion object {
private const val CONNECT_TIMEOUT_MS = 8000
private const val READ_TIMEOUT_MS = 15000
private fun formatError(error: Exception): String {
val message = error.message
?.replace(Regex("\\s+"), " ")
?.take(180)
?.let { ":$it" }
?: ""
return "${error.javaClass.simpleName}$message"
}
}
private data class HttpStatus(
val statusCode: Int,
val reason: String,
)
}
private class ClosingInputStream(
delegate: InputStream,
private val socket: Socket,
) : FilterInputStream(delegate) {
override fun close() {
try {
super.close()
} finally {
socket.close()
}
}
}
private class ChunkedInputStream(
private val input: java.io.BufferedInputStream,
) : InputStream() {
private var remainingInChunk = 0
private var done = false
override fun read(): Int {
if (!prepareChunk()) return -1
val value = input.read()
if (value == -1) {
done = true
return -1
}
remainingInChunk -= 1
if (remainingInChunk == 0) {
readAsciiLine(input)
}
return value
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (!prepareChunk()) return -1
val count = input.read(buffer, offset, minOf(length, remainingInChunk))
if (count == -1) {
done = true
return -1
}
remainingInChunk -= count
if (remainingInChunk == 0) {
readAsciiLine(input)
}
return count
}
private fun prepareChunk(): Boolean {
if (done) return false
if (remainingInChunk > 0) return true
val chunkHeader = readAsciiLine(input) ?: run {
done = true
return false
}
val chunkSize = chunkHeader
.substringBefore(";")
.trim()
.toIntOrNull(16)
?: throw java.io.IOException("invalid chunk size '$chunkHeader'")
if (chunkSize == 0) {
while (true) {
val trailerLine = readAsciiLine(input) ?: break
if (trailerLine.isEmpty()) break
}
done = true
return false
}
remainingInChunk = chunkSize
return true
}
private fun readAsciiLine(input: java.io.BufferedInputStream): String? {
val buffer = ByteArrayOutputStream()
while (true) {
val next = input.read()
if (next == -1) {
if (buffer.size() == 0) return null
break
}
if (next == '\n'.code) break
if (next != '\r'.code) {
buffer.write(next)
}
}
return buffer.toString(StandardCharsets.ISO_8859_1.name())
}
}
private object ProxyAuthenticator : Authenticator() {
private val credentials = ThreadLocal<PasswordAuthentication?>()
init {
System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "")
Authenticator.setDefault(this)
}
fun <T> withCredentials(proxy: ParsedProxy, block: () -> T): T {
credentials.set(proxy.passwordAuthentication)
try {
return block()
} finally {
credentials.remove()
}
}
override fun getPasswordAuthentication(): PasswordAuthentication? {
if (requestorType != RequestorType.PROXY) return null
return credentials.get()
}
}

View file

@ -0,0 +1,651 @@
package com.tommychow.frosty.streamproxy
import org.json.JSONArray
import org.json.JSONObject
object StreamProxyPageScript {
fun create(config: StreamProxyConfig): String {
val workerInstaller = INSTALLER.replace("__INSTALLER_SOURCE__", "null")
val pageInstaller = INSTALLER.replace(
"__INSTALLER_SOURCE__",
JSONObject.quote(workerInstaller),
)
return "($pageInstaller)(${config.toPageJson()});"
}
fun updateConfigScript(config: StreamProxyConfig): String {
return "globalThis.__frostyStreamProxy?.updateConfig(${config.toPageJson()});"
}
private fun StreamProxyConfig.toPageJson(): String {
return JSONObject()
.put("enabled", enabled)
.put("currentChannelLogin", currentChannelLogin)
.put("hasProxyUrls", proxyUrls.isNotEmpty())
.put("whitelistedChannels", JSONArray(whitelistedChannels.toList()))
.put("debugLogging", debugLogging)
.toString()
}
private val INSTALLER = """
function(initialConfig) {
"use strict";
const ACCEPT_FLAG = "TTV-LOL-PRO";
const GRAPHQL_URL = "https://gql.twitch.tv/gql";
const TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
const scope = typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope
? "worker"
: "page";
if (globalThis.__frostyStreamProxy) {
globalThis.__frostyStreamProxy.updateConfig(initialConfig);
return;
}
const nativeFetch = globalThis.fetch.bind(globalThis);
const nativeWorker = scope === "page" ? globalThis.Worker : null;
const state = {
config: normalizeConfig(initialConfig),
cachedUsherUrl: null,
cachedPlaybackTokenRequestBody: null,
cachedPlaybackTokenRequestHeaders: null,
manifests: [],
proxiedVideoWeaverUrls: new Map(),
videoWeaverUrlsToNotProxy: new Set(),
locks: new Map(),
workerBlobUrls: [],
};
globalThis.__frostyStreamProxy = {
updateConfig(config) {
state.config = normalizeConfig(config);
log("config updated");
},
};
globalThis.fetch = streamProxyFetch;
if (scope === "page" && nativeWorker) {
installWorkerWrapper();
}
log("fetch hook installed in " + scope);
async function streamProxyFetch(input, init) {
const originalRequest = toRequest(input, init);
const url = normalizeUrl(originalRequest.url);
const host = hostFromUrl(url);
let request = originalRequest;
let requestType = null;
let flagged = false;
let response;
try {
if (!state.config.enabled) {
return nativeFetch(request);
}
if (host === "gql.twitch.tv") {
requestType = "graphQL";
request = await handleGraphQlRequest(request);
} else if (host === "usher.ttvnw.net") {
requestType = "usher";
const usherRequest = handleUsherRequest(request, url);
request = usherRequest.request;
flagged = usherRequest.flagged;
} else if (isVideoWeaverHost(host)) {
requestType = "videoWeaver";
const weaverRequest = handleVideoWeaverRequest(request, url);
request = weaverRequest.request;
flagged = weaverRequest.flagged;
}
if (flagged && requestType) {
response = await withTypeLock(requestType, () => nativeFetch(request));
} else {
if (requestType) {
await waitForTypeLock(requestType);
}
response = await nativeFetch(request);
}
if (host === "usher.ttvnw.net" && response.status < 400) {
await handleUsherResponse(response, url);
} else if (isVideoWeaverHost(host) && response.status < 400) {
await handleVideoWeaverResponse(response, url, flagged);
}
return response;
} catch (error) {
log("fetch hook error type=" + (requestType || "unknown") + " reason=" + formatError(error));
throw error;
}
}
async function handleGraphQlRequest(request) {
const body = await request.clone().text().catch(() => null);
if (!body || body.indexOf("PlaybackAccessToken") === -1) {
return request;
}
state.cachedPlaybackTokenRequestBody = body;
state.cachedPlaybackTokenRequestHeaders = headersToObject(request.headers);
let parsed;
try {
parsed = JSON.parse(body);
} catch (error) {
log("graphQL parse failed reason=" + formatError(error));
return request;
}
const queries = Array.isArray(parsed) ? parsed : [parsed];
const eligible = queries.some((query) => {
const login = query && query.variables && query.variables.login;
if (!login || isVodLogin(login)) return false;
if (query.variables.playerType === "frontpage") return false;
return !isWhitelisted(login);
});
if (!eligible) {
log("graphQL playback token skipped reason=ineligible");
return request;
}
const headers = new Headers(request.headers);
setHeaderIfExists(headers, "Authorization", "undefined");
setHeaderIfExists(headers, "Client-Session-Id", randomString(16, "abcdefghijklmnopqrstuvwxyz0123456789"));
setHeaderIfExists(headers, "Device-ID", randomString(32));
setHeaderIfExists(headers, "X-Device-Id", randomString(32));
log("graphQL playback token anonymized");
return new Request(request, { headers });
}
function handleUsherRequest(request, url) {
state.cachedUsherUrl = url;
const channel = channelFromUsherUrl(url);
if (url.toLowerCase().indexOf("/vod/") !== -1) {
log("usher skipped channel=" + (channel || "") + " reason=vod");
return { request, flagged: false };
}
if (url.indexOf(encodeURIComponent('"player_type":"frontpage"')) !== -1) {
log("usher skipped channel=" + (channel || "") + " reason=frontpage");
return { request, flagged: false };
}
if (isWhitelisted(channel)) {
log("usher skipped channel=" + (channel || "") + " reason=whitelisted");
return { request, flagged: false };
}
let nextRequest = request;
const anonymizedUrl = anonymizeUsherUrl(url);
if (anonymizedUrl !== url) {
nextRequest = new Request(anonymizedUrl, request);
}
if (!state.config.hasProxyUrls) {
return { request: nextRequest, flagged: false };
}
log("usher flagged channel=" + (channel || ""));
return { request: flagRequest(nextRequest), flagged: true };
}
function handleVideoWeaverRequest(request, url) {
if (state.videoWeaverUrlsToNotProxy.has(url)) {
log("videoWeaver skipped reason=marked_direct");
return { request, flagged: false };
}
const manifest = findManifestForUrl(url);
let videoWeaverUrl = url;
if (manifest && manifest.replacementMap) {
const quality = qualityForUrl(manifest.assignedMap, url);
videoWeaverUrl = quality && manifest.replacementMap.has(quality)
? manifest.replacementMap.get(quality)
: firstMapValue(manifest.replacementMap);
if (videoWeaverUrl && videoWeaverUrl !== url) {
request = new Request(videoWeaverUrl, request);
log("videoWeaver replaced quality=" + (quality || "fallback"));
}
}
if (!state.config.hasProxyUrls || !isVideoWeaverPlaylistUrl(videoWeaverUrl)) {
return { request, flagged: false };
}
const count = state.proxiedVideoWeaverUrls.get(videoWeaverUrl) || 0;
if (count >= 1) {
return { request, flagged: false };
}
state.proxiedVideoWeaverUrls.set(videoWeaverUrl, count + 1);
log("videoWeaver flagged count=" + (count + 1));
return { request: flagRequest(request), flagged: true };
}
async function handleUsherResponse(response, url) {
if (url.toLowerCase().indexOf("/vod/") !== -1) return;
const body = await response.clone().text().catch(() => null);
if (!body) return;
const channel = channelFromUsherUrl(url);
const assignedMap = parseUsherManifest(body, url);
if (!assignedMap || assignedMap.size === 0) {
log("usher response parse failed channel=" + (channel || ""));
return;
}
state.manifests = state.manifests.filter((manifest) => !manifest.deleted);
const manifest = {
channelName: channel,
assignedMap,
replacementMap: null,
consecutiveAdResponses: 0,
consecutiveAdCooldown: 0,
deleted: false,
};
state.manifests.push(manifest);
for (const videoWeaverUrl of assignedMap.values()) {
state.proxiedVideoWeaverUrls.delete(videoWeaverUrl);
state.videoWeaverUrlsToNotProxy.delete(videoWeaverUrl);
if (url.indexOf(encodeURIComponent('"player_type":"frontpage"')) !== -1 || isWhitelisted(channel)) {
state.videoWeaverUrlsToNotProxy.add(videoWeaverUrl);
}
}
log("usher response tracked channel=" + (channel || "") + " variants=" + assignedMap.size);
}
async function handleVideoWeaverResponse(response, url, flagged) {
const manifest = findManifestForUrl(url);
if (!manifest) return;
const body = await response.clone().text().catch(() => null);
if (!body) return;
const includesAd = body.toLowerCase().indexOf("stitched-ad") !== -1;
if (!includesAd) {
if (manifest.consecutiveAdCooldown > 0) {
manifest.consecutiveAdCooldown -= 1;
} else {
manifest.consecutiveAdResponses = 0;
}
return;
}
log("videoWeaver ad_detected channel=" + (manifest.channelName || ""));
if (state.videoWeaverUrlsToNotProxy.has(url)) return;
manifest.consecutiveAdResponses += 1;
manifest.consecutiveAdCooldown = 15;
if (manifest.consecutiveAdResponses <= 1) {
const replacementUrls = await updateVideoWeaverReplacementMap(manifest, flagged ? false : undefined);
if (flagged) {
replacementUrls.forEach((replacementUrl) => state.videoWeaverUrlsToNotProxy.add(replacementUrl));
}
log("videoWeaver replacement_ready count=" + replacementUrls.length);
throw new Error("Frosty stream proxy cancelled ad playlist request");
}
if (manifest.consecutiveAdResponses === 2 && manifest.replacementMap && !flagged) {
manifest.replacementMap = null;
throw new Error("Frosty stream proxy reset replacement map");
}
}
async function updateVideoWeaverReplacementMap(manifest, flaggedOverride) {
const token = await fetchReplacementPlaybackAccessToken(flaggedOverride);
if (!token || !token.value || !token.signature) {
throw new Error("replacement token unavailable");
}
const usherManifest = await fetchReplacementUsherManifest(token, flaggedOverride);
const replacementMap = parseUsherManifest(usherManifest || "", state.cachedUsherUrl || "");
if (!replacementMap || replacementMap.size === 0) {
throw new Error("replacement usher parse failed");
}
manifest.replacementMap = replacementMap;
return Array.from(replacementMap.values());
}
async function fetchReplacementPlaybackAccessToken(flaggedOverride) {
const channel = state.config.currentChannelLogin;
if (!channel) return null;
const headers = new Headers();
headers.set("Authorization", "undefined");
headers.set("Client-ID", TWITCH_CLIENT_ID);
headers.set("Content-Type", "text/plain; charset=UTF-8");
headers.set("Device-ID", randomString(32));
const queryText = "query PlaybackAccessToken_Template { streamPlaybackAccessToken(channelName: " +
JSON.stringify(channel) +
", params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: \"site\"}) { value signature authorization { isForbidden forbiddenReasonCode } __typename }}";
const query = {
operationName: "PlaybackAccessToken_Template",
query: queryText,
variables: {},
};
let request = new Request(GRAPHQL_URL, {
method: "POST",
headers,
body: JSON.stringify(query),
});
if (flaggedOverride === true && state.config.hasProxyUrls) {
request = flagRequest(request);
}
const response = await nativeFetch(request);
if (response.status >= 400) return null;
const json = await response.json();
return json && json.data && json.data.streamPlaybackAccessToken;
}
async function fetchReplacementUsherManifest(token, flaggedOverride) {
const replacementUrl = getReplacementUsherUrl(token);
if (!replacementUrl) return null;
let request = new Request(replacementUrl);
const shouldFlag = flaggedOverride === undefined ? state.config.hasProxyUrls : flaggedOverride;
if (shouldFlag && state.config.hasProxyUrls) {
request = flagRequest(request);
}
const response = await nativeFetch(request);
if (response.status >= 400) return null;
return response.text();
}
function getReplacementUsherUrl(token) {
if (!state.cachedUsherUrl) return null;
try {
const url = new URL(state.cachedUsherUrl);
url.searchParams.set("sig", token.signature);
url.searchParams.set("token", token.value);
return anonymizeUsherUrl(url.toString());
} catch (error) {
return null;
}
}
function parseUsherManifest(body, manifestUrl) {
const lines = body.split(/\r?\n/).map((line) => line.trim());
const variants = [];
let pendingAttributes = null;
for (const line of lines) {
if (!line) continue;
if (line.toUpperCase().indexOf("#EXT-X-STREAM-INF:") === 0) {
pendingAttributes = parseAttributes(line.substring(line.indexOf(":") + 1));
continue;
}
if (line.charAt(0) === "#") continue;
if (!pendingAttributes) continue;
variants.push({
attributes: pendingAttributes,
uri: absolutize(line, manifestUrl),
});
pendingAttributes = null;
}
if (variants.length === 0) return null;
variants.sort((a, b) => {
const scoreA = Number(a.attributes.SCORE || 0);
const scoreB = Number(b.attributes.SCORE || 0);
if (scoreA && scoreB) return scoreB - scoreA;
return b.uri.length - a.uri.length;
});
const keys = new Map();
const map = new Map();
variants.forEach((variant, index) => {
const resolution = variant.attributes.RESOLUTION || "";
const height = resolution.indexOf("x") !== -1 ? resolution.split("x")[1] : "";
const frameRate = variant.attributes["FRAME-RATE"] ? Math.round(Number(variant.attributes["FRAME-RATE"])) : "";
const baseKey = height ? height + "p" + frameRate : "idx_" + index;
const count = keys.get(baseKey) || 0;
keys.set(baseKey, count + 1);
map.set(count === 0 ? baseKey : baseKey + "_" + count, variant.uri);
});
return map;
}
function parseAttributes(raw) {
const attributes = {};
let key = "";
let value = "";
let readingKey = true;
let quoted = false;
function commit() {
if (key) attributes[key.trim()] = value.trim().replace(/^"|"$/g, "");
key = "";
value = "";
readingKey = true;
}
for (let i = 0; i < raw.length; i += 1) {
const char = raw.charAt(i);
if (readingKey && char === "=") {
readingKey = false;
continue;
}
if (!readingKey && char === '"') {
quoted = !quoted;
}
if (!readingKey && char === "," && !quoted) {
commit();
continue;
}
if (readingKey) key += char;
else value += char;
}
commit();
return attributes;
}
function installWorkerWrapper() {
globalThis.Worker = class FrostyStreamProxyWorker extends nativeWorker {
constructor(scriptUrl, options) {
const fullUrl = absolutize(String(scriptUrl), String(location.href));
if (fullUrl.indexOf(".twitch.tv") === -1) {
super(scriptUrl, options);
return;
}
let twitchWorkerScript = "";
try {
const request = new XMLHttpRequest();
request.open("GET", fullUrl, false);
request.send();
if (request.status >= 200 && request.status < 300) {
twitchWorkerScript = request.responseText;
} else {
twitchWorkerScript = "importScripts(" + JSON.stringify(fullUrl) + ");";
}
} catch (error) {
twitchWorkerScript = "importScripts(" + JSON.stringify(fullUrl) + ");";
}
const workerInstallerSource = __INSTALLER_SOURCE__;
if (!workerInstallerSource) {
super(scriptUrl, options);
return;
}
const workerScript = "(" + workerInstallerSource + ")(" + JSON.stringify(state.config) + ");\n" + twitchWorkerScript;
const blobUrl = URL.createObjectURL(new Blob([workerScript], { type: "text/javascript" }));
state.workerBlobUrls.push(blobUrl);
super(blobUrl, options);
}
};
log("Worker hook installed");
}
function toRequest(input, init) {
return input instanceof Request ? new Request(input, init) : new Request(input, init);
}
function normalizeUrl(url) {
return absolutize(url, String(location.href));
}
function absolutize(url, base) {
try {
return new URL(url, base).toString();
} catch (error) {
return url;
}
}
function hostFromUrl(url) {
try {
return new URL(url).host.toLowerCase();
} catch (error) {
return "";
}
}
function channelFromUsherUrl(url) {
const match = /\/hls\/([^/?#]+)\.m3u8/i.exec(url);
return match ? decodeURIComponent(match[1]).toLowerCase() : null;
}
function isVideoWeaverHost(host) {
return /^(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)$/i.test(host);
}
function isVideoWeaverPlaylistUrl(url) {
return /^https?:\/\/(?:[a-z0-9-]+\.playlist\.(?:live-video|ttvnw)\.net|video-weaver\.[a-z0-9-]+\.hls\.ttvnw\.net)\/v1\/playlist\/.+\.m3u8(?:$|[?#])/i.test(url);
}
function isVodLogin(login) {
return /^\d+$/.test(String(login));
}
function isWhitelisted(channel) {
if (!channel) return false;
return state.config.whitelistedChannels.indexOf(String(channel).toLowerCase()) !== -1;
}
function flagRequest(request) {
const headers = new Headers(request.headers);
const accept = headers.get("Accept") || "";
if (accept.indexOf(ACCEPT_FLAG) === -1) {
headers.set("Accept", accept + ACCEPT_FLAG);
}
return new Request(request, { headers });
}
function anonymizeUsherUrl(rawUrl) {
try {
const url = new URL(rawUrl);
url.searchParams.set("p", String(Math.floor(Math.random() * 10000000)));
url.searchParams.set("play_session_id", randomString(32, "abcdefghijklmnopqrstuvwxyz0123456789"));
return url.toString();
} catch (error) {
return rawUrl;
}
}
function findManifestForUrl(url) {
for (const manifest of state.manifests) {
for (const assignedUrl of manifest.assignedMap.values()) {
if (assignedUrl === url) return manifest;
}
}
return null;
}
function qualityForUrl(map, url) {
for (const entry of map.entries()) {
if (entry[1] === url) return entry[0];
}
return null;
}
function firstMapValue(map) {
const next = map.values().next();
return next.done ? null : next.value;
}
function setHeaderIfExists(headers, name, value) {
if (headers.has(name)) headers.set(name, value);
}
function headersToObject(headers) {
const object = {};
headers.forEach((value, key) => {
object[key] = value;
});
return object;
}
function normalizeConfig(config) {
const whitelistedChannels = Array.isArray(config && config.whitelistedChannels)
? config.whitelistedChannels.map((channel) => String(channel).toLowerCase())
: [];
return {
enabled: !!(config && config.enabled),
currentChannelLogin: String((config && config.currentChannelLogin) || "").toLowerCase(),
hasProxyUrls: !!(config && config.hasProxyUrls),
whitelistedChannels,
debugLogging: !!(config && config.debugLogging),
};
}
function randomString(length, alphabet) {
const chars = alphabet || "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const cryptoObject = globalThis.crypto;
if (cryptoObject && cryptoObject.getRandomValues) {
const bytes = new Uint8Array(length);
cryptoObject.getRandomValues(bytes);
for (let i = 0; i < length; i += 1) {
result += chars.charAt(bytes[i] % chars.length);
}
return result;
}
for (let i = 0; i < length; i += 1) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
async function withTypeLock(type, task) {
const previous = state.locks.get(type) || Promise.resolve();
let release;
const next = new Promise((resolve) => {
release = resolve;
});
state.locks.set(type, previous.catch(() => undefined).then(() => next));
await previous.catch(() => undefined);
try {
return await task();
} finally {
release();
}
}
async function waitForTypeLock(type) {
const lock = state.locks.get(type);
if (lock) await lock.catch(() => undefined);
}
function formatError(error) {
if (!error) return "unknown";
return error.message || String(error);
}
function log(message) {
if (state.config.debugLogging) {
console.log("[FrostyStreamProxy] " + message);
}
}
}
"""
}

View file

@ -0,0 +1,270 @@
package com.tommychow.frosty.streamproxy
import java.net.URI
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
class StreamProxyRequestRouter {
private val videoWeaverChannels = ConcurrentHashMap<String, String>()
private val proxiedVideoWeaverUrls = ConcurrentHashMap.newKeySet<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(?:$|[?#])",
RegexOption.IGNORE_CASE,
)
fun route(
url: String,
method: String,
headers: Map<String, String>,
config: StreamProxyConfig,
): StreamProxyDecision {
if (!config.enabled) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.UNKNOWN,
action = StreamProxyAction.DIRECT,
reason = "mode_off",
)
}
val uri = runCatching { URI(url) }.getOrNull()
?: return StreamProxyDecision(
requestType = StreamProxyRequestType.UNKNOWN,
action = StreamProxyAction.SKIP,
reason = "invalid_url",
)
val host = uri.host?.lowercase(Locale.US).orEmpty()
val requestType = requestTypeForHost(host)
val normalizedMethod = method.uppercase(Locale.US)
val flagged = hasTtvLolProAcceptFlag(headers)
if (requestType == StreamProxyRequestType.UNKNOWN && !flagged) {
return StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "unknown",
)
}
if (requestType == StreamProxyRequestType.GRAPHQL) {
return StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "post_body_unavailable",
flagged = flagged,
)
}
if (normalizedMethod != "GET" && normalizedMethod != "HEAD") {
return StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "method_not_supported",
flagged = flagged,
)
}
if (config.proxyUrls.isEmpty()) {
return StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "no_proxy_urls",
flagged = flagged,
)
}
return when (requestType) {
StreamProxyRequestType.USHER -> routeUsher(url, config, flagged)
StreamProxyRequestType.VIDEO_WEAVER -> routeVideoWeaver(url, config, flagged)
StreamProxyRequestType.PASSPORT,
StreamProxyRequestType.TWITCH_WEBPAGE -> StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "optimized_unproxied",
flagged = flagged,
)
StreamProxyRequestType.UNKNOWN -> StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "unknown",
flagged = flagged,
)
StreamProxyRequestType.GRAPHQL -> StreamProxyDecision(
requestType = requestType,
action = StreamProxyAction.SKIP,
reason = "post_body_unavailable",
flagged = flagged,
)
}
}
fun rememberUsherManifest(channel: String?, manifestUrl: String, manifestBody: String) {
val normalizedChannel = channel?.lowercase(Locale.US) ?: return
val baseUri = runCatching { URI(manifestUrl) }.getOrNull() ?: return
manifestBody
.lineSequence()
.map(String::trim)
.filter { it.isNotEmpty() && !it.startsWith("#") }
.mapNotNull { line ->
runCatching { baseUri.resolve(line).toString() }.getOrNull()
}
.filter { videoWeaverUrlRegex.containsMatchIn(it) }
.forEach { videoWeaverUrl ->
videoWeaverChannels[videoWeaverUrl] = normalizedChannel
}
}
private fun routeUsher(
url: String,
config: StreamProxyConfig,
flagged: Boolean,
): StreamProxyDecision {
val channel = extractUsherChannel(url)
if (url.contains("/vod/", ignoreCase = true)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.USHER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "vod",
flagged = flagged,
)
}
if (isFrontpageUsherUrl(url)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.USHER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "frontpage",
flagged = flagged,
)
}
if (!flagged) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.USHER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "optimized_unflagged",
flagged = flagged,
)
}
if (channel != null && config.whitelistedChannels.contains(channel)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.USHER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "whitelisted",
flagged = flagged,
)
}
return StreamProxyDecision(
requestType = StreamProxyRequestType.USHER,
action = StreamProxyAction.PROXY,
channel = channel,
flagged = flagged,
)
}
private fun routeVideoWeaver(
url: String,
config: StreamProxyConfig,
flagged: Boolean,
): StreamProxyDecision {
val channel = videoWeaverChannels[url] ?: config.currentChannelLogin.takeIf(String::isNotEmpty)
if (!isVideoWeaverPlaylistUrl(url)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.VIDEO_WEAVER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "not_playlist",
flagged = flagged,
)
}
if (!flagged) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.VIDEO_WEAVER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "optimized_unflagged",
flagged = flagged,
)
}
if (channel != null && config.whitelistedChannels.contains(channel)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.VIDEO_WEAVER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "whitelisted",
flagged = flagged,
)
}
if (!proxiedVideoWeaverUrls.add(url)) {
return StreamProxyDecision(
requestType = StreamProxyRequestType.VIDEO_WEAVER,
action = StreamProxyAction.SKIP,
channel = channel,
reason = "optimized_already_proxied",
flagged = flagged,
)
}
return StreamProxyDecision(
requestType = StreamProxyRequestType.VIDEO_WEAVER,
action = StreamProxyAction.PROXY,
channel = channel,
flagged = flagged,
)
}
private fun requestTypeForHost(host: String): StreamProxyRequestType {
return when {
host == "passport.twitch.tv" -> StreamProxyRequestType.PASSPORT
host == "usher.ttvnw.net" -> StreamProxyRequestType.USHER
isVideoWeaverHost(host) -> StreamProxyRequestType.VIDEO_WEAVER
host == "gql.twitch.tv" -> StreamProxyRequestType.GRAPHQL
host == "www.twitch.tv" || host == "m.twitch.tv" -> StreamProxyRequestType.TWITCH_WEBPAGE
else -> StreamProxyRequestType.UNKNOWN
}
}
private fun isVideoWeaverHost(host: String): Boolean {
return Regex(
"^(?:[a-z0-9-]+\\.playlist\\.(?:live-video|ttvnw)\\.net|video-weaver\\.[a-z0-9-]+\\.hls\\.ttvnw\\.net)$",
RegexOption.IGNORE_CASE,
).matches(host)
}
private fun isVideoWeaverPlaylistUrl(url: String): Boolean {
return videoWeaverUrlRegex.containsMatchIn(url)
}
private fun extractUsherChannel(url: String): String? {
return usherChannelRegex
.find(url)
?.groupValues
?.getOrNull(1)
?.lowercase(Locale.US)
}
private fun isFrontpageUsherUrl(url: String): Boolean {
return url.contains("player_type=frontpage", ignoreCase = true) ||
url.contains("%22player_type%22:%22frontpage%22", ignoreCase = true) ||
url.contains("%22playerType%22:%22frontpage%22", ignoreCase = true)
}
private fun hasTtvLolProAcceptFlag(headers: Map<String, String>): Boolean {
return headers.any { (name, value) ->
name.equals("accept", ignoreCase = true) &&
value.contains("TTV-LOL-PRO", ignoreCase = true)
}
}
}

View file

@ -0,0 +1,167 @@
package com.tommychow.frosty.streamproxy
import android.graphics.Bitmap
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
class StreamProxyWebViewClient(
initialConfig: StreamProxyConfig,
private val router: StreamProxyRequestRouter,
private val onPageFinished: (String) -> Unit,
) : WebViewClient() {
@Volatile
private var config = initialConfig
private val fetcher = StreamProxyFetcher(::log)
private var webView: WebView? = null
private var documentStartScript: ScriptHandler? = null
private var documentStartSupported = false
fun attachToWebView(view: WebView) {
webView = view
installDocumentStartScript()
}
fun updateConfig(newConfig: StreamProxyConfig) {
config = newConfig
installDocumentStartScript()
webView?.post {
webView?.evaluateJavascript(
StreamProxyPageScript.updateConfigScript(newConfig),
null,
)
}
}
fun detachFromWebView() {
removeDocumentStartScript()
webView = null
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (!documentStartSupported && config.enabled) {
view?.evaluateJavascript(StreamProxyPageScript.create(config), null)
}
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (url != null) {
onPageFinished(url)
}
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
if (request == null) return null
val currentConfig = config
val decision = router.route(
url = request.url.toString(),
method = request.method,
headers = request.requestHeaders ?: emptyMap(),
config = currentConfig,
)
if (decision.action != StreamProxyAction.PROXY) {
logDecision(decision)
return null
}
return try {
val response = fetcher.fetch(
request = request,
decision = decision,
config = currentConfig,
router = router,
)
if (response == null) {
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=fallback " +
"reason=proxy_failed",
)
}
response
} catch (error: Exception) {
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} action=fallback " +
"reason=${error.javaClass.simpleName}",
)
null
}
}
private fun logDecision(decision: StreamProxyDecision) {
log(
"matched type=${decision.requestType.logName} " +
"channel=${decision.channel ?: ""} " +
"action=${decision.action.logName} " +
"reason=${decision.reason ?: "none"}",
)
}
private fun log(message: String) {
if (config.debugLogging) {
Log.d(LOG_TAG, message)
}
}
private fun installDocumentStartScript() {
val view = webView ?: return
removeDocumentStartScript()
documentStartSupported = WebViewFeature.isFeatureSupported(
WebViewFeature.DOCUMENT_START_SCRIPT,
)
if (!config.enabled) return
if (!documentStartSupported) {
log("page_hook action=skip reason=document_start_unsupported")
return
}
try {
documentStartScript = WebViewCompat.addDocumentStartJavaScript(
view,
StreamProxyPageScript.create(config),
setOf(
"https://player.twitch.tv",
"https://www.twitch.tv",
"https://m.twitch.tv",
"https://*.twitch.tv",
),
)
log("page_hook action=installed")
} catch (error: Exception) {
documentStartSupported = false
log("page_hook action=install_failed reason=${error.javaClass.simpleName}")
}
}
private fun removeDocumentStartScript() {
try {
documentStartScript?.remove()
} catch (error: Exception) {
log("page_hook action=remove_failed reason=${error.javaClass.simpleName}")
} finally {
documentStartScript = null
}
}
companion object {
private const val LOG_TAG = "FrostyStreamProxy"
}
}

View file

@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
org.gradle.caching=true
org.gradle.parallel=true
android.builtInKotlin=false
android.newDsl=false

View file

@ -615,15 +615,13 @@ abstract class ChatTabsStoreBase with Store {
/// Reorders a tab from oldIndex to newIndex.
/// The primary tab (index 0) cannot be moved, and no tab can be placed before it.
/// The caller must pass an insertion index already adjusted for Flutter's
/// ReorderableListView remove-then-insert behavior.
@action
void reorderTab(int oldIndex, int newIndex) {
// Primary tab (index 0) cannot be moved, and nothing can move before it
if (oldIndex == 0 || newIndex == 0) return;
// ReorderableListView passes newIndex as if item was already removed
if (oldIndex < newIndex) {
newIndex -= 1;
}
if (oldIndex == newIndex) return;
final tab = _tabs.removeAt(oldIndex);

View file

@ -144,9 +144,16 @@ class ChatTabs extends StatelessWidget {
left: 12,
right: showMerge ? 32 : 12,
),
// ignore: deprecated_member_use
onReorder: (oldIndex, newIndex) {
HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex);
final adjustedNewIndex = oldIndex < newIndex
? newIndex - 1
: newIndex;
chatTabsStore.reorderTab(
oldIndex,
adjustedNewIndex,
);
},
proxyDecorator: (child, index, animation) {
return Material(

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -21,9 +22,7 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
void initState() {
super.initState();
if (widget.videoStore.settingsStore.showVideo) {
widget.videoStore.videoWebViewController.loadRequest(
Uri.parse(widget.videoStore.videoUrl),
);
unawaited(widget.videoStore.initializeVideoPlayer());
}
WidgetsBinding.instance.addObserver(this);
}

View file

@ -9,6 +9,8 @@ import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
import 'package:frosty/services/stream_proxy_config.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simple_pip_mode/simple_pip.dart';
@ -47,6 +49,14 @@ abstract class VideoStoreBase with Store {
/// The video web view params used for enabling auto play.
late final PlatformWebViewControllerCreationParams _videoWebViewParams;
late final NavigationDelegate _navigationDelegate = NavigationDelegate(
onPageFinished: _handlePageFinished,
);
final StreamProxyBridge _streamProxyBridge = StreamProxyBridge();
var _streamProxyAttached = false;
/// The webview controller used for injecting JavaScript to control the webview and video player.
late final WebViewController videoWebViewController =
WebViewController.fromPlatformCreationParams(_videoWebViewParams)
@ -128,17 +138,7 @@ abstract class VideoStoreBase with Store {
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (url != videoUrl) return;
if (!_needsInit) return;
_needsInit = false;
await initVideo();
_acceptContentWarning();
},
),
);
..setNavigationDelegate(_navigationDelegate);
/// The timer that handles hiding the overlay automatically
Timer? _overlayTimer;
@ -169,6 +169,9 @@ abstract class VideoStoreBase with Store {
/// Disposes the latency settings reaction.
ReactionDisposer? _disposeLatencySettingsReaction;
/// Disposes the stream proxy settings reaction.
ReactionDisposer? _disposeStreamProxyReaction;
/// If the video is currently paused.
///
/// Does not pause or play the video, only used for rendering state of the overlay.
@ -297,6 +300,15 @@ abstract class VideoStoreBase with Store {
pip.setAutoPipMode(autoEnter: false);
}
});
_disposeStreamProxyReaction = reaction(
(_) => (
settingsStore.streamProxyMode,
settingsStore.streamProxyUrls.join('\n'),
settingsStore.streamProxyWhitelistedChannels.join('\n'),
),
(_) => unawaited(_syncStreamProxyBridge(refresh: true)),
);
}
updateStreamInfo();
@ -320,10 +332,7 @@ abstract class VideoStoreBase with Store {
// 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 numericPart = _latency!.replaceAll(RegExp(r'[^0-9.]'), '');
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
@ -343,6 +352,66 @@ abstract class VideoStoreBase with Store {
);
}
bool get _shouldUseStreamProxy =>
Platform.isAndroid &&
settingsStore.streamProxyMode == StreamProxyMode.ttvLolPro;
StreamProxyConfig get _streamProxyConfig => StreamProxyConfig.fromSettings(
settingsStore: settingsStore,
currentChannelLogin: userLogin,
);
Future<void> initializeVideoPlayer() async {
if (!settingsStore.showVideo) return;
await _syncStreamProxyBridge(refresh: false);
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
}
Future<void> _handlePageFinished(String url) async {
if (url != videoUrl) return;
if (!_needsInit) return;
_needsInit = false;
await initVideo();
_acceptContentWarning();
}
Future<void> _syncStreamProxyBridge({required bool refresh}) async {
if (!Platform.isAndroid) return;
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 {
await _streamProxyBridge.attach(
controller: platformController,
config: _streamProxyConfig,
onPageFinished: (url) => unawaited(_handlePageFinished(url)),
);
_streamProxyAttached = true;
}
if (refresh && settingsStore.showVideo) {
await handleRefresh(haptic: false);
}
}
@action
Future<void> updateStreamQualities() async {
try {
@ -1005,8 +1074,10 @@ abstract class VideoStoreBase with Store {
/// Refreshes the stream webview and updates the stream info.
@action
Future<void> handleRefresh() async {
HapticFeedback.lightImpact();
Future<void> handleRefresh({bool haptic = true}) async {
if (haptic) {
HapticFeedback.lightImpact();
}
_paused = true;
_firstTimeSettingQuality = true;
_isInPipMode = false;
@ -1022,6 +1093,7 @@ abstract class VideoStoreBase with Store {
// Ignore - may not exist yet
}
await _syncStreamProxyBridge(refresh: false);
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
updateStreamInfo();
@ -1105,6 +1177,15 @@ abstract class VideoStoreBase with Store {
_disposeVideoModeReaction();
_disposeAndroidAutoPipReaction?.call();
_disposeLatencySettingsReaction?.call();
_disposeStreamProxyReaction?.call();
final platformController = videoWebViewController.platform;
if (_streamProxyAttached &&
Platform.isAndroid &&
platformController is AndroidWebViewController) {
unawaited(_streamProxyBridge.detach(platformController));
}
_streamProxyBridge.dispose();
// Explicitly stop latency tracker as defense-in-depth.
// The Video widget also loads about:blank during disposal which clears

View file

@ -237,8 +237,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
);
@override
Future<void> handleRefresh() {
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
Future<void> handleRefresh({bool haptic = true}) {
return _$handleRefreshAsyncAction.run(
() => super.handleRefresh(haptic: haptic),
);
}
late final _$VideoStoreBaseActionController = ActionController(

View file

@ -66,6 +66,9 @@ abstract class _SettingsStoreBase with Store {
static const defaultDefaultToHighestQuality = false;
static const defaultUseTextureRendering = true;
static const defaultKeepScreenAwake = true;
static const defaultStreamProxyMode = StreamProxyMode.off;
static const defaultStreamProxyUrls = <String>[];
static const defaultStreamProxyWhitelistedChannels = <String>[];
// Overlay defaults
static const defaultShowOverlay = true;
@ -89,6 +92,22 @@ abstract class _SettingsStoreBase with Store {
@observable
var keepScreenAwake = defaultKeepScreenAwake;
@JsonKey(
defaultValue: defaultStreamProxyMode,
unknownEnumValue: StreamProxyMode.off,
)
@observable
var streamProxyMode = defaultStreamProxyMode;
@JsonKey(defaultValue: defaultStreamProxyUrls)
@observable
List<String> streamProxyUrls = defaultStreamProxyUrls;
@JsonKey(defaultValue: defaultStreamProxyWhitelistedChannels)
@observable
List<String> streamProxyWhitelistedChannels =
defaultStreamProxyWhitelistedChannels;
// Overlay options
@JsonKey(defaultValue: defaultShowOverlay)
@observable
@ -108,6 +127,9 @@ abstract class _SettingsStoreBase with Store {
defaultToHighestQuality = defaultDefaultToHighestQuality;
useTextureRendering = defaultUseTextureRendering;
keepScreenAwake = defaultKeepScreenAwake;
streamProxyMode = defaultStreamProxyMode;
streamProxyUrls = defaultStreamProxyUrls;
streamProxyWhitelistedChannels = defaultStreamProxyWhitelistedChannels;
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;
@ -416,3 +438,7 @@ enum TimestampType { disabled, twelve, twentyFour }
const landscapeCutoutNames = ['None', 'Left', 'Right', 'Both'];
enum LandscapeCutoutType { none, left, right, both }
const streamProxyModeNames = ['Off', 'On'];
enum StreamProxyMode { off, ttvLolPro }

View file

@ -24,6 +24,23 @@ SettingsStore _$SettingsStoreFromJson(
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
..streamProxyMode =
$enumDecodeNullable(
_$StreamProxyModeEnumMap,
json['streamProxyMode'],
unknownValue: StreamProxyMode.off,
) ??
StreamProxyMode.off
..streamProxyUrls =
(json['streamProxyUrls'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[]
..streamProxyWhitelistedChannels =
(json['streamProxyWhitelistedChannels'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[]
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..showLatency = json['showLatency'] as bool? ?? false
@ -102,6 +119,9 @@ Map<String, dynamic> _$SettingsStoreToJson(
'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering,
'keepScreenAwake': instance.keepScreenAwake,
'streamProxyMode': _$StreamProxyModeEnumMap[instance.streamProxyMode]!,
'streamProxyUrls': instance.streamProxyUrls,
'streamProxyWhitelistedChannels': instance.streamProxyWhitelistedChannels,
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
'showLatency': instance.showLatency,
@ -148,6 +168,11 @@ const _$ThemeTypeEnumMap = {
ThemeType.dark: 'dark',
};
const _$StreamProxyModeEnumMap = {
StreamProxyMode.off: 'off',
StreamProxyMode.ttvLolPro: 'ttvLolPro',
};
const _$TimestampTypeEnumMap = {
TimestampType.disabled: 'disabled',
TimestampType.twelve: 'twelve',
@ -343,6 +368,64 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$streamProxyModeAtom = Atom(
name: '_SettingsStoreBase.streamProxyMode',
context: context,
);
@override
StreamProxyMode get streamProxyMode {
_$streamProxyModeAtom.reportRead();
return super.streamProxyMode;
}
@override
set streamProxyMode(StreamProxyMode value) {
_$streamProxyModeAtom.reportWrite(value, super.streamProxyMode, () {
super.streamProxyMode = value;
});
}
late final _$streamProxyUrlsAtom = Atom(
name: '_SettingsStoreBase.streamProxyUrls',
context: context,
);
@override
List<String> get streamProxyUrls {
_$streamProxyUrlsAtom.reportRead();
return super.streamProxyUrls;
}
@override
set streamProxyUrls(List<String> value) {
_$streamProxyUrlsAtom.reportWrite(value, super.streamProxyUrls, () {
super.streamProxyUrls = value;
});
}
late final _$streamProxyWhitelistedChannelsAtom = Atom(
name: '_SettingsStoreBase.streamProxyWhitelistedChannels',
context: context,
);
@override
List<String> get streamProxyWhitelistedChannels {
_$streamProxyWhitelistedChannelsAtom.reportRead();
return super.streamProxyWhitelistedChannels;
}
@override
set streamProxyWhitelistedChannels(List<String> value) {
_$streamProxyWhitelistedChannelsAtom.reportWrite(
value,
super.streamProxyWhitelistedChannels,
() {
super.streamProxyWhitelistedChannels = value;
},
);
}
late final _$showOverlayAtom = Atom(
name: '_SettingsStoreBase.showOverlay',
context: context,
@ -1166,6 +1249,9 @@ showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality},
useTextureRendering: ${useTextureRendering},
keepScreenAwake: ${keepScreenAwake},
streamProxyMode: ${streamProxyMode},
streamProxyUrls: ${streamProxyUrls},
streamProxyWhitelistedChannels: ${streamProxyWhitelistedChannels},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
showLatency: ${showLatency},

View file

@ -3,7 +3,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/screens/settings/widgets/settings_string_list_editor.dart';
import 'package:frosty/services/stream_proxy_config.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/settings_page_layout.dart';
@ -49,6 +52,54 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.keepScreenAwake,
onChanged: (newValue) => settingsStore.keepScreenAwake = newValue,
),
if (Platform.isAndroid) ...[
SettingsListSelect(
title: 'Stream proxy mode',
selectedOption:
streamProxyModeNames[settingsStore.streamProxyMode.index],
options: streamProxyModeNames,
onChanged: (selectedOption) {
final selectedIndex = streamProxyModeNames.indexOf(
selectedOption,
);
if (selectedIndex == -1) return;
settingsStore.streamProxyMode =
StreamProxyMode.values[selectedIndex];
},
),
if (settingsStore.streamProxyMode == StreamProxyMode.ttvLolPro) ...[
SettingsStringListEditor(
title: 'Proxy URLs',
subtitle: 'Used for eligible livestream playback requests.',
emptyMessage: 'No proxy URLs',
hintText: 'proxy.example.com:3128',
values: settingsStore.streamProxyUrls,
validator: validateStreamProxyUrl,
onChanged: (values) {
settingsStore.streamProxyUrls = values
.map((value) => value.trim())
.where((value) => value.isNotEmpty)
.toList();
},
),
SettingsStringListEditor(
title: 'Whitelisted channels',
subtitle: 'Channels that should always load direct.',
emptyMessage: 'No whitelisted channels',
hintText: 'streamer_name123',
values: settingsStore.streamProxyWhitelistedChannels,
validator: validateStreamProxyChannelLogin,
normalizeValue: (value) => value.trim().toLowerCase(),
onChanged: (values) {
settingsStore.streamProxyWhitelistedChannels = values
.map((value) => value.trim().toLowerCase())
.where((value) => value.isNotEmpty)
.toList();
},
),
],
],
const SectionHeader('Overlay'),
SettingsListSwitch(
title: 'Use custom video overlay',

View file

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
class SettingsStringListEditor extends StatefulWidget {
final String title;
final String? subtitle;
final String emptyMessage;
final String hintText;
final List<String> values;
final ValueChanged<List<String>> onChanged;
final String? Function(String value) validator;
final String Function(String value) normalizeValue;
const SettingsStringListEditor({
super.key,
required this.title,
this.subtitle,
required this.emptyMessage,
required this.hintText,
required this.values,
required this.onChanged,
required this.validator,
this.normalizeValue = _trimValue,
});
static String _trimValue(String value) => value.trim();
@override
State<SettingsStringListEditor> createState() =>
_SettingsStringListEditorState();
}
class _SettingsStringListEditorState extends State<SettingsStringListEditor> {
final TextEditingController textController = TextEditingController();
final FocusNode textFieldFocusNode = FocusNode();
@override
Widget build(BuildContext context) {
final subtitle = widget.subtitle;
return ListTile(
trailing: const Icon(Icons.edit),
title: Text(widget.title),
subtitle: subtitle != null ? Text(subtitle) : null,
onTap: () => _showEditor(context),
);
}
void _showEditor(BuildContext context) {
var values = [...widget.values];
String? errorText;
textController.clear();
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) {
void addValue() {
final rawValue = textController.text;
final error = widget.validator(rawValue);
if (error != null) {
setModalState(() => errorText = error);
return;
}
final value = widget.normalizeValue(rawValue);
if (values.contains(value)) {
setModalState(() => errorText = 'This entry is already saved');
return;
}
setModalState(() {
values = [...values, value];
errorText = null;
});
widget.onChanged(values);
textController.clear();
textFieldFocusNode.unfocus();
}
void removeValue(int index) {
final removed = values.elementAt(index);
setModalState(() {
values = [...values..removeAt(index)];
});
widget.onChanged(values);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Removed '$removed'"),
action: SnackBarAction(
label: 'Undo',
onPressed: () {
setModalState(() {
values = [...values, removed];
});
widget.onChanged(values);
},
),
),
);
}
return SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: TextField(
controller: textController,
focusNode: textFieldFocusNode,
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
onSubmitted: (_) => addValue(),
autocorrect: false,
decoration: InputDecoration(
hintText: widget.hintText,
errorText: errorText,
suffixIcon: IconButton(
tooltip: 'Add',
onPressed: addValue,
icon: const Icon(Icons.add),
),
),
),
),
if (values.isEmpty)
Expanded(
child: AlertMessage(
message: widget.emptyMessage,
vertical: true,
),
)
else
Expanded(
child: FrostyScrollbar(
child: ListView.builder(
itemCount: values.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(values.elementAt(index)),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => removeValue(index),
),
);
},
),
),
),
],
),
);
},
),
);
}
@override
void dispose() {
textController.dispose();
textFieldFocusNode.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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');
void Function(String url)? _onPageFinished;
int? _webViewIdentifier;
StreamProxyBridge() {
_channel.setMethodCallHandler(_handleMethodCall);
}
bool get isSupported => Platform.isAndroid;
Future<void> attach({
required AndroidWebViewController controller,
required StreamProxyConfig config,
required void Function(String url) onPageFinished,
}) async {
if (!isSupported) return;
_onPageFinished = onPageFinished;
_webViewIdentifier = controller.webViewIdentifier;
await _invoke('attach', {
'webViewIdentifier': _webViewIdentifier,
'config': config.toMethodChannelPayload(),
});
}
Future<void> updateConfig(StreamProxyConfig config) async {
if (!isSupported) return;
await _invoke('updateConfig', {
'webViewIdentifier': _webViewIdentifier,
'config': config.toMethodChannelPayload(),
});
}
Future<void> detach(AndroidWebViewController controller) async {
if (!isSupported) return;
await _invoke('detach', {
'webViewIdentifier': controller.webViewIdentifier,
});
_onPageFinished = null;
_webViewIdentifier = null;
}
void dispose() {
_onPageFinished = null;
_webViewIdentifier = null;
_channel.setMethodCallHandler(null);
}
Future<void> _invoke(String method, Map<String, Object?> arguments) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} on PlatformException catch (e) {
debugPrint('Stream proxy bridge $method failed: ${e.message}');
}
}
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);
}
}
return;
default:
debugPrint('Unknown stream proxy bridge method: ${call.method}');
}
}
}

View file

@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
class StreamProxyConfig {
final StreamProxyMode mode;
final String currentChannelLogin;
final List<String> proxyUrls;
final List<String> whitelistedChannels;
final bool debugLogging;
const StreamProxyConfig({
required this.mode,
required this.currentChannelLogin,
required this.proxyUrls,
required this.whitelistedChannels,
this.debugLogging = kDebugMode,
});
factory StreamProxyConfig.fromSettings({
required SettingsStore settingsStore,
required String currentChannelLogin,
bool debugLogging = kDebugMode,
}) {
return StreamProxyConfig(
mode: settingsStore.streamProxyMode,
currentChannelLogin: currentChannelLogin,
proxyUrls: settingsStore.streamProxyUrls,
whitelistedChannels: settingsStore.streamProxyWhitelistedChannels,
debugLogging: debugLogging,
);
}
Map<String, Object?> toMethodChannelPayload() {
return {
'mode': mode.name,
'currentChannelLogin': currentChannelLogin.trim().toLowerCase(),
'proxyUrls': proxyUrls
.map((url) => url.trim())
.where((url) => url.isNotEmpty)
.toList(),
'whitelistedChannels': whitelistedChannels
.map((channel) => channel.trim().toLowerCase())
.where((channel) => channel.isNotEmpty)
.toList(),
'debugLogging': debugLogging,
};
}
}
String? validateStreamProxyUrl(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return 'Enter a proxy URL';
}
final normalized = trimmed.contains('://') ? trimmed : 'http://$trimmed';
final Uri uri;
try {
uri = Uri.parse(normalized);
} catch (_) {
return 'Enter a valid proxy URL';
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return 'Use an HTTP or HTTPS proxy';
}
if (uri.host.isEmpty) {
return 'Enter a proxy host';
}
try {
if (!uri.hasPort || uri.port <= 0 || uri.port > 65535) {
return 'Enter a proxy port';
}
} catch (_) {
return 'Enter a numeric proxy port';
}
return null;
}
String? validateStreamProxyChannelLogin(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return 'Enter a channel login';
}
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(trimmed)) {
return 'Use letters, numbers, and underscores only';
}
return null;
}

File diff suppressed because it is too large Load diff

@ -0,0 +1 @@
Subproject commit d5bbb6dd77240328d9679c797170ec0309560b77

View file

@ -14,6 +14,9 @@ void main() {
expect(store.showVideo, isTrue);
expect(store.defaultToHighestQuality, isFalse);
expect(store.useTextureRendering, isTrue);
expect(store.streamProxyMode, StreamProxyMode.off);
expect(store.streamProxyUrls, isEmpty);
expect(store.streamProxyWhitelistedChannels, isEmpty);
expect(store.showOverlay, isTrue);
expect(store.toggleableOverlay, isFalse);
expect(store.showLatency, isFalse);
@ -67,6 +70,12 @@ void main() {
store.chatDelay = 5.0;
store.syncedChatDelay = 8.0;
store.showVideo = false;
store.streamProxyMode = StreamProxyMode.ttvLolPro;
store.streamProxyUrls = [
'proxy.example.com:3128',
'https://user:pass@proxy.example.com:443',
];
store.streamProxyWhitelistedChannels = ['streamer_name123'];
store.timestampType = TimestampType.twelve;
store.landscapeCutout = LandscapeCutoutType.both;
store.mutedWords = ['spam', 'bad'];
@ -83,6 +92,12 @@ void main() {
expect(restored.syncedChatDelay, 0.0);
expect(json.containsKey('syncedChatDelay'), isFalse);
expect(restored.showVideo, isFalse);
expect(restored.streamProxyMode, StreamProxyMode.ttvLolPro);
expect(restored.streamProxyUrls, [
'proxy.example.com:3128',
'https://user:pass@proxy.example.com:443',
]);
expect(restored.streamProxyWhitelistedChannels, ['streamer_name123']);
expect(restored.timestampType, TimestampType.twelve);
expect(restored.landscapeCutout, LandscapeCutoutType.both);
expect(restored.mutedWords, ['spam', 'bad']);
@ -100,6 +115,13 @@ void main() {
});
expect(store.timestampType, TimestampType.disabled);
});
test('unknown StreamProxyMode enum value falls back to off', () {
final store = SettingsStore.fromJson({
'streamProxyMode': 'nonexistent_proxy_mode',
});
expect(store.streamProxyMode, StreamProxyMode.off);
});
});
group('SettingsStore reset actions', () {
@ -127,6 +149,9 @@ void main() {
store.showVideo = false;
store.defaultToHighestQuality = true;
store.useTextureRendering = false;
store.streamProxyMode = StreamProxyMode.ttvLolPro;
store.streamProxyUrls = ['proxy.example.com:3128'];
store.streamProxyWhitelistedChannels = ['streamer_name123'];
store.showOverlay = false;
store.toggleableOverlay = true;
store.showLatency = true;
@ -136,6 +161,9 @@ void main() {
expect(store.showVideo, isTrue);
expect(store.defaultToHighestQuality, isFalse);
expect(store.useTextureRendering, isTrue);
expect(store.streamProxyMode, StreamProxyMode.off);
expect(store.streamProxyUrls, isEmpty);
expect(store.streamProxyWhitelistedChannels, isEmpty);
expect(store.showOverlay, isTrue);
expect(store.toggleableOverlay, isFalse);
expect(store.showLatency, isFalse);
@ -292,5 +320,11 @@ void main() {
expect(timestampNames[1], '12-hour');
expect(timestampNames[2], '24-hour');
});
test('streamProxyModeNames matches StreamProxyMode order', () {
expect(streamProxyModeNames.length, StreamProxyMode.values.length);
expect(streamProxyModeNames[0], 'Off');
expect(streamProxyModeNames[1], 'On');
});
});
}

View file

@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/stream_proxy_config.dart';
void main() {
group('StreamProxyConfig', () {
test('toMethodChannelPayload serializes settings for Android bridge', () {
final config = StreamProxyConfig(
mode: StreamProxyMode.ttvLolPro,
currentChannelLogin: 'Streamer',
proxyUrls: const [
'proxy.example.com:3128',
'https://user:pass@proxy.example.com:443',
],
whitelistedChannels: const ['Whitelisted_Channel'],
debugLogging: false,
);
expect(config.toMethodChannelPayload(), {
'mode': 'ttvLolPro',
'currentChannelLogin': 'streamer',
'proxyUrls': [
'proxy.example.com:3128',
'https://user:pass@proxy.example.com:443',
],
'whitelistedChannels': ['whitelisted_channel'],
'debugLogging': false,
});
});
});
group('stream proxy validators', () {
test('accepts supported HTTP and HTTPS proxy URL formats', () {
expect(validateStreamProxyUrl('proxy.example.com:3128'), isNull);
expect(validateStreamProxyUrl('http://proxy.example.com:8080'), isNull);
expect(
validateStreamProxyUrl('https://user:pass@proxy.example.com:443'),
isNull,
);
});
test('rejects unsupported or incomplete proxy URL formats', () {
expect(validateStreamProxyUrl(''), isNotNull);
expect(
validateStreamProxyUrl('socks://proxy.example.com:1080'),
isNotNull,
);
expect(validateStreamProxyUrl('https://proxy.example.com'), isNotNull);
expect(validateStreamProxyUrl('example.com:notaport'), isNotNull);
});
test('accepts valid channel whitelist entries', () {
expect(validateStreamProxyChannelLogin('streamer_name123'), isNull);
});
test('rejects invalid channel whitelist entries', () {
expect(validateStreamProxyChannelLogin('streamer name'), isNotNull);
expect(validateStreamProxyChannelLogin('streamer/name'), isNotNull);
});
});
}