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:
parent
1020b37e2f
commit
1779520db8
9 changed files with 157 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
ios/Runner/CookieExtractorPlugin.swift
Normal file
26
ios/Runner/CookieExtractorPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
12
lib/services/cookie_extractor.dart
Normal file
12
lib/services/cookie_extractor.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue