diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c0a4184..88ab511b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -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; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8be1cecd..b85295f0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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) } } diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift new file mode 100644 index 00000000..e39a4acc --- /dev/null +++ b/ios/Runner/CookieExtractorPlugin.swift @@ -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) + } + } +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 292fee35..44901993 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -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? _streamInfoRequest; bool _overlayWasVisibleBeforePip = true; List _qualityObjects = []; var _firstTimeSettingQuality = true; + int? _pendingQualityIndex; StreamSubscription? _pipSub; StreamSubscription>? _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 _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(); diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 84d4a2ba..a302f7a8 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -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 _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 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 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(); diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index e3f1a04f..5331d568 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -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, diff --git a/lib/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart new file mode 100644 index 00000000..75315a1e --- /dev/null +++ b/lib/services/cookie_extractor.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class CookieExtractor { + static const _channel = MethodChannel('frosty/cookie_extractor'); + + static Future extractTwitchAuthToken() async { + if (!Platform.isIOS) return null; + return await _channel.invokeMethod('extractTwitchAuthToken'); + } +} diff --git a/pubspec.lock b/pubspec.lock index ca382203..99037401 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index 67b2662b..83ca06c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: