use forked plugin for seamless quality switching, add latency tracking and cookie auth

Switch to forked better_native_video_player that uses preferredPeakBitRate
instead of replaceCurrentItem for seamless quality transitions without
black flash. Fork also adds configureForLivePlayback and getLatencyToLive
APIs for low-latency tuning and HLS program date-time based latency.

Add latency polling (10s interval) with auto-sync chat delay support.
Only update syncedChatDelay on >2s drift to avoid restarting countdown.

Add cookie extraction from WKWebView login session for ad-free GQL
playback. The auth-token cookie works with the web Client-ID per
Streamlink's approach. Extract at login, persist to secure storage,
and attempt fresh extraction on init for existing logged-in users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-08 16:28:50 -07:00
parent 1020b37e2f
commit 1779520db8
9 changed files with 157 additions and 19 deletions

View file

@ -11,6 +11,7 @@
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -38,6 +39,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -106,6 +108,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
);
@ -304,6 +307,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -8,6 +8,7 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
CookieExtractorPlugin.register(with: self.registrar(forPlugin: "CookieExtractorPlugin")!)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View file

@ -0,0 +1,26 @@
import Flutter
import WebKit
class CookieExtractorPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "frosty/cookie_extractor",
binaryMessenger: registrar.messenger()
)
let instance = CookieExtractorPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "extractTwitchAuthToken" {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let authCookie = cookies.first {
$0.name == "auth-token" && $0.domain.contains("twitch.tv")
}
result(authCookie?.value)
}
} else {
result(FlutterMethodNotImplemented)
}
}
}

View file

@ -11,6 +11,7 @@ import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/cookie_extractor.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -34,13 +35,14 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
NativeVideoPlayerController? _controller;
Timer? _overlayTimer;
Timer? _streamInfoTimer;
Timer? _latencyTimer;
DateTime? _lastStreamInfoUpdate;
Future<void>? _streamInfoRequest;
bool _overlayWasVisibleBeforePip = true;
List<NativeVideoPlayerQuality> _qualityObjects = [];
var _firstTimeSettingQuality = true;
int? _pendingQualityIndex;
StreamSubscription<bool>? _pipSub;
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
@ -67,7 +69,6 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
/// Always null — native player has no latency-to-broadcaster metric.
@readonly
String? _latency;
@ -101,8 +102,10 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
@action
Future<void> _initPlayer() async {
try {
// Try with auth token for ad-free playback, fall back to anonymous.
final authToken = authStore.isLoggedIn ? authStore.token : null;
// Prefer web cookie token (works with web Client-ID) for ad-free playback.
// Fall back to direct cookie extraction if AuthStore hasn't resolved it yet.
var authToken = authStore.gqlToken;
authToken ??= await CookieExtractor.extractTwitchAuthToken();
late final PlaybackAccessToken token;
try {
token = await twitchGqlApi.getPlaybackAccessToken(
@ -159,20 +162,24 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
if (_firstTimeSettingQuality && qualities.isNotEmpty) {
_firstTimeSettingQuality = false;
if (settingsStore.defaultToHighestQuality) {
_setStreamQualityIndex(1);
return;
_pendingQualityIndex = 1;
} else {
SharedPreferences.getInstance().then((prefs) {
final lastQuality = prefs.getString('last_stream_quality');
if (lastQuality != null) {
final index = _availableStreamQualities.indexOf(lastQuality);
if (index != -1) _pendingQualityIndex = index;
}
});
}
SharedPreferences.getInstance().then((prefs) {
final lastQuality = prefs.getString('last_stream_quality');
if (lastQuality != null) {
setStreamQuality(lastQuality);
}
});
}
});
});
await _controller!.loadUrl(url: _hlsUrl!);
await _controller!.configureForLivePlayback();
_startLatencyPolling();
runInAction(() {
_error = null;
@ -191,6 +198,11 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
switch (event.state) {
case PlayerActivityState.playing:
_paused = false;
if (_pendingQualityIndex != null) {
final index = _pendingQualityIndex!;
_pendingQualityIndex = null;
_setStreamQualityIndex(index);
}
case PlayerActivityState.paused:
case PlayerActivityState.stopped:
case PlayerActivityState.completed:
@ -202,6 +214,34 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
});
}
void _startLatencyPolling() {
_latencyTimer?.cancel();
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0;
}
_latencyTimer = Timer.periodic(
const Duration(seconds: 10),
(_) async {
final seconds = await _controller?.getLatencyToLive();
if (seconds == null) return;
final rounded = seconds.round();
runInAction(() {
_latency = '${rounded}s';
});
if (!settingsStore.autoSyncChatDelay) return;
// Only update when unset or drifted by >2s to avoid restarting
// the chat countdown on minor fluctuations.
final current = settingsStore.syncedChatDelay;
if (current == 0 || (seconds - current).abs() > 2) {
settingsStore.syncedChatDelay = seconds;
}
},
);
}
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
_overlayTimer?.cancel();
@ -267,11 +307,13 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
HapticFeedback.lightImpact();
_paused = true;
_firstTimeSettingQuality = true;
_pendingQualityIndex = null;
_isInPipMode = false;
_availableStreamQualities = [];
_qualityObjects = [];
_error = null;
_latencyTimer?.cancel();
_pipSub?.cancel();
_qualitiesSub?.cancel();
_controller?.removeActivityListener(_handleActivityEvent);
@ -389,7 +431,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface {
@action
void dispose() {
_overlayTimer?.cancel();
_streamInfoTimer?.cancel();
_latencyTimer?.cancel();
_pipSub?.cancel();
_qualitiesSub?.cancel();

View file

@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/services/cookie_extractor.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -27,6 +28,9 @@ abstract class AuthBase with Store {
/// The shared_preferences key for the user token.
static const _userTokenKey = 'user_token';
/// The secure storage key for the GQL web cookie token.
static const _gqlTokenKey = 'gql_token';
/// The Twitch API service for making requests.
final TwitchApi twitchApi;
@ -49,6 +53,10 @@ abstract class AuthBase with Store {
@readonly
String? _token;
/// Web cookie token extracted from the login WebView for GQL ad-free playback.
@readonly
String? _gqlToken;
/// Whether the user is logged in or not.
@readonly
var _isLoggedIn = false;
@ -83,6 +91,7 @@ abstract class AuthBase with Store {
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
_extractGqlToken();
if (routeAfter != null) {
navigatorKey.currentState?.pop();
navigatorKey.currentState?.push(
@ -211,12 +220,27 @@ abstract class AuthBase with Store {
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
Future<void> _extractGqlToken() async {
try {
final token = await CookieExtractor.extractTwitchAuthToken();
if (token != null) {
runInAction(() {
_gqlToken = token;
});
await _storage.write(key: _gqlTokenKey, value: token);
}
} catch (e) {
debugPrint('GQL token extraction failed: $e');
}
}
/// Initialize by retrieving a token if it does not already exist.
@action
Future<void> init() async {
try {
// Read and set the currently stored user token, if any.
_token = await _storage.read(key: _userTokenKey);
_gqlToken = await _storage.read(key: _gqlTokenKey);
// If the token does not exist, get the default token.
// Otherwise, log in.
@ -251,6 +275,9 @@ abstract class AuthBase with Store {
}
}
// Try extracting a fresh GQL token from the WebView cookie store.
if (_isLoggedIn) await _extractGqlToken();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', _isLoggedIn);
_error = null;
} catch (e) {
@ -293,9 +320,11 @@ abstract class AuthBase with Store {
Future<void> logout() async {
try {
_stopReconnectLoop();
// Delete the existing user token.
// Delete the existing user token and GQL token.
await _storage.delete(key: _userTokenKey);
await _storage.delete(key: _gqlTokenKey);
_token = null;
_gqlToken = null;
// Clear the user info.
user.dispose();

View file

@ -35,6 +35,26 @@ mixin _$AuthStore on AuthBase, Store {
});
}
late final _$_gqlTokenAtom = Atom(
name: 'AuthBase._gqlToken',
context: context,
);
String? get gqlToken {
_$_gqlTokenAtom.reportRead();
return super._gqlToken;
}
@override
String? get _gqlToken => gqlToken;
@override
set _gqlToken(String? value) {
_$_gqlTokenAtom.reportWrite(value, super._gqlToken, () {
super._gqlToken = value;
});
}
late final _$_isLoggedInAtom = Atom(
name: 'AuthBase._isLoggedIn',
context: context,

View file

@ -0,0 +1,12 @@
import 'dart:io';
import 'package:flutter/services.dart';
class CookieExtractor {
static const _channel = MethodChannel('frosty/cookie_extractor');
static Future<String?> extractTwitchAuthToken() async {
if (!Platform.isIOS) return null;
return await _channel.invokeMethod<String>('extractTwitchAuthToken');
}
}

View file

@ -100,10 +100,11 @@ packages:
better_native_video_player:
dependency: "direct main"
description:
name: better_native_video_player
sha256: ba3771b064d0369270ced87a0e1319b376d96740ed7e94804f500b1578729bbb
url: "https://pub.dev"
source: hosted
path: "."
ref: frosty-patches
resolved-ref: e16914aff5ea8a0f5bc038eb706764738f2a99c9
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
source: git
version: "0.4.11"
boolean_selector:
dependency: transitive

View file

@ -71,7 +71,10 @@ dependencies:
app_links: ^7.0.0
extended_text_field: ^16.0.2
native_device_orientation: ^2.1.0
better_native_video_player: ^0.4.1
better_native_video_player:
git:
url: https://github.com/tommyxchow/flutter-native-video-player.git
ref: frosty-patches
dev_dependencies:
flutter_test: