add ad proxying
This commit is contained in:
parent
2b81f2cb31
commit
b45080dcc8
28 changed files with 4366 additions and 1601 deletions
3
.fvmrc
Normal file
3
.fvmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"flutter": "3.38.9"
|
||||
}
|
||||
145
.gitignore
vendored
145
.gitignore
vendored
|
|
@ -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
3
.gitmodules
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.38.9"
|
||||
}
|
||||
|
|
@ -58,3 +58,8 @@ android {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<*, *>)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
174
lib/screens/settings/widgets/settings_string_list_editor.dart
Normal file
174
lib/screens/settings/widgets/settings_string_list_editor.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
84
lib/services/stream_proxy_bridge.dart
Normal file
84
lib/services/stream_proxy_bridge.dart
Normal 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}');
|
||||
}
|
||||
}
|
||||
}
|
||||
94
lib/services/stream_proxy_config.dart
Normal file
94
lib/services/stream_proxy_config.dart
Normal 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;
|
||||
}
|
||||
3006
pubspec.lock
3006
pubspec.lock
File diff suppressed because it is too large
Load diff
1
references/ttv-lol-pro
Submodule
1
references/ttv-lol-pro
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit d5bbb6dd77240328d9679c797170ec0309560b77
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
61
test/services/stream_proxy_config_test.dart
Normal file
61
test/services/stream_proxy_config_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue