From 1020b37e2f86be26ec4de290b4a9dc2cecf5e868 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 03:13:00 -0700 Subject: [PATCH 01/28] add experimental native video player with iOS auto-PiP Adds an opt-in native HLS video player (iOS-only, off by default) that uses AVPlayerViewController for true automatic picture-in-picture on swipe-home, replacing the WebView-based player when enabled. Key changes: - GQL API client for fetching playback access tokens and building HLS URLs - NativeVideoStore (MobX) managing player lifecycle, quality selection, PiP - VideoPlayerInterface abstraction so both stores drive the same overlay - Settings toggle gated on iOS with "Native player (experimental)" label - Auth token fallback: tries authenticated GQL for ad-free, falls back to anonymous if rejected (app OAuth token != web Client-ID token) - Scoped UnauthorizedInterceptor to Helix/OAuth URLs only - iOS Info.plist background audio mode for PiP audio continuity Co-Authored-By: Claude Opus 4.6 --- ios/Podfile.lock | 6 + ios/Runner/Info.plist | 4 + lib/apis/twitch_gql_api.dart | 69 +++ lib/apis/unauthorized_interceptor.dart | 11 +- lib/constants.dart | 3 + lib/main.dart | 3 + lib/models/playback_access_token.dart | 18 + lib/screens/channel/channel.dart | 34 +- lib/screens/channel/video/native_video.dart | 46 ++ .../channel/video/native_video_store.dart | 400 ++++++++++++++++++ .../channel/video/native_video_store.g.dart | 355 ++++++++++++++++ lib/screens/channel/video/video_overlay.dart | 10 +- .../channel/video/video_player_interface.dart | 30 ++ lib/screens/channel/video/video_store.dart | 16 +- .../settings/stores/settings_store.dart | 8 + .../settings/stores/settings_store.g.dart | 21 + lib/screens/settings/video_settings.dart | 10 + lib/utils/context_extensions.dart | 4 + pubspec.lock | 24 ++ pubspec.yaml | 1 + 20 files changed, 1059 insertions(+), 14 deletions(-) create mode 100644 lib/apis/twitch_gql_api.dart create mode 100644 lib/models/playback_access_token.dart create mode 100644 lib/screens/channel/video/native_video.dart create mode 100644 lib/screens/channel/video/native_video_store.dart create mode 100644 lib/screens/channel/video/native_video_store.g.dart create mode 100644 lib/screens/channel/video/video_player_interface.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a571234b..f0d96492 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - Flutter - app_links (7.0.0): - Flutter + - better_native_video_player (0.4.11): + - Flutter - device_info_plus (0.0.1): - Flutter - Firebase/CoreOnly (12.9.0): @@ -192,6 +194,7 @@ PODS: DEPENDENCIES: - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - app_links (from `.symlinks/plugins/app_links/ios`) + - better_native_video_player (from `.symlinks/plugins/better_native_video_player/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -237,6 +240,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/advanced_in_app_review/ios" app_links: :path: ".symlinks/plugins/app_links/ios" + better_native_video_player: + :path: ".symlinks/plugins/better_native_video_player/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" firebase_analytics: @@ -273,6 +278,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d + better_native_video_player: 88a6b9bf169428352c05b6ebec085c5263603282 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8aa7eded..6052fdf9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -54,5 +54,9 @@ UIStatusBarHidden + UIBackgroundModes + + audio + diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart new file mode 100644 index 00000000..11f67102 --- /dev/null +++ b/lib/apis/twitch_gql_api.dart @@ -0,0 +1,69 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +/// Twitch GQL API client for fetching playback access tokens. +/// +/// Uses the Twitch web client ID (not the app's registered client ID) +/// since the GQL endpoint requires it. +class TwitchGqlApi extends BaseApiClient { + TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); + + /// Fetches a playback access token for the given channel [login]. + /// + /// When [authToken] is provided, the request is authenticated which may + /// grant ad-free playback for subscribers and Turbo users. + Future getPlaybackAccessToken({ + required String login, + String? authToken, + }) async { + final headers = { + 'Client-ID': twitchGqlClientId, + if (authToken != null) 'Authorization': 'OAuth $authToken', + }; + + final body = { + 'operationName': 'PlaybackAccessToken_Template', + 'query': + 'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }', + 'variables': { + 'isLive': true, + 'login': login, + 'isVod': false, + 'vodID': '', + 'playerType': 'site', + }, + }; + + final response = await post( + '/gql', + data: body, + headers: headers, + ); + + return PlaybackAccessToken.fromGqlResponse(response); + } + + /// Builds the HLS stream URL for the given channel [login] and [token]. + String buildHlsUrl({ + required String login, + required PlaybackAccessToken token, + }) { + final random = Random().nextInt(999999); + final encodedToken = Uri.encodeComponent(token.value); + + return 'https://usher.ttvnw.net/api/channel/hls/$login.m3u8' + '?sig=${token.signature}' + '&token=$encodedToken' + '&allow_source=true' + '&allow_audio_only=true' + '&fast_bread=true' + '&supported_codecs=h264' + '&platform=web' + '&playlist_include_framerate=true' + '&p=$random'; + } +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 5f3f26cc..bd983fc4 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { - // Check if this is a 401 Unauthorized error - if (err.response?.statusCode == 401) { + // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request + if (err.response?.statusCode == 401 && + _isTwitchHelixOrOAuth(err.requestOptions.uri)) { // For token validation requests, let the error propagate so validateToken can handle it if (err.requestOptions.uri.path.endsWith('/validate')) { handler.next(err); @@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor { } static bool _isDialogShowing = false; + + bool _isTwitchHelixOrOAuth(Uri uri) { + final url = uri.toString(); + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 2edaddba..eb17910b 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,6 +3,9 @@ import 'package:flutter/material.dart'; /// Twitch API client ID. const clientId = String.fromEnvironment('CLIENT_ID'); +/// Twitch GQL client ID (web player, used for playback access tokens). +const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); diff --git a/lib/main.dart b/lib/main.dart index cc428c84..795374b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; import 'package:frosty/firebase_options.dart'; @@ -104,6 +105,7 @@ void main() async { final bttvApiService = BTTVApi(dioClient); final ffzApiService = FFZApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient); + final twitchGqlApiService = TwitchGqlApi(dioClient); // Create global assets store (shared cache for global emotes/badges) final globalAssetsStore = GlobalAssetsStore( @@ -137,6 +139,7 @@ void main() async { Provider.value(value: bttvApiService), Provider.value(value: ffzApiService), Provider.value(value: sevenTVApiService), + Provider.value(value: twitchGqlApiService), Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart new file mode 100644 index 00000000..244765d8 --- /dev/null +++ b/lib/models/playback_access_token.dart @@ -0,0 +1,18 @@ +class PlaybackAccessToken { + final String value; + final String signature; + + const PlaybackAccessToken({ + required this.value, + required this.signature, + }); + + factory PlaybackAccessToken.fromGqlResponse(Map json) { + final data = json['data']['streamPlaybackAccessToken'] + as Map; + return PlaybackAccessToken( + value: data['value'] as String, + signature: data['signature'] as String, + ); + } +} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 3d24bb26..468dca06 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,9 +6,12 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/native_video.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/video.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; @@ -75,13 +78,23 @@ class _VideoChatState extends State primaryDisplayName: widget.userName, ); - late final VideoStore _videoStore = VideoStore( - userLogin: widget.userLogin, - userId: widget.userId, - twitchApi: context.twitchApi, - authStore: context.authStore, - settingsStore: context.settingsStore, - ); + late final VideoPlayerInterface _videoStore = + Platform.isIOS && context.settingsStore.useNativePlayer + ? NativeVideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + twitchGqlApi: context.twitchGqlApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ) + : VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); @override void initState() { @@ -297,7 +310,12 @@ class _VideoChatState extends State final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - child: Video(key: _videoKey, videoStore: _videoStore), + child: _videoStore is NativeVideoStore + ? NativeVideo(nativeVideoStore: _videoStore) + : Video( + key: _videoKey, + videoStore: _videoStore as VideoStore, + ), ); final overlay = GestureDetector( diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart new file mode 100644 index 00000000..cbffca9c --- /dev/null +++ b/lib/screens/channel/video/native_video.dart @@ -0,0 +1,46 @@ +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; + +/// Native video player widget backed by AVPlayerViewController (iOS) +/// and ExoPlayer (Android). +class NativeVideo extends StatelessWidget { + final NativeVideoStore nativeVideoStore; + + const NativeVideo({super.key, required this.nativeVideoStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final controller = nativeVideoStore.controller; + final error = nativeVideoStore.error; + + return ColoredBox( + color: Colors.black, + child: Stack( + children: [ + if (controller != null) + NativeVideoPlayer( + key: ObjectKey(controller), + controller: controller, + ), + if (error != null) + Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + error, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart new file mode 100644 index 00000000..292fee35 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.dart @@ -0,0 +1,400 @@ +import 'dart:async'; + +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/playback_access_token.dart'; +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:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'native_video_store.g.dart'; + +class NativeVideoStore = NativeVideoStoreBase with _$NativeVideoStore; + +abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { + static int _nextId = 0; + + final String userLogin; + final String userId; + final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; + final AuthStore authStore; + + @override + final SettingsStore settingsStore; + + @readonly + NativeVideoPlayerController? _controller; + + Timer? _overlayTimer; + Timer? _streamInfoTimer; + DateTime? _lastStreamInfoUpdate; + Future? _streamInfoRequest; + bool _overlayWasVisibleBeforePip = true; + + List _qualityObjects = []; + var _firstTimeSettingQuality = true; + + StreamSubscription? _pipSub; + StreamSubscription>? _qualitiesSub; + + @readonly + var _paused = true; + + @readonly + var _overlayVisible = true; + + @readonly + StreamTwitch? _streamInfo; + + @readonly + Channel? _offlineChannelInfo; + + @readonly + var _availableStreamQualities = []; + + @readonly + var _streamQualityIndex = 0; + + @override + String get streamQuality => + _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; + + /// Always null — native player has no latency-to-broadcaster metric. + @readonly + String? _latency; + + @readonly + var _isInPipMode = false; + + @readonly + String? _error; + + String? _hlsUrl; + + NativeVideoStoreBase({ + required this.userLogin, + required this.userId, + required this.twitchApi, + required this.twitchGqlApi, + required this.authStore, + required this.settingsStore, + }) { + _controller = NativeVideoPlayerController( + id: _nextId++, + autoPlay: true, + showNativeControls: false, + ); + _controller!.addActivityListener(_handleActivityEvent); + _scheduleOverlayHide(); + updateStreamInfo(); + _initPlayer(); + } + + @action + Future _initPlayer() async { + try { + // Try with auth token for ad-free playback, fall back to anonymous. + final authToken = authStore.isLoggedIn ? authStore.token : null; + late final PlaybackAccessToken token; + try { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + } catch (_) { + if (authToken != null) { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + ); + } else { + rethrow; + } + } + + _hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + + await _controller!.initialize(); + + _pipSub = _controller!.isPipEnabledStream.listen((isPip) { + runInAction(() { + if (isPip && !_isInPipMode) { + _overlayWasVisibleBeforePip = _overlayVisible; + _isInPipMode = true; + _overlayTimer?.cancel(); + _overlayVisible = true; + } else if (!isPip && _isInPipMode) { + _isInPipMode = false; + if (_overlayWasVisibleBeforePip) { + _scheduleOverlayHide(); + } else { + _overlayVisible = false; + } + } + }); + }); + + _qualitiesSub = _controller!.qualitiesStream.listen((qualities) { + runInAction(() { + // Filter out the plugin's auto entry (we add our own) and deduplicate. + final seen = {}; + final filtered = qualities + .where((q) => !q.isAuto && seen.add(q.label)) + .toList(); + + // Reverse so highest quality comes first. + _qualityObjects = filtered.reversed.toList(); + _availableStreamQualities = [ + 'Auto', + ..._qualityObjects.map((q) => q.label), + ]; + + if (_firstTimeSettingQuality && qualities.isNotEmpty) { + _firstTimeSettingQuality = false; + if (settingsStore.defaultToHighestQuality) { + _setStreamQualityIndex(1); + return; + } + SharedPreferences.getInstance().then((prefs) { + final lastQuality = prefs.getString('last_stream_quality'); + if (lastQuality != null) { + setStreamQuality(lastQuality); + } + }); + } + }); + }); + + await _controller!.loadUrl(url: _hlsUrl!); + + runInAction(() { + _error = null; + }); + } catch (e) { + runInAction(() { + _error = + 'Native player failed to load. Try the standard player in Settings.'; + }); + debugPrint('NativeVideoStore init error: $e'); + } + } + + void _handleActivityEvent(PlayerActivityEvent event) { + runInAction(() { + switch (event.state) { + case PlayerActivityState.playing: + _paused = false; + case PlayerActivityState.paused: + case PlayerActivityState.stopped: + case PlayerActivityState.completed: + case PlayerActivityState.idle: + _paused = true; + default: + break; + } + }); + } + + void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) { + _overlayTimer?.cancel(); + + if (_isInPipMode) { + _overlayVisible = true; + return; + } + + _overlayTimer = Timer(delay, () { + if (_isInPipMode) return; + runInAction(() { + _overlayVisible = false; + }); + }); + } + + @override + @action + void handleVideoTap() { + if (_isInPipMode) { + _overlayVisible = true; + return; + } + + _overlayTimer?.cancel(); + + if (_overlayVisible) { + _overlayVisible = false; + } else { + updateStreamInfo(forceUpdate: true); + _overlayVisible = true; + _scheduleOverlayHide(); + } + } + + @override + void handlePausePlay() { + if (_controller == null) return; + if (_paused) { + _controller!.play(); + } else { + _controller!.pause(); + } + } + + @override + @action + void handleToggleOverlay() { + if (settingsStore.toggleableOverlay) { + HapticFeedback.mediumImpact(); + settingsStore.showOverlay = !settingsStore.showOverlay; + + if (settingsStore.showOverlay) { + _overlayVisible = true; + _scheduleOverlayHide(const Duration(seconds: 3)); + } + } + } + + @override + @action + Future handleRefresh() async { + HapticFeedback.lightImpact(); + _paused = true; + _firstTimeSettingQuality = true; + _isInPipMode = false; + _availableStreamQualities = []; + _qualityObjects = []; + _error = null; + + _pipSub?.cancel(); + _qualitiesSub?.cancel(); + _controller?.removeActivityListener(_handleActivityEvent); + _controller?.dispose(); + _controller = NativeVideoPlayerController( + id: _nextId++, + autoPlay: true, + showNativeControls: false, + ); + _controller!.addActivityListener(_handleActivityEvent); + + await _initPlayer(); + updateStreamInfo(); + } + + @override + void requestPictureInPicture() { + _controller?.enterPictureInPicture(); + } + + @override + @action + void togglePictureInPicture() { + if (_isInPipMode) { + _controller?.exitPictureInPicture(); + } else { + _controller?.enterPictureInPicture(); + } + } + + @override + @action + Future updateStreamQualities() async { + // Qualities are populated automatically via qualitiesStream. + } + + @override + @action + Future setStreamQuality(String quality) async { + final index = _availableStreamQualities.indexOf(quality); + if (index == -1) return; + await _setStreamQualityIndex(index); + } + + @action + Future _setStreamQualityIndex(int index) async { + _streamQualityIndex = index; + if (_controller == null) return; + + if (index == 0) { + // 'Auto' — reset to adaptive quality + await _controller!.setQuality(NativeVideoPlayerQuality.auto()); + } else { + final qualityIndex = index - 1; + if (qualityIndex < _qualityObjects.length) { + await _controller!.setQuality(_qualityObjects[qualityIndex]); + } + } + } + + @override + @action + Future updateStreamInfo({bool forceUpdate = false}) async { + if (_streamInfoRequest != null) { + await _streamInfoRequest; + return; + } + + final now = DateTime.now(); + if (!forceUpdate && _lastStreamInfoUpdate != null) { + final timeSince = now.difference(_lastStreamInfoUpdate!); + if (timeSince.inSeconds < 5) return; + } + + _lastStreamInfoUpdate = now; + + final request = _updateStreamInfoInternal(); + _streamInfoRequest = request; + + try { + await request; + } finally { + if (identical(_streamInfoRequest, request)) { + _streamInfoRequest = null; + } + } + } + + Future _updateStreamInfoInternal() async { + try { + _streamInfo = await twitchApi.getStream(userLogin: userLogin); + _offlineChannelInfo = null; + } catch (e) { + _overlayTimer?.cancel(); + _streamInfo = null; + _paused = true; + + try { + _offlineChannelInfo = await twitchApi.getChannel(userId: userId); + } catch (_) { + _offlineChannelInfo = null; + } + } + } + + @override + @action + void handleAppResume() { + if (!settingsStore.showVideo) { + updateStreamInfo(forceUpdate: true); + } + } + + @override + @action + void dispose() { + _overlayTimer?.cancel(); + _streamInfoTimer?.cancel(); + _pipSub?.cancel(); + _qualitiesSub?.cancel(); + + _controller?.removeActivityListener(_handleActivityEvent); + _controller?.dispose(); + _controller = null; + } +} diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart new file mode 100644 index 00000000..1e074f13 --- /dev/null +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -0,0 +1,355 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'native_video_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$NativeVideoStore on NativeVideoStoreBase, Store { + late final _$_controllerAtom = Atom( + name: 'NativeVideoStoreBase._controller', + context: context, + ); + + NativeVideoPlayerController? get controller { + _$_controllerAtom.reportRead(); + return super._controller; + } + + @override + NativeVideoPlayerController? get _controller => controller; + + @override + set _controller(NativeVideoPlayerController? value) { + _$_controllerAtom.reportWrite(value, super._controller, () { + super._controller = value; + }); + } + + late final _$_pausedAtom = Atom( + name: 'NativeVideoStoreBase._paused', + context: context, + ); + + bool get paused { + _$_pausedAtom.reportRead(); + return super._paused; + } + + @override + bool get _paused => paused; + + @override + set _paused(bool value) { + _$_pausedAtom.reportWrite(value, super._paused, () { + super._paused = value; + }); + } + + late final _$_overlayVisibleAtom = Atom( + name: 'NativeVideoStoreBase._overlayVisible', + context: context, + ); + + bool get overlayVisible { + _$_overlayVisibleAtom.reportRead(); + return super._overlayVisible; + } + + @override + bool get _overlayVisible => overlayVisible; + + @override + set _overlayVisible(bool value) { + _$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () { + super._overlayVisible = value; + }); + } + + late final _$_streamInfoAtom = Atom( + name: 'NativeVideoStoreBase._streamInfo', + context: context, + ); + + StreamTwitch? get streamInfo { + _$_streamInfoAtom.reportRead(); + return super._streamInfo; + } + + @override + StreamTwitch? get _streamInfo => streamInfo; + + @override + set _streamInfo(StreamTwitch? value) { + _$_streamInfoAtom.reportWrite(value, super._streamInfo, () { + super._streamInfo = value; + }); + } + + late final _$_offlineChannelInfoAtom = Atom( + name: 'NativeVideoStoreBase._offlineChannelInfo', + context: context, + ); + + Channel? get offlineChannelInfo { + _$_offlineChannelInfoAtom.reportRead(); + return super._offlineChannelInfo; + } + + @override + Channel? get _offlineChannelInfo => offlineChannelInfo; + + @override + set _offlineChannelInfo(Channel? value) { + _$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () { + super._offlineChannelInfo = value; + }); + } + + late final _$_availableStreamQualitiesAtom = Atom( + name: 'NativeVideoStoreBase._availableStreamQualities', + context: context, + ); + + List get availableStreamQualities { + _$_availableStreamQualitiesAtom.reportRead(); + return super._availableStreamQualities; + } + + @override + List get _availableStreamQualities => availableStreamQualities; + + @override + set _availableStreamQualities(List value) { + _$_availableStreamQualitiesAtom.reportWrite( + value, + super._availableStreamQualities, + () { + super._availableStreamQualities = value; + }, + ); + } + + late final _$_streamQualityIndexAtom = Atom( + name: 'NativeVideoStoreBase._streamQualityIndex', + context: context, + ); + + int get streamQualityIndex { + _$_streamQualityIndexAtom.reportRead(); + return super._streamQualityIndex; + } + + @override + int get _streamQualityIndex => streamQualityIndex; + + @override + set _streamQualityIndex(int value) { + _$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () { + super._streamQualityIndex = value; + }); + } + + late final _$_latencyAtom = Atom( + name: 'NativeVideoStoreBase._latency', + context: context, + ); + + String? get latency { + _$_latencyAtom.reportRead(); + return super._latency; + } + + @override + String? get _latency => latency; + + @override + set _latency(String? value) { + _$_latencyAtom.reportWrite(value, super._latency, () { + super._latency = value; + }); + } + + late final _$_isInPipModeAtom = Atom( + name: 'NativeVideoStoreBase._isInPipMode', + context: context, + ); + + bool get isInPipMode { + _$_isInPipModeAtom.reportRead(); + return super._isInPipMode; + } + + @override + bool get _isInPipMode => isInPipMode; + + @override + set _isInPipMode(bool value) { + _$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () { + super._isInPipMode = value; + }); + } + + late final _$_errorAtom = Atom( + name: 'NativeVideoStoreBase._error', + context: context, + ); + + String? get error { + _$_errorAtom.reportRead(); + return super._error; + } + + @override + String? get _error => error; + + @override + set _error(String? value) { + _$_errorAtom.reportWrite(value, super._error, () { + super._error = value; + }); + } + + late final _$_initPlayerAsyncAction = AsyncAction( + 'NativeVideoStoreBase._initPlayer', + context: context, + ); + + @override + Future _initPlayer() { + return _$_initPlayerAsyncAction.run(() => super._initPlayer()); + } + + late final _$handleRefreshAsyncAction = AsyncAction( + 'NativeVideoStoreBase.handleRefresh', + context: context, + ); + + @override + Future handleRefresh() { + return _$handleRefreshAsyncAction.run(() => super.handleRefresh()); + } + + late final _$updateStreamQualitiesAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamQualities', + context: context, + ); + + @override + Future updateStreamQualities() { + return _$updateStreamQualitiesAsyncAction.run( + () => super.updateStreamQualities(), + ); + } + + late final _$setStreamQualityAsyncAction = AsyncAction( + 'NativeVideoStoreBase.setStreamQuality', + context: context, + ); + + @override + Future setStreamQuality(String quality) { + return _$setStreamQualityAsyncAction.run( + () => super.setStreamQuality(quality), + ); + } + + late final _$_setStreamQualityIndexAsyncAction = AsyncAction( + 'NativeVideoStoreBase._setStreamQualityIndex', + context: context, + ); + + @override + Future _setStreamQualityIndex(int index) { + return _$_setStreamQualityIndexAsyncAction.run( + () => super._setStreamQualityIndex(index), + ); + } + + late final _$updateStreamInfoAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamInfo', + context: context, + ); + + @override + Future updateStreamInfo({bool forceUpdate = false}) { + return _$updateStreamInfoAsyncAction.run( + () => super.updateStreamInfo(forceUpdate: forceUpdate), + ); + } + + late final _$NativeVideoStoreBaseActionController = ActionController( + name: 'NativeVideoStoreBase', + context: context, + ); + + @override + void handleVideoTap() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleVideoTap', + ); + try { + return super.handleVideoTap(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleToggleOverlay() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleToggleOverlay', + ); + try { + return super.handleToggleOverlay(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void togglePictureInPicture() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.togglePictureInPicture', + ); + try { + return super.togglePictureInPicture(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleAppResume() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleAppResume', + ); + try { + return super.handleAppResume(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void dispose() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.dispose', + ); + try { + return super.dispose(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' + + '''; + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 62fb13b3..bef13dbe 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -5,8 +5,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; -import 'package:frosty/screens/channel/video/video_store.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; @@ -21,7 +22,7 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Creates a widget containing controls which enable interactions with an underlying [Video] widget. class VideoOverlay extends StatelessWidget { - final VideoStore videoStore; + final VideoPlayerInterface videoStore; final ChatStore chatStore; final SettingsStore settingsStore; @@ -344,7 +345,10 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && context.isLandscape) chatOverlayButton, - if (!Platform.isIOS || isIPad()) videoSettingsButton, + if (!Platform.isIOS || + isIPad() || + videoStore is NativeVideoStore) + videoSettingsButton, ], ), Center( diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart new file mode 100644 index 00000000..dcdcc662 --- /dev/null +++ b/lib/screens/channel/video/video_player_interface.dart @@ -0,0 +1,30 @@ +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +/// Abstraction over the video player store so both [VideoStore] (WebView) +/// and [NativeVideoStore] can drive the same overlay and channel UI. +abstract class VideoPlayerInterface { + SettingsStore get settingsStore; + + bool get paused; + bool get overlayVisible; + bool get isInPipMode; + StreamTwitch? get streamInfo; + Channel? get offlineChannelInfo; + List get availableStreamQualities; + String get streamQuality; + String? get latency; + + void handleVideoTap(); + void handlePausePlay(); + void handleToggleOverlay(); + Future handleRefresh(); + void requestPictureInPicture(); + void togglePictureInPicture(); + Future updateStreamQualities(); + Future setStreamQuality(String quality); + Future updateStreamInfo({bool forceUpdate}); + void handleAppResume(); + void dispose(); +} diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 07889964..21032972 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/channel.dart'; 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:mobx/mobx.dart'; @@ -20,7 +21,7 @@ part 'video_store.g.dart'; class VideoStore = VideoStoreBase with _$VideoStore; -abstract class VideoStoreBase with Store { +abstract class VideoStoreBase with Store implements VideoPlayerInterface { final TwitchApi twitchApi; /// The userlogin of the current channel. @@ -31,6 +32,7 @@ abstract class VideoStoreBase with Store { final AuthStore authStore; + @override final SettingsStore settingsStore; /// The [SimplePip] instance used for initiating PiP on Android. @@ -195,6 +197,7 @@ abstract class VideoStoreBase with Store { int _streamQualityIndex = 0; // The current stream quality string + @override String get streamQuality => _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; @@ -343,6 +346,7 @@ abstract class VideoStoreBase with Store { ); } + @override @action Future updateStreamQualities() async { try { @@ -382,6 +386,7 @@ abstract class VideoStoreBase with Store { } } + @override @action Future setStreamQuality(String newStreamQuality) async { final indexOfStreamQuality = _availableStreamQualities.indexOf( @@ -842,6 +847,7 @@ abstract class VideoStoreBase with Store { } /// Called whenever the video/overlay is tapped. + @override @action void handleVideoTap() { if (_isInPipMode) { @@ -918,6 +924,7 @@ abstract class VideoStoreBase with Store { } /// Handles app resume event for immediate stream info refresh in chat-only mode. + @override @action void handleAppResume() { // Only refresh immediately in chat-only mode @@ -930,6 +937,7 @@ abstract class VideoStoreBase with Store { /// /// If the stream is offline, fetches channel information to show offline details. /// Set [forceUpdate] to true to bypass the rate limiting check. + @override @action Future updateStreamInfo({bool forceUpdate = false}) async { if (_streamInfoRequest != null) { @@ -987,6 +995,7 @@ abstract class VideoStoreBase with Store { /// Handles the toggle overlay options. /// /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. + @override @action void handleToggleOverlay() { if (settingsStore.toggleableOverlay) { @@ -1004,6 +1013,7 @@ abstract class VideoStoreBase with Store { } /// Refreshes the stream webview and updates the stream info. + @override @action Future handleRefresh() async { HapticFeedback.lightImpact(); @@ -1028,6 +1038,7 @@ abstract class VideoStoreBase with Store { } /// Play or pause the video depending on the current state of [_paused]. + @override void handlePausePlay() { try { if (_paused) { @@ -1048,6 +1059,7 @@ abstract class VideoStoreBase with Store { /// /// On Android, this will utilize the native Android PiP API. /// On iOS, this will utilize the web picture-in-picture API. + @override void requestPictureInPicture() { try { if (Platform.isAndroid) { @@ -1067,6 +1079,7 @@ abstract class VideoStoreBase with Store { /// If not in PiP mode, enters PiP mode. /// If already in PiP mode on iOS, exits PiP mode. /// On Android, always enters PiP mode (no programmatic exit or state tracking). + @override @action void togglePictureInPicture() { try { @@ -1088,6 +1101,7 @@ abstract class VideoStoreBase with Store { } } + @override @action void dispose() { // Disable auto PiP when leaving so that we don't enter PiP on other screens. diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 0ffe4c16..f35d0741 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -68,6 +68,8 @@ abstract class _SettingsStoreBase with Store { static const defaultKeepScreenAwake = true; // Overlay defaults + static const defaultUseNativePlayer = false; + static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; static const defaultShowLatency = false; @@ -89,6 +91,10 @@ abstract class _SettingsStoreBase with Store { @observable var keepScreenAwake = defaultKeepScreenAwake; + @JsonKey(defaultValue: defaultUseNativePlayer) + @observable + var useNativePlayer = defaultUseNativePlayer; + // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @observable @@ -109,6 +115,8 @@ abstract class _SettingsStoreBase with Store { useTextureRendering = defaultUseTextureRendering; keepScreenAwake = defaultKeepScreenAwake; + useNativePlayer = defaultUseNativePlayer; + showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; showLatency = defaultShowLatency; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 1955c3f5..a5699705 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson( ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true + ..useNativePlayer = json['useNativePlayer'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -102,6 +103,7 @@ Map _$SettingsStoreToJson( 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, 'keepScreenAwake': instance.keepScreenAwake, + 'useNativePlayer': instance.useNativePlayer, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -343,6 +345,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$useNativePlayerAtom = Atom( + name: '_SettingsStoreBase.useNativePlayer', + context: context, + ); + + @override + bool get useNativePlayer { + _$useNativePlayerAtom.reportRead(); + return super.useNativePlayer; + } + + @override + set useNativePlayer(bool value) { + _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () { + super.useNativePlayer = value; + }); + } + late final _$showOverlayAtom = Atom( name: '_SettingsStoreBase.showOverlay', context: context, @@ -1166,6 +1186,7 @@ showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, keepScreenAwake: ${keepScreenAwake}, +useNativePlayer: ${useNativePlayer}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index d6b2b72b..e54fec02 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -24,6 +24,16 @@ class VideoSettings extends StatelessWidget { value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), + if (Platform.isIOS && settingsStore.showVideo) + SettingsListSwitch( + title: 'Native player (experimental)', + subtitle: const Text( + 'Uses a native video player with improved picture-in-picture.', + ), + value: settingsStore.useNativePlayer, + onChanged: (newValue) => + settingsStore.useNativePlayer = newValue, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', diff --git a/lib/utils/context_extensions.dart b/lib/utils/context_extensions.dart index f9cf83bf..f8a5c1df 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/stores/global_assets_store.dart'; @@ -71,6 +72,9 @@ extension ContextExtensions on BuildContext { /// Gets TwitchApi from provider TwitchApi get twitchApi => read(); + /// Gets TwitchGqlApi from provider + TwitchGqlApi get twitchGqlApi => read(); + /// Gets AuthStore from provider AuthStore get authStore => read(); diff --git a/pubspec.lock b/pubspec.lock index 6eb900ef..ca382203 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + better_native_video_player: + dependency: "direct main" + description: + name: better_native_video_player + sha256: ba3771b064d0369270ced87a0e1319b376d96740ed7e94804f500b1578729bbb + url: "https://pub.dev" + source: hosted + version: "0.4.11" boolean_selector: dependency: transitive description: @@ -313,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + dismissible_page: + dependency: transitive + description: + name: dismissible_page + sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958" + url: "https://pub.dev" + source: hosted + version: "1.0.2" extended_text_field: dependency: "direct main" description: @@ -449,6 +465,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + floating: + dependency: transitive + description: + name: floating + sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3be38265..67b2662b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ 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 dev_dependencies: flutter_test: From 1779520db8fa034b501bf041e9f30f1020afd8fb Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 16:28:50 -0700 Subject: [PATCH 02/28] 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 --- ios/Runner.xcodeproj/project.pbxproj | 4 ++ ios/Runner/AppDelegate.swift | 1 + ios/Runner/CookieExtractorPlugin.swift | 26 +++++++ .../channel/video/native_video_store.dart | 68 +++++++++++++++---- lib/screens/settings/stores/auth_store.dart | 31 ++++++++- lib/screens/settings/stores/auth_store.g.dart | 20 ++++++ lib/services/cookie_extractor.dart | 12 ++++ pubspec.lock | 9 +-- pubspec.yaml | 5 +- 9 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 ios/Runner/CookieExtractorPlugin.swift create mode 100644 lib/services/cookie_extractor.dart 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: From 418c51ce6d8d67268fde6985df522d51829fd3c3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 16:42:39 -0700 Subject: [PATCH 03/28] handle offline channels gracefully in native video player Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/native_video.dart | 16 +++++++++++++++- .../channel/video/native_video_store.dart | 10 ++++++++-- lib/screens/channel/video/video_overlay.dart | 1 - 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart index cbffca9c..546dbc17 100644 --- a/lib/screens/channel/video/native_video.dart +++ b/lib/screens/channel/video/native_video.dart @@ -17,11 +17,14 @@ class NativeVideo extends StatelessWidget { final controller = nativeVideoStore.controller; final error = nativeVideoStore.error; + final isOffline = + nativeVideoStore.streamInfo == null && error == null; + return ColoredBox( color: Colors.black, child: Stack( children: [ - if (controller != null) + if (controller != null && !isOffline) NativeVideoPlayer( key: ObjectKey(controller), controller: controller, @@ -37,6 +40,17 @@ class NativeVideo extends StatelessWidget { ), ), ), + if (isOffline) + const Center( + child: Text( + 'Offline', + style: TextStyle( + color: Colors.white38, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), ], ), ); diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 44901993..9f7928b0 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -185,9 +185,15 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _error = null; }); } catch (e) { + // Wait for stream info so we can distinguish "offline" from "broken". + await updateStreamInfo(); runInAction(() { - _error = - 'Native player failed to load. Try the standard player in Settings.'; + // Only show the error if the stream is actually live — an offline + // channel is expected to fail here and the overlay handles it. + if (_streamInfo != null) { + _error = + 'Native player failed to load. Try the standard player in Settings.'; + } }); debugPrint('NativeVideoStore init error: $e'); } diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index bef13dbe..3ba00545 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -260,7 +260,6 @@ class VideoOverlay extends StatelessWidget { displayName: chatStore.displayName, showUptime: false, showViewerCount: false, - showOfflineIndicator: false, textColor: surfaceColor, isOffline: true, isInSharedChatMode: From 6e5da0bae032ccbe034c84ad0a3deeb4a473dc26 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 17:05:20 -0700 Subject: [PATCH 04/28] optimize native player with immediate latency, auto seek-to-live-edge, and app resume recovery Co-Authored-By: Claude Opus 4.6 --- .../channel/video/native_video_store.dart | 51 +++++++++++-------- pubspec.lock | 2 +- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 9f7928b0..e0ebf2c9 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -225,29 +225,37 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { if (settingsStore.autoSyncChatDelay) { settingsStore.syncedChatDelay = 0; } + _pollLatency(); _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; - } - }, + (_) => _pollLatency(), ); } + Future _pollLatency() async { + final seconds = await _controller?.getLatencyToLive(); + if (seconds == null) return; + final rounded = seconds.round(); + + runInAction(() { + _latency = '${rounded}s'; + }); + + // Auto seek to live edge if we've drifted too far. + if (seconds > 15) { + await _controller?.seekToLiveEdge(); + } + + 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(); @@ -428,9 +436,10 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @override @action void handleAppResume() { - if (!settingsStore.showVideo) { - updateStreamInfo(forceUpdate: true); - } + updateStreamInfo(forceUpdate: true); + // Re-check latency immediately — the stream may have drifted or died + // while backgrounded. If latency > 15s, _pollLatency auto-seeks to live edge. + _pollLatency(); } @override diff --git a/pubspec.lock b/pubspec.lock index 99037401..39ce1e56 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: e16914aff5ea8a0f5bc038eb706764738f2a99c9 + resolved-ref: "90b262e9f0d5df797918128e9a277daab57bc814" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From b2039e1cd8753a9effbf0b4944780e4baa87357e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 17:20:05 -0700 Subject: [PATCH 05/28] update fork dependency to fix empty switch case build error Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index 39ce1e56..05f7a872 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "90b262e9f0d5df797918128e9a277daab57bc814" + resolved-ref: "1a1330c815f09f1b3ff2ca11442276315f12030c" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From f27de6dc3dcc829f914b889cf7d5d2c9df04b19d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 17:29:11 -0700 Subject: [PATCH 06/28] remove auto seek-to-live-edge to fix random pausing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seekToLiveEdge call was firing too aggressively — on initial load latency reports inflated values before the player stabilizes, and each seek interrupts playback. The refresh button handles manual recovery if the viewer falls behind. Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/native_video_store.dart | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index e0ebf2c9..0d5a29bd 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -241,11 +241,6 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _latency = '${rounded}s'; }); - // Auto seek to live edge if we've drifted too far. - if (seconds > 15) { - await _controller?.seekToLiveEdge(); - } - if (!settingsStore.autoSyncChatDelay) return; // Only update when unset or drifted by >2s to avoid restarting @@ -437,9 +432,6 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action void handleAppResume() { updateStreamInfo(forceUpdate: true); - // Re-check latency immediately — the stream may have drifted or died - // while backgrounded. If latency > 15s, _pollLatency auto-seeks to live edge. - _pollLatency(); } @override From d0488754906fd97c2158103104cbb3e8a585f41b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 18:40:43 -0700 Subject: [PATCH 07/28] add web session indicator to profile card, clean up cookie extraction Co-Authored-By: Claude Opus 4.6 --- .../channel/video/native_video_store.dart | 7 +-- .../account/widgets/profile_card.dart | 52 ++++++++++++++++++- lib/screens/settings/stores/auth_store.dart | 3 -- lib/screens/settings/video_settings.dart | 2 +- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 0d5a29bd..0ef29efd 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -11,7 +11,6 @@ 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'; @@ -102,10 +101,8 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action Future _initPlayer() async { try { - // 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(); + // Use web cookie token (works with web Client-ID) for ad-free playback. + final authToken = authStore.gqlToken; late final PlaybackAccessToken token; try { token = await twitchGqlApi.getPlaybackAccessToken( diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 7a55c3a5..169c2c71 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -39,13 +39,63 @@ class ProfileCard extends StatelessWidget { ); } if (authStore.isLoggedIn && authStore.user.details != null) { + final hasToken = authStore.gqlToken != null; return ListTile( leading: ProfilePicture( userLogin: authStore.user.details!.login, radius: 12, ), title: Text(authStore.user.details!.displayName), - trailing: const Icon(Icons.chevron_right_rounded), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: hasToken + ? 'Web session linked' + : 'Web session not linked', + icon: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.link_off_rounded, + size: 20, + color: hasToken ? Colors.green : null, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Web session'), + content: Text( + hasToken + ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' + : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', + ), + actions: [ + if (!hasToken) + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginWebView(), + ), + ); + }, + child: const Text('Log in'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + ), + const Icon(Icons.chevron_right_rounded), + ], + ), onTap: () => _showAccountOptionsModalBottomSheet(context), ); } diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index a302f7a8..2f985eab 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -275,9 +275,6 @@ 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) { diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index e54fec02..f7928a36 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -28,7 +28,7 @@ class VideoSettings extends StatelessWidget { SettingsListSwitch( title: 'Native player (experimental)', subtitle: const Text( - 'Uses a native video player with improved picture-in-picture.', + 'Native video player with auto picture-in-picture and quality selection.', ), value: settingsStore.useNativePlayer, onChanged: (newValue) => From 8d5ccb699e41d347bd9365c50181ed00ebe84f81 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 19:42:53 -0700 Subject: [PATCH 08/28] enable native video player on Android Co-Authored-By: Claude Opus 4.6 --- .../tommychow/frosty/CookieExtractorPlugin.kt | 32 +++++++++++++++++++ .../com/tommychow/frosty/MainActivity.kt | 5 +++ lib/screens/channel/channel.dart | 2 +- .../channel/video/native_video_store.dart | 32 +++++++++++++++++-- lib/screens/settings/video_settings.dart | 7 ++-- lib/services/cookie_extractor.dart | 3 -- pubspec.lock | 2 +- 7 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -0,0 +1,32 @@ +package com.tommychow.frosty + +import android.webkit.CookieManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "extractTwitchAuthToken") { + val cookies = CookieManager.getInstance().getCookie("https://twitch.tv") + val authToken = cookies?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.startsWith("auth-token=") } + ?.substringAfter("auth-token=") + result.success(authToken) + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(CookieExtractorPlugin()) + } } diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 468dca06..a3bd16d4 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -79,7 +79,7 @@ class _VideoChatState extends State ); late final VideoPlayerInterface _videoStore = - Platform.isIOS && context.settingsStore.useNativePlayer + context.settingsStore.useNativePlayer ? NativeVideoStore( userLogin: widget.userLogin, userId: widget.userId, diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 0ef29efd..da830918 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:better_native_video_player/better_native_video_player.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:simple_pip_mode/simple_pip.dart'; part 'native_video_store.g.dart'; @@ -43,6 +45,9 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { var _firstTimeSettingQuality = true; int? _pendingQualityIndex; + final _pip = SimplePip(); + ReactionDisposer? _disposeAndroidAutoPipReaction; + StreamSubscription? _pipSub; StreamSubscription>? _qualitiesSub; @@ -96,6 +101,16 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _scheduleOverlayHide(); updateStreamInfo(); _initPlayer(); + + if (Platform.isAndroid) { + _disposeAndroidAutoPipReaction = autorun((_) async { + if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) { + _pip.setAutoPipMode(); + } else { + _pip.setAutoPipMode(autoEnter: false); + } + }); + } } @action @@ -337,16 +352,20 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @override void requestPictureInPicture() { - _controller?.enterPictureInPicture(); + if (Platform.isAndroid) { + _pip.enterPipMode(autoEnter: true); + } else { + _controller?.enterPictureInPicture(); + } } @override @action void togglePictureInPicture() { - if (_isInPipMode) { + if (Platform.isIOS && _isInPipMode) { _controller?.exitPictureInPicture(); } else { - _controller?.enterPictureInPicture(); + requestPictureInPicture(); } } @@ -434,10 +453,17 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @override @action void dispose() { + if (Platform.isAndroid) { + SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) { + if (isAutoPipAvailable) _pip.setAutoPipMode(autoEnter: false); + }); + } + _overlayTimer?.cancel(); _latencyTimer?.cancel(); _pipSub?.cancel(); _qualitiesSub?.cancel(); + _disposeAndroidAutoPipReaction?.call(); _controller?.removeActivityListener(_handleActivityEvent); _controller?.dispose(); diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index f7928a36..714694d9 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -24,11 +24,11 @@ class VideoSettings extends StatelessWidget { value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), - if (Platform.isIOS && settingsStore.showVideo) + if (settingsStore.showVideo) SettingsListSwitch( title: 'Native player (experimental)', subtitle: const Text( - 'Native video player with auto picture-in-picture and quality selection.', + 'More performant video player with auto picture-in-picture and quality selection.', ), value: settingsStore.useNativePlayer, onChanged: (newValue) => @@ -74,7 +74,8 @@ class VideoSettings extends StatelessWidget { 'Allows switching between Twitch\'s overlay and the custom overlay.', ), value: settingsStore.toggleableOverlay, - onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, + onChanged: (newValue) => + settingsStore.toggleableOverlay = newValue, ), SettingsListSwitch( title: 'Show latency', diff --git a/lib/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart index 75315a1e..cca2b028 100644 --- a/lib/services/cookie_extractor.dart +++ b/lib/services/cookie_extractor.dart @@ -1,12 +1,9 @@ -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 05f7a872..397a0565 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "1a1330c815f09f1b3ff2ca11442276315f12030c" + resolved-ref: "72ef59c95ef282029c2ac6d0bd782f153f2fd9ef" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 649710c7585675995c34822ab383af3446774eee Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 20:18:03 -0700 Subject: [PATCH 09/28] show loading spinner instead of flashing offline/play button on video load Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/channel.dart | 24 +++++++++++++++++++ lib/screens/channel/video/native_video.dart | 4 +++- .../channel/video/native_video_store.dart | 6 +++++ .../channel/video/native_video_store.g.dart | 20 ++++++++++++++++ .../channel/video/video_player_interface.dart | 1 + lib/screens/channel/video/video_store.dart | 6 +++++ lib/screens/channel/video/video_store.g.dart | 20 ++++++++++++++++ 7 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index a3bd16d4..b8e6ad32 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -336,6 +336,10 @@ class _VideoChatState extends State }, child: Observer( builder: (_) { + if (_videoStore.loading) { + return const SizedBox.shrink(); + } + final videoOverlay = VideoOverlay( videoStore: _videoStore, chatStore: _chatStore, @@ -367,6 +371,26 @@ class _VideoChatState extends State return Stack( children: [ player, + AnimatedOpacity( + opacity: _videoStore.loading ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + child: const IgnorePointer( + child: ColoredBox( + color: Colors.black, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white38, + strokeWidth: 2, + ), + ), + ), + ), + ), + ), if (_videoStore.settingsStore.showOverlay) overlay, ], ); diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart index 546dbc17..c8ddbcd0 100644 --- a/lib/screens/channel/video/native_video.dart +++ b/lib/screens/channel/video/native_video.dart @@ -18,7 +18,9 @@ class NativeVideo extends StatelessWidget { final error = nativeVideoStore.error; final isOffline = - nativeVideoStore.streamInfo == null && error == null; + !nativeVideoStore.loading && + nativeVideoStore.streamInfo == null && + error == null; return ColoredBox( color: Colors.black, diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index da830918..f9feddce 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -51,6 +51,9 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { StreamSubscription? _pipSub; StreamSubscription>? _qualitiesSub; + @readonly + var _loading = true; + @readonly var _paused = true; @@ -200,6 +203,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { // Wait for stream info so we can distinguish "offline" from "broken". await updateStreamInfo(); runInAction(() { + _loading = false; // Only show the error if the stream is actually live — an offline // channel is expected to fail here and the overlay handles it. if (_streamInfo != null) { @@ -215,6 +219,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { runInAction(() { switch (event.state) { case PlayerActivityState.playing: + _loading = false; _paused = false; if (_pendingQualityIndex != null) { final index = _pendingQualityIndex!; @@ -326,6 +331,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _loading = true; _paused = true; _firstTimeSettingQuality = true; _pendingQualityIndex = null; diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index 1e074f13..a92378f0 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -29,6 +29,26 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { }); } + late final _$_loadingAtom = Atom( + name: 'NativeVideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + late final _$_pausedAtom = Atom( name: 'NativeVideoStoreBase._paused', context: context, diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart index dcdcc662..1f2d24ba 100644 --- a/lib/screens/channel/video/video_player_interface.dart +++ b/lib/screens/channel/video/video_player_interface.dart @@ -7,6 +7,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; abstract class VideoPlayerInterface { SettingsStore get settingsStore; + bool get loading; bool get paused; bool get overlayVisible; bool get isInPipMode; diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 21032972..9634df86 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -104,6 +104,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { ..addJavaScriptChannel( 'VideoPlaying', onMessageReceived: (message) { + _loading = false; _paused = false; if (Platform.isAndroid) pip.setIsPlaying(true); }, @@ -171,6 +172,9 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Disposes the latency settings reaction. ReactionDisposer? _disposeLatencySettingsReaction; + @readonly + var _loading = true; + /// If the video is currently paused. /// /// Does not pause or play the video, only used for rendering state of the overlay. @@ -975,6 +979,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { _offlineChannelInfo = null; } catch (e) { _overlayTimer?.cancel(); + _loading = false; _streamInfo = null; _paused = true; @@ -1017,6 +1022,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _loading = true; _paused = true; _firstTimeSettingQuality = true; _isInPipMode = false; diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 30c79efc..c12540e5 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,6 +9,26 @@ part of 'video_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$VideoStore on VideoStoreBase, Store { + late final _$_loadingAtom = Atom( + name: 'VideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + late final _$_pausedAtom = Atom( name: 'VideoStoreBase._paused', context: context, From a69eb5ddfade964bba612bdf51c88ed26b0bd48c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:05:02 -0700 Subject: [PATCH 10/28] update fork with tuned live playback and reverted iOS seek Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index 397a0565..742f596e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "72ef59c95ef282029c2ac6d0bd782f153f2fd9ef" + resolved-ref: d2f51433a5723fa48c7be6a79dba0981110ac67d url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From adb166ff920689821d1e4c2349e095eba0bfab64 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:08:12 -0700 Subject: [PATCH 11/28] hide iOS player placeholder logo via black background Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index 742f596e..b558ce3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: d2f51433a5723fa48c7be6a79dba0981110ac67d + resolved-ref: "30348af9f778c99f1f886274e75d740233efc179" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 97d371c0e1f9a1befa3b1ec9a17c6ce0d3ff8f99 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:12:21 -0700 Subject: [PATCH 12/28] update fork with media3 1.9.2 Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index b558ce3c..a011b611 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "30348af9f778c99f1f886274e75d740233efc179" + resolved-ref: "87ebdbc8c0a716e00042dbe4ab767a7688c67206" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From cb79dfd2339489a63180044a531394e88f10ab4c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:25:28 -0700 Subject: [PATCH 13/28] update fork with auto quality fix and LiveConfiguration on quality switch Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index a011b611..1f4357c6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "87ebdbc8c0a716e00042dbe4ab767a7688c67206" + resolved-ref: "8d770ac28dfe76a0d6adf521daee138253e4ea89" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 144c5e3f1f5800c2b39c86d5a5affc8e59f1dc01 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:31:47 -0700 Subject: [PATCH 14/28] fix stream info mutations outside MobX action context Co-Authored-By: Claude Opus 4.6 --- .../channel/video/native_video_store.dart | 27 +++++++++++-------- pubspec.lock | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index f9feddce..ae21c771 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -435,18 +435,23 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { Future _updateStreamInfoInternal() async { try { - _streamInfo = await twitchApi.getStream(userLogin: userLogin); - _offlineChannelInfo = null; - } catch (e) { - _overlayTimer?.cancel(); - _streamInfo = null; - _paused = true; - - try { - _offlineChannelInfo = await twitchApi.getChannel(userId: userId); - } catch (_) { + final info = await twitchApi.getStream(userLogin: userLogin); + runInAction(() { + _streamInfo = info; _offlineChannelInfo = null; - } + }); + } catch (e) { + Channel? channel; + try { + channel = await twitchApi.getChannel(userId: userId); + } catch (_) {} + + runInAction(() { + _overlayTimer?.cancel(); + _streamInfo = null; + _paused = true; + _offlineChannelInfo = channel; + }); } } diff --git a/pubspec.lock b/pubspec.lock index 1f4357c6..d4a5fb57 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "8d770ac28dfe76a0d6adf521daee138253e4ea89" + resolved-ref: cc173d8329f79f12960dd7280ef2709496ff0ef0 url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From e7c726daeb5af72e194e87d01aedc9046c7f5b25 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:40:52 -0700 Subject: [PATCH 15/28] guard async callbacks against post-dispose use Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/native_video_store.dart | 8 ++++++++ pubspec.lock | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index ae21c771..d724f3bf 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -44,6 +44,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { List _qualityObjects = []; var _firstTimeSettingQuality = true; int? _pendingQualityIndex; + var _disposed = false; final _pip = SimplePip(); ReactionDisposer? _disposeAndroidAutoPipReaction; @@ -137,9 +138,12 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { } } + if (_disposed) return; + _hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); await _controller!.initialize(); + if (_disposed) return; _pipSub = _controller!.isPipEnabledStream.listen((isPip) { runInAction(() { @@ -180,6 +184,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _pendingQualityIndex = 1; } else { SharedPreferences.getInstance().then((prefs) { + if (_disposed) return; final lastQuality = prefs.getString('last_stream_quality'); if (lastQuality != null) { final index = _availableStreamQualities.indexOf(lastQuality); @@ -331,6 +336,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _disposed = false; _loading = true; _paused = true; _firstTimeSettingQuality = true; @@ -464,6 +470,8 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @override @action void dispose() { + _disposed = true; + if (Platform.isAndroid) { SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) { if (isAutoPipAvailable) _pip.setAutoPipMode(autoEnter: false); diff --git a/pubspec.lock b/pubspec.lock index d4a5fb57..a7970c94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: cc173d8329f79f12960dd7280ef2709496ff0ef0 + resolved-ref: "6795a21905584cd8a04fee2834175d84e70e8ab6" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From d01ee26fca85ec0703e458018660d60c20e202bb Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:43:20 -0700 Subject: [PATCH 16/28] revert iOS latency measurement to wall-clock approach Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index a7970c94..b277f7cb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "6795a21905584cd8a04fee2834175d84e70e8ab6" + resolved-ref: "5516ee133c633dcc18f568fde23248cf4439c5eb" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 9529438a3f15692e569af5cb9374af1f29dc1480 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 21:45:11 -0700 Subject: [PATCH 17/28] add disposed guards to error path in _initPlayer Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/native_video_store.dart | 2 ++ pubspec.lock | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index d724f3bf..2f2f4244 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -205,8 +205,10 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _error = null; }); } catch (e) { + if (_disposed) return; // Wait for stream info so we can distinguish "offline" from "broken". await updateStreamInfo(); + if (_disposed) return; runInAction(() { _loading = false; // Only show the error if the stream is actually live — an offline diff --git a/pubspec.lock b/pubspec.lock index b277f7cb..898cb8d2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "5516ee133c633dcc18f568fde23248cf4439c5eb" + resolved-ref: "92b2c025b368225c4ee75db567f561be78c5ce45" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 8b8b1903167a8d107f4777c1cfd0098a047a60b3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 22:32:41 -0700 Subject: [PATCH 18/28] fix video loading spinner not covering full player area Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/channel.dart | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index b8e6ad32..f0a75617 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -371,20 +371,22 @@ class _VideoChatState extends State return Stack( children: [ player, - AnimatedOpacity( - opacity: _videoStore.loading ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - child: const IgnorePointer( - child: ColoredBox( - color: Colors.black, - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - color: Colors.white38, - strokeWidth: 2, + Positioned.fill( + child: AnimatedOpacity( + opacity: _videoStore.loading ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + child: const IgnorePointer( + child: ColoredBox( + color: Colors.black, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white38, + strokeWidth: 2, + ), ), ), ), From 9c1be7e121dde7b43edd70d5e4514cebe53d2f28 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 23:13:44 -0700 Subject: [PATCH 19/28] clean up settings: move keep-awake to chat, improve labels and section headers - move keepScreenAwake from video to chat settings (store + UI) - mute section headers (lighter weight, smaller, reduced opacity) - add breathing room between app bar and first setting - improve labels across general, video, and chat settings - reorder store fields to logical grouping with organized reset Co-Authored-By: Claude Opus 4.6 --- lib/screens/settings/chat_settings.dart | 716 +++++++++--------- lib/screens/settings/general_settings.dart | 4 +- .../settings/stores/settings_store.dart | 208 +++-- .../settings/stores/settings_store.g.dart | 524 ++++++------- lib/screens/settings/video_settings.dart | 14 +- lib/widgets/section_header.dart | 10 +- lib/widgets/settings_page_layout.dart | 2 +- 7 files changed, 737 insertions(+), 741 deletions(-) diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 6216ac26..a1e339b3 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,347 +1,369 @@ -import 'dart:io'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.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_slider.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; -import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; -import 'package:frosty/utils/context_extensions.dart'; -import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/section_header.dart'; -import 'package:frosty/widgets/settings_page_layout.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ChatSettings extends StatefulWidget { - final SettingsStore settingsStore; - - const ChatSettings({super.key, required this.settingsStore}); - - @override - State createState() => _ChatSettingsState(); -} - -class _ChatSettingsState extends State { - var showPreview = false; - - @override - Widget build(BuildContext context) { - final settingsStore = widget.settingsStore; - - return Observer( - builder: (context) => SettingsPageLayout( - children: [ - const SectionHeader('Message sizing', isFirst: true), - ExpansionTile( - title: const Text('Preview'), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - child: DefaultTextStyle( - style: DefaultTextStyle.of( - context, - ).style.copyWith(fontSize: settingsStore.fontSize), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', - height: - defaultBadgeSize * settingsStore.badgeScale, - width: - defaultBadgeSize * settingsStore.badgeScale, - ), - ), - const TextSpan(text: ' Badge and emote preview. '), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', - height: - defaultEmoteSize * settingsStore.emoteScale, - width: - defaultEmoteSize * settingsStore.emoteScale, - ), - ), - ], - ), - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'Hello! Here\'s a text preview.', - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'And another for spacing without an emote!', - textScaler: settingsStore.messageScale.textScaler, - ), - ], - ), - ), - ), - ], - ), - SettingsListSlider( - title: 'Badge scale', - trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', - value: settingsStore.badgeScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.badgeScale = newValue, - ), - SettingsListSlider( - title: 'Emote scale', - trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', - value: settingsStore.emoteScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.emoteScale = newValue, - ), - SettingsListSlider( - title: 'Message scale', - trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', - value: settingsStore.messageScale, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageScale = newValue, - ), - SettingsListSlider( - title: 'Message spacing', - trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', - value: settingsStore.messageSpacing, - max: 30.0, - divisions: 15, - onChanged: (newValue) => settingsStore.messageSpacing = newValue, - ), - SettingsListSlider( - title: 'Font size', - trailing: settingsStore.fontSize.toInt().toString(), - value: settingsStore.fontSize, - min: 5, - max: 20, - divisions: 15, - onChanged: (newValue) => settingsStore.fontSize = newValue, - ), - const SectionHeader('Message appearance'), - SettingsListSwitch( - title: 'Show deleted messages', - subtitle: const Text( - 'Restores the original message of deleted messages.', - ), - value: settingsStore.showDeletedMessages, - onChanged: (newValue) => - settingsStore.showDeletedMessages = newValue, - ), - SettingsListSwitch( - title: 'Show message dividers', - value: settingsStore.showChatMessageDividers, - onChanged: (newValue) => - settingsStore.showChatMessageDividers = newValue, - ), - SettingsListSelect( - title: 'Message timestamps', - selectedOption: timestampNames[settingsStore.timestampType.index], - options: timestampNames, - onChanged: (newValue) => settingsStore.timestampType = - TimestampType.values[timestampNames.indexOf(newValue)], - ), - const SectionHeader('Delay and latency'), - SettingsListSwitch( - title: 'Sync message delay and stream latency', - value: settingsStore.autoSyncChatDelay, - onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue, - ), - if (!settingsStore.autoSyncChatDelay) - SettingsListSlider( - title: 'Message delay', - trailing: '${settingsStore.chatDelay.toInt()} seconds', - subtitle: - 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', - value: settingsStore.chatDelay, - max: 30.0, - divisions: 30, - onChanged: (newValue) => settingsStore.chatDelay = newValue, - ), - const SectionHeader('Alerts'), - SettingsListSwitch( - title: 'Highlight first time chatters', - value: settingsStore.highlightFirstTimeChatter, - onChanged: (newValue) => - settingsStore.highlightFirstTimeChatter = newValue, - ), - SettingsListSwitch( - title: 'Show notices', - subtitle: const Text( - 'Shows notices such as subs and re-subs, announcements, and raids.', - ), - value: settingsStore.showUserNotices, - onChanged: (newValue) => settingsStore.showUserNotices = newValue, - ), - const SectionHeader('Layout'), - SettingsListSwitch( - title: 'Move emote menu button left', - subtitle: const Text( - 'Places the emote menu button on the left side to avoid accidental presses.', - ), - value: settingsStore.emoteMenuButtonOnLeft, - onChanged: (newValue) => - settingsStore.emoteMenuButtonOnLeft = newValue, - ), - SettingsListSwitch( - title: 'Persist chat tabs', - subtitle: const Text( - 'Secondary chat tabs are remembered when switching channels.', - ), - value: settingsStore.persistChatTabs, - onChanged: (newValue) { - settingsStore.persistChatTabs = newValue; - if (!newValue) { - settingsStore.secondaryTabs = []; - } - }, - ), - const SectionHeader('Landscape mode'), - SettingsListSwitch( - title: 'Move chat left', - value: settingsStore.landscapeChatLeftSide, - onChanged: (newValue) => - settingsStore.landscapeChatLeftSide = newValue, - ), - SettingsListSwitch( - title: 'Force vertical chat', - subtitle: const Text( - 'Intended for tablets and other larger displays.', - ), - value: settingsStore.landscapeForceVerticalChat, - onChanged: (newValue) => - settingsStore.landscapeForceVerticalChat = newValue, - ), - SettingsListSelect( - title: 'Fill notch side', - subtitle: - 'Overrides and fills the available space in devices with a display notch.', - selectedOption: - landscapeCutoutNames[settingsStore.landscapeCutout.index], - options: landscapeCutoutNames, - onChanged: (newValue) => settingsStore.landscapeCutout = - LandscapeCutoutType.values[landscapeCutoutNames.indexOf( - newValue, - )], - ), - SettingsListSlider( - title: 'Chat overlay opacity', - trailing: - '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', - subtitle: - 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', - value: settingsStore.fullScreenChatOverlayOpacity, - divisions: 10, - onChanged: (newValue) => - settingsStore.fullScreenChatOverlayOpacity = newValue, - ), - const SectionHeader('Muted keywords'), - SettingsMutedWords(settingsStore: settingsStore), - SettingsListSwitch( - title: 'Match whole words', - subtitle: const Text( - 'Only matches whole words instead of partial matches.', - ), - value: settingsStore.matchWholeWord, - onChanged: (newValue) => settingsStore.matchWholeWord = newValue, - ), - const SectionHeader('Autocomplete'), - SettingsListSwitch( - title: 'Show autocomplete bar', - subtitle: const Text( - 'Shows a bar containing matching emotes and mentions while typing.', - ), - value: settingsStore.autocomplete, - onChanged: (newValue) => settingsStore.autocomplete = newValue, - ), - const SectionHeader('Emotes and badges'), - SettingsListSwitch( - title: 'Show Twitch emotes', - value: settingsStore.showTwitchEmotes, - onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show Twitch badges', - value: settingsStore.showTwitchBadges, - onChanged: (newValue) => settingsStore.showTwitchBadges = newValue, - ), - SettingsListSwitch( - title: 'Show 7TV emotes', - value: settingsStore.show7TVEmotes, - onChanged: (newValue) => settingsStore.show7TVEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show BTTV emotes', - value: settingsStore.showBTTVEmotes, - onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show BTTV badges', - value: settingsStore.showBTTVBadges, - onChanged: (newValue) => settingsStore.showBTTVBadges = newValue, - ), - SettingsListSwitch( - title: 'Show FFZ emotes', - value: settingsStore.showFFZEmotes, - onChanged: (newValue) => settingsStore.showFFZEmotes = newValue, - ), - SettingsListSwitch( - title: 'Show FFZ badges', - value: settingsStore.showFFZBadges, - onChanged: (newValue) => settingsStore.showFFZBadges = newValue, - ), - const SectionHeader('Recent messages'), - SettingsListSwitch( - title: 'Show historical recent messages', - subtitle: Text.rich( - TextSpan( - text: - 'Loads historical recent messages in chat through a third-party API service at ', - children: [ - TextSpan( - text: 'https://recent-messages.robotty.de/', - style: TextStyle( - color: context.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: context.colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl( - Uri.parse('https://recent-messages.robotty.de/'), - mode: settingsStore.launchUrlExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ), - ), - ], - ), - ), - value: settingsStore.showRecentMessages, - onChanged: (newValue) => - settingsStore.showRecentMessages = newValue, - ), - ], - ), - ); - } -} +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.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_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/settings_page_layout.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ChatSettings extends StatelessWidget { + final SettingsStore settingsStore; + + const ChatSettings({super.key, required this.settingsStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) => SettingsPageLayout( + children: [ + const SectionHeader('General', isFirst: true), + SettingsListSwitch( + title: 'Keep screen on', + subtitle: const Text( + 'Prevents the screen from sleeping while a channel is open.', + ), + value: settingsStore.keepScreenAwake, + onChanged: (newValue) => + settingsStore.keepScreenAwake = newValue, + ), + SettingsListSwitch( + title: 'Autocomplete', + subtitle: const Text( + 'Shows matching emotes and mentions while typing.', + ), + value: settingsStore.autocomplete, + onChanged: (newValue) => + settingsStore.autocomplete = newValue, + ), + SettingsListSwitch( + title: 'Load recent messages', + subtitle: Text.rich( + TextSpan( + text: + 'Loads historical recent messages in chat through a third-party API service at ', + children: [ + TextSpan( + text: 'https://recent-messages.robotty.de/', + style: TextStyle( + color: context.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: context.colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse( + 'https://recent-messages.robotty.de/', + ), + mode: settingsStore.launchUrlExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), + ), + ], + ), + ), + value: settingsStore.showRecentMessages, + onChanged: (newValue) => + settingsStore.showRecentMessages = newValue, + ), + const SectionHeader('Message sizing'), + ExpansionTile( + title: const Text('Preview'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: DefaultTextStyle( + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: settingsStore.fontSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', + height: + defaultBadgeSize * + settingsStore.badgeScale, + width: + defaultBadgeSize * + settingsStore.badgeScale, + ), + ), + const TextSpan( + text: ' Badge and emote preview. ', + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', + height: + defaultEmoteSize * + settingsStore.emoteScale, + width: + defaultEmoteSize * + settingsStore.emoteScale, + ), + ), + ], + ), + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'Hello! Here\'s a text preview.', + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'And another for spacing without an emote!', + textScaler: settingsStore.messageScale.textScaler, + ), + ], + ), + ), + ), + ], + ), + SettingsListSlider( + title: 'Badge scale', + trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', + value: settingsStore.badgeScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.badgeScale = newValue, + ), + SettingsListSlider( + title: 'Emote scale', + trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', + value: settingsStore.emoteScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.emoteScale = newValue, + ), + SettingsListSlider( + title: 'Message scale', + trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', + value: settingsStore.messageScale, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageScale = newValue, + ), + SettingsListSlider( + title: 'Message spacing', + trailing: + '${settingsStore.messageSpacing.toStringAsFixed(0)}px', + value: settingsStore.messageSpacing, + max: 30.0, + divisions: 15, + onChanged: (newValue) => + settingsStore.messageSpacing = newValue, + ), + SettingsListSlider( + title: 'Font size', + trailing: settingsStore.fontSize.toInt().toString(), + value: settingsStore.fontSize, + min: 5, + max: 20, + divisions: 15, + onChanged: (newValue) => settingsStore.fontSize = newValue, + ), + const SectionHeader('Message appearance'), + SettingsListSwitch( + title: 'Show deleted messages', + subtitle: const Text( + 'Restores the original message of deleted messages.', + ), + value: settingsStore.showDeletedMessages, + onChanged: (newValue) => + settingsStore.showDeletedMessages = newValue, + ), + SettingsListSwitch( + title: 'Show message dividers', + value: settingsStore.showChatMessageDividers, + onChanged: (newValue) => + settingsStore.showChatMessageDividers = newValue, + ), + SettingsListSelect( + title: 'Timestamps', + selectedOption: + timestampNames[settingsStore.timestampType.index], + options: timestampNames, + onChanged: (newValue) => settingsStore.timestampType = + TimestampType.values[timestampNames.indexOf(newValue)], + ), + const SectionHeader('Delay'), + SettingsListSwitch( + title: 'Auto-sync chat delay', + value: settingsStore.autoSyncChatDelay, + onChanged: (newValue) => + settingsStore.autoSyncChatDelay = newValue, + ), + if (!settingsStore.autoSyncChatDelay) + SettingsListSlider( + title: 'Chat delay', + trailing: '${settingsStore.chatDelay.toInt()} seconds', + subtitle: + 'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', + value: settingsStore.chatDelay, + max: 30.0, + divisions: 30, + onChanged: (newValue) => + settingsStore.chatDelay = newValue, + ), + const SectionHeader('Alerts'), + SettingsListSwitch( + title: 'Highlight first-time chatters', + value: settingsStore.highlightFirstTimeChatter, + onChanged: (newValue) => + settingsStore.highlightFirstTimeChatter = newValue, + ), + SettingsListSwitch( + title: 'Show notices', + subtitle: const Text( + 'Shows notices such as subs, announcements, and raids.', + ), + value: settingsStore.showUserNotices, + onChanged: (newValue) => + settingsStore.showUserNotices = newValue, + ), + const SectionHeader('Layout'), + SettingsListSwitch( + title: 'Emote menu on left', + subtitle: const Text( + 'Places the emote menu button on the left side.', + ), + value: settingsStore.emoteMenuButtonOnLeft, + onChanged: (newValue) => + settingsStore.emoteMenuButtonOnLeft = newValue, + ), + SettingsListSwitch( + title: 'Remember chat tabs', + subtitle: const Text( + 'Secondary chat tabs are kept when switching channels.', + ), + value: settingsStore.persistChatTabs, + onChanged: (newValue) { + settingsStore.persistChatTabs = newValue; + if (!newValue) { + settingsStore.secondaryTabs = []; + } + }, + ), + const SectionHeader('Landscape'), + SettingsListSwitch( + title: 'Chat on left side', + value: settingsStore.landscapeChatLeftSide, + onChanged: (newValue) => + settingsStore.landscapeChatLeftSide = newValue, + ), + SettingsListSwitch( + title: 'Force vertical chat', + subtitle: const Text( + 'Intended for tablets and larger displays.', + ), + value: settingsStore.landscapeForceVerticalChat, + onChanged: (newValue) => + settingsStore.landscapeForceVerticalChat = newValue, + ), + SettingsListSelect( + title: 'Notch fill', + subtitle: + 'Fills the display cutout area on the selected side.', + selectedOption: + landscapeCutoutNames[settingsStore.landscapeCutout.index], + options: landscapeCutoutNames, + onChanged: (newValue) => settingsStore.landscapeCutout = + LandscapeCutoutType.values[landscapeCutoutNames.indexOf( + newValue, + )], + ), + SettingsListSlider( + title: 'Overlay chat opacity', + trailing: + '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: + 'Opacity of the chat overlay in fullscreen mode.', + value: settingsStore.fullScreenChatOverlayOpacity, + divisions: 10, + onChanged: (newValue) => + settingsStore.fullScreenChatOverlayOpacity = newValue, + ), + const SectionHeader('Filtering'), + SettingsMutedWords(settingsStore: settingsStore), + SettingsListSwitch( + title: 'Match whole words', + subtitle: const Text( + 'Only matches whole words instead of partial matches.', + ), + value: settingsStore.matchWholeWord, + onChanged: (newValue) => + settingsStore.matchWholeWord = newValue, + ), + const SectionHeader('Emotes and badges'), + SettingsListSwitch( + title: 'Twitch emotes', + value: settingsStore.showTwitchEmotes, + onChanged: (newValue) => + settingsStore.showTwitchEmotes = newValue, + ), + SettingsListSwitch( + title: 'Twitch badges', + value: settingsStore.showTwitchBadges, + onChanged: (newValue) => + settingsStore.showTwitchBadges = newValue, + ), + SettingsListSwitch( + title: '7TV emotes', + value: settingsStore.show7TVEmotes, + onChanged: (newValue) => + settingsStore.show7TVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV emotes', + value: settingsStore.showBTTVEmotes, + onChanged: (newValue) => + settingsStore.showBTTVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV badges', + value: settingsStore.showBTTVBadges, + onChanged: (newValue) => + settingsStore.showBTTVBadges = newValue, + ), + SettingsListSwitch( + title: 'FFZ emotes', + value: settingsStore.showFFZEmotes, + onChanged: (newValue) => + settingsStore.showFFZEmotes = newValue, + ), + SettingsListSwitch( + title: 'FFZ badges', + value: settingsStore.showFFZBadges, + onChanged: (newValue) => + settingsStore.showFFZBadges = newValue, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index e67149f6..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget { AccentColorSetting(settingsStore: settingsStore), const SectionHeader('Stream card'), SettingsListSwitch( - title: 'Use large stream card', + title: 'Large stream cards', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), SettingsListSwitch( - title: 'Show thumbnail', + title: 'Show thumbnails', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index f35d0741..d5a7f03a 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -65,9 +65,6 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultKeepScreenAwake = true; - - // Overlay defaults static const defaultUseNativePlayer = false; static const defaultShowOverlay = true; @@ -87,10 +84,6 @@ abstract class _SettingsStoreBase with Store { @observable var useTextureRendering = defaultUseTextureRendering; - @JsonKey(defaultValue: defaultKeepScreenAwake) - @observable - var keepScreenAwake = defaultKeepScreenAwake; - @JsonKey(defaultValue: defaultUseNativePlayer) @observable var useNativePlayer = defaultUseNativePlayer; @@ -113,8 +106,6 @@ abstract class _SettingsStoreBase with Store { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; - keepScreenAwake = defaultKeepScreenAwake; - useNativePlayer = defaultUseNativePlayer; showOverlay = defaultShowOverlay; @@ -123,44 +114,25 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - // Message sizing defaults + + // -- Messages -- + // Sizing defaults static const defaultBadgeScale = 1.0; static const defaultEmoteScale = 1.0; static const defaultMessageScale = 1.0; static const defaultMessageSpacing = 8.0; static const defaultFontSize = 12.0; - // Message appearance defaults + // Appearance defaults static const defaultShowDeletedMessages = false; static const defaultShowChatMessageDividers = false; static const defaultTimestampType = TimestampType.disabled; - // Delay defaults - static const defaultAutoSyncChatDelay = false; - static const defaultChatDelay = 0.0; - // Alert defaults static const defaultHighlightFirstTimeChatter = true; static const defaultShowUserNotices = true; - // Layout defaults - static const defaultEmoteMenuButtonOnLeft = false; - - // Landscape mode defaults - static const defaultLandscapeChatLeftSide = false; - static const defaultLandscapeForceVerticalChat = false; - static const defaultLandscapeCutout = LandscapeCutoutType.none; - static const defaultChatWidth = 0.2; - static const defaultFullScreenChatOverlayOpacity = 0.5; - - // mute words defaults - static const defaultMutedWords = []; - static const defaultMatchWholeWord = true; - - // Autocomplete defaults - static const defaultAutocomplete = true; - - // Emotes and badges defaults + // -- Emotes & Badges -- static const defaultShowTwitchEmotes = true; static const defaultShowTwitchBadges = true; static const defaultShow7TVEmotes = true; @@ -169,14 +141,26 @@ abstract class _SettingsStoreBase with Store { static const defaultShowFFZEmotes = true; static const defaultShowFFZBadges = true; - // Recent messages defaults - static const defaultShowRecentMessages = false; - - // Chat tabs defaults + // -- Layout -- + static const defaultEmoteMenuButtonOnLeft = false; static const defaultPersistChatTabs = true; static const defaultSecondaryTabs = []; + static const defaultLandscapeChatLeftSide = false; + static const defaultLandscapeForceVerticalChat = false; + static const defaultLandscapeCutout = LandscapeCutoutType.none; + static const defaultChatWidth = 0.2; + static const defaultFullScreenChatOverlayOpacity = 0.5; - // Message sizing options + // -- Behavior -- + static const defaultKeepScreenAwake = true; + static const defaultAutocomplete = true; + static const defaultShowRecentMessages = false; + static const defaultAutoSyncChatDelay = false; + static const defaultChatDelay = 0.0; + static const defaultMutedWords = []; + static const defaultMatchWholeWord = true; + + // Messages options @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -197,7 +181,6 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; - // Message appearance options @JsonKey(defaultValue: defaultShowDeletedMessages) @observable var showDeletedMessages = defaultShowDeletedMessages; @@ -213,24 +196,6 @@ abstract class _SettingsStoreBase with Store { @observable var timestampType = defaultTimestampType; - // Delay options - @JsonKey(defaultValue: defaultAutoSyncChatDelay) - @observable - var autoSyncChatDelay = defaultAutoSyncChatDelay; - - @JsonKey(defaultValue: defaultChatDelay) - @observable - var chatDelay = defaultChatDelay; - - @JsonKey(includeFromJson: false, includeToJson: false) - @observable - var syncedChatDelay = defaultChatDelay; - - @computed - double get effectiveChatDelay => - autoSyncChatDelay ? syncedChatDelay : chatDelay; - - // Alert options @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) @observable var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; @@ -239,38 +204,7 @@ abstract class _SettingsStoreBase with Store { @observable var showUserNotices = defaultShowUserNotices; - // Layout options - @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) - @observable - var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - // Landscape mode options - @JsonKey(defaultValue: defaultLandscapeChatLeftSide) - @observable - var landscapeChatLeftSide = defaultLandscapeChatLeftSide; - - @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) - @observable - var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - - @JsonKey(defaultValue: defaultLandscapeCutout) - @observable - var landscapeCutout = defaultLandscapeCutout; - - @JsonKey(defaultValue: defaultChatWidth) - @observable - var chatWidth = defaultChatWidth; - - @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) - @observable - var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - // Autocomplete options - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; - - // Emotes and badges + // Emotes & Badges options @JsonKey(defaultValue: defaultShowTwitchEmotes) @observable var showTwitchEmotes = defaultShowTwitchEmotes; @@ -299,12 +233,11 @@ abstract class _SettingsStoreBase with Store { @observable var showFFZBadges = defaultShowFFZBadges; - // Recent messages - @JsonKey(defaultValue: defaultShowRecentMessages) + // Layout options + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) @observable - var showRecentMessages = defaultShowRecentMessages; + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - // Chat tabs @JsonKey(defaultValue: defaultPersistChatTabs) @observable var persistChatTabs = defaultPersistChatTabs; @@ -313,6 +246,55 @@ abstract class _SettingsStoreBase with Store { @observable var secondaryTabs = defaultSecondaryTabs; + @JsonKey(defaultValue: defaultLandscapeChatLeftSide) + @observable + var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + + @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) + @observable + var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + + @JsonKey(defaultValue: defaultChatWidth) + @observable + var chatWidth = defaultChatWidth; + + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior options + @JsonKey(defaultValue: defaultKeepScreenAwake) + @observable + var keepScreenAwake = defaultKeepScreenAwake; + + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + + @JsonKey(defaultValue: defaultShowRecentMessages) + @observable + var showRecentMessages = defaultShowRecentMessages; + + @JsonKey(defaultValue: defaultAutoSyncChatDelay) + @observable + var autoSyncChatDelay = defaultAutoSyncChatDelay; + + @JsonKey(defaultValue: defaultChatDelay) + @observable + var chatDelay = defaultChatDelay; + + @JsonKey(includeFromJson: false, includeToJson: false) + @observable + var syncedChatDelay = defaultChatDelay; + + @computed + double get effectiveChatDelay => + autoSyncChatDelay ? syncedChatDelay : chatDelay; + @JsonKey(defaultValue: defaultMutedWords) @observable List mutedWords = defaultMutedWords; @@ -323,36 +305,19 @@ abstract class _SettingsStoreBase with Store { @action void resetChatSettings() { + // Messages badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; messageScale = defaultMessageScale; messageSpacing = defaultMessageSpacing; fontSize = defaultFontSize; - showDeletedMessages = defaultShowDeletedMessages; showChatMessageDividers = defaultShowChatMessageDividers; timestampType = defaultTimestampType; - - autoSyncChatDelay = defaultAutoSyncChatDelay; - chatDelay = defaultChatDelay; - syncedChatDelay = defaultChatDelay; - highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; showUserNotices = defaultShowUserNotices; - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - mutedWords = defaultMutedWords; - matchWholeWord = defaultMatchWholeWord; - - autocomplete = defaultAutocomplete; - + // Emotes & Badges showTwitchEmotes = defaultShowTwitchEmotes; showTwitchBadges = defaultShowTwitchBadges; show7TVEmotes = defaultShow7TVEmotes; @@ -361,10 +326,25 @@ abstract class _SettingsStoreBase with Store { showFFZEmotes = defaultShowFFZEmotes; showFFZBadges = defaultShowFFZBadges; - showRecentMessages = defaultShowRecentMessages; - + // Layout + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; persistChatTabs = defaultPersistChatTabs; secondaryTabs = defaultSecondaryTabs; + landscapeChatLeftSide = defaultLandscapeChatLeftSide; + landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + landscapeCutout = defaultLandscapeCutout; + chatWidth = defaultChatWidth; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior + keepScreenAwake = defaultKeepScreenAwake; + autocomplete = defaultAutocomplete; + showRecentMessages = defaultShowRecentMessages; + autoSyncChatDelay = defaultAutoSyncChatDelay; + chatDelay = defaultChatDelay; + syncedChatDelay = defaultChatDelay; + mutedWords = defaultMutedWords; + matchWholeWord = defaultMatchWholeWord; } // * Other settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index a5699705..2e3313e0 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -23,7 +23,6 @@ SettingsStore _$SettingsStoreFromJson( ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..useNativePlayer = json['useNativePlayer'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false @@ -42,12 +41,23 @@ SettingsStore _$SettingsStoreFromJson( unknownValue: TimestampType.disabled, ) ?? TimestampType.disabled - ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..highlightFirstTimeChatter = json['highlightFirstTimeChatter'] as bool? ?? true ..showUserNotices = json['showUserNotices'] as bool? ?? true + ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true + ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true + ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true + ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true + ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true + ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true + ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false + ..persistChatTabs = json['persistChatTabs'] as bool? ?? true + ..secondaryTabs = + (json['secondaryTabs'] as List?) + ?.map((e) => PersistedChatTab.fromJson(e as Map)) + .toList() ?? + [] ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false ..landscapeForceVerticalChat = json['landscapeForceVerticalChat'] as bool? ?? false @@ -60,21 +70,11 @@ SettingsStore _$SettingsStoreFromJson( ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2 ..fullScreenChatOverlayOpacity = (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..autocomplete = json['autocomplete'] as bool? ?? true - ..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true - ..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true - ..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true - ..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true - ..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true - ..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true - ..showFFZBadges = json['showFFZBadges'] as bool? ?? true ..showRecentMessages = json['showRecentMessages'] as bool? ?? false - ..persistChatTabs = json['persistChatTabs'] as bool? ?? true - ..secondaryTabs = - (json['secondaryTabs'] as List?) - ?.map((e) => PersistedChatTab.fromJson(e as Map)) - .toList() ?? - [] + ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..mutedWords = (json['mutedWords'] as List?) ?.map((e) => e as String) @@ -102,7 +102,6 @@ Map _$SettingsStoreToJson( 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, - 'keepScreenAwake': instance.keepScreenAwake, 'useNativePlayer': instance.useNativePlayer, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, @@ -115,17 +114,8 @@ Map _$SettingsStoreToJson( 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'autoSyncChatDelay': instance.autoSyncChatDelay, - 'chatDelay': instance.chatDelay, 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, 'showUserNotices': instance.showUserNotices, - 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, - 'landscapeChatLeftSide': instance.landscapeChatLeftSide, - 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, - 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, - 'chatWidth': instance.chatWidth, - 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, - 'autocomplete': instance.autocomplete, 'showTwitchEmotes': instance.showTwitchEmotes, 'showTwitchBadges': instance.showTwitchBadges, 'show7TVEmotes': instance.show7TVEmotes, @@ -133,9 +123,19 @@ Map _$SettingsStoreToJson( 'showBTTVBadges': instance.showBTTVBadges, 'showFFZEmotes': instance.showFFZEmotes, 'showFFZBadges': instance.showFFZBadges, - 'showRecentMessages': instance.showRecentMessages, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, 'persistChatTabs': instance.persistChatTabs, 'secondaryTabs': instance.secondaryTabs, + 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, + 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, + 'chatWidth': instance.chatWidth, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, + 'keepScreenAwake': instance.keepScreenAwake, + 'autocomplete': instance.autocomplete, + 'showRecentMessages': instance.showRecentMessages, + 'autoSyncChatDelay': instance.autoSyncChatDelay, + 'chatDelay': instance.chatDelay, 'mutedWords': instance.mutedWords, 'matchWholeWord': instance.matchWholeWord, 'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics, @@ -327,24 +327,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$keepScreenAwakeAtom = Atom( - name: '_SettingsStoreBase.keepScreenAwake', - context: context, - ); - - @override - bool get keepScreenAwake { - _$keepScreenAwakeAtom.reportRead(); - return super.keepScreenAwake; - } - - @override - set keepScreenAwake(bool value) { - _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { - super.keepScreenAwake = value; - }); - } - late final _$useNativePlayerAtom = Atom( name: '_SettingsStoreBase.useNativePlayer', context: context, @@ -565,60 +547,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$autoSyncChatDelayAtom = Atom( - name: '_SettingsStoreBase.autoSyncChatDelay', - context: context, - ); - - @override - bool get autoSyncChatDelay { - _$autoSyncChatDelayAtom.reportRead(); - return super.autoSyncChatDelay; - } - - @override - set autoSyncChatDelay(bool value) { - _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { - super.autoSyncChatDelay = value; - }); - } - - late final _$chatDelayAtom = Atom( - name: '_SettingsStoreBase.chatDelay', - context: context, - ); - - @override - double get chatDelay { - _$chatDelayAtom.reportRead(); - return super.chatDelay; - } - - @override - set chatDelay(double value) { - _$chatDelayAtom.reportWrite(value, super.chatDelay, () { - super.chatDelay = value; - }); - } - - late final _$syncedChatDelayAtom = Atom( - name: '_SettingsStoreBase.syncedChatDelay', - context: context, - ); - - @override - double get syncedChatDelay { - _$syncedChatDelayAtom.reportRead(); - return super.syncedChatDelay; - } - - @override - set syncedChatDelay(double value) { - _$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () { - super.syncedChatDelay = value; - }); - } - late final _$highlightFirstTimeChatterAtom = Atom( name: '_SettingsStoreBase.highlightFirstTimeChatter', context: context, @@ -659,148 +587,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$emoteMenuButtonOnLeftAtom = Atom( - name: '_SettingsStoreBase.emoteMenuButtonOnLeft', - context: context, - ); - - @override - bool get emoteMenuButtonOnLeft { - _$emoteMenuButtonOnLeftAtom.reportRead(); - return super.emoteMenuButtonOnLeft; - } - - @override - set emoteMenuButtonOnLeft(bool value) { - _$emoteMenuButtonOnLeftAtom.reportWrite( - value, - super.emoteMenuButtonOnLeft, - () { - super.emoteMenuButtonOnLeft = value; - }, - ); - } - - late final _$landscapeChatLeftSideAtom = Atom( - name: '_SettingsStoreBase.landscapeChatLeftSide', - context: context, - ); - - @override - bool get landscapeChatLeftSide { - _$landscapeChatLeftSideAtom.reportRead(); - return super.landscapeChatLeftSide; - } - - @override - set landscapeChatLeftSide(bool value) { - _$landscapeChatLeftSideAtom.reportWrite( - value, - super.landscapeChatLeftSide, - () { - super.landscapeChatLeftSide = value; - }, - ); - } - - late final _$landscapeForceVerticalChatAtom = Atom( - name: '_SettingsStoreBase.landscapeForceVerticalChat', - context: context, - ); - - @override - bool get landscapeForceVerticalChat { - _$landscapeForceVerticalChatAtom.reportRead(); - return super.landscapeForceVerticalChat; - } - - @override - set landscapeForceVerticalChat(bool value) { - _$landscapeForceVerticalChatAtom.reportWrite( - value, - super.landscapeForceVerticalChat, - () { - super.landscapeForceVerticalChat = value; - }, - ); - } - - late final _$landscapeCutoutAtom = Atom( - name: '_SettingsStoreBase.landscapeCutout', - context: context, - ); - - @override - LandscapeCutoutType get landscapeCutout { - _$landscapeCutoutAtom.reportRead(); - return super.landscapeCutout; - } - - @override - set landscapeCutout(LandscapeCutoutType value) { - _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { - super.landscapeCutout = value; - }); - } - - late final _$chatWidthAtom = Atom( - name: '_SettingsStoreBase.chatWidth', - context: context, - ); - - @override - double get chatWidth { - _$chatWidthAtom.reportRead(); - return super.chatWidth; - } - - @override - set chatWidth(double value) { - _$chatWidthAtom.reportWrite(value, super.chatWidth, () { - super.chatWidth = value; - }); - } - - late final _$fullScreenChatOverlayOpacityAtom = Atom( - name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', - context: context, - ); - - @override - double get fullScreenChatOverlayOpacity { - _$fullScreenChatOverlayOpacityAtom.reportRead(); - return super.fullScreenChatOverlayOpacity; - } - - @override - set fullScreenChatOverlayOpacity(double value) { - _$fullScreenChatOverlayOpacityAtom.reportWrite( - value, - super.fullScreenChatOverlayOpacity, - () { - super.fullScreenChatOverlayOpacity = value; - }, - ); - } - - late final _$autocompleteAtom = Atom( - name: '_SettingsStoreBase.autocomplete', - context: context, - ); - - @override - bool get autocomplete { - _$autocompleteAtom.reportRead(); - return super.autocomplete; - } - - @override - set autocomplete(bool value) { - _$autocompleteAtom.reportWrite(value, super.autocomplete, () { - super.autocomplete = value; - }); - } - late final _$showTwitchEmotesAtom = Atom( name: '_SettingsStoreBase.showTwitchEmotes', context: context, @@ -927,22 +713,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showRecentMessagesAtom = Atom( - name: '_SettingsStoreBase.showRecentMessages', + late final _$emoteMenuButtonOnLeftAtom = Atom( + name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context, ); @override - bool get showRecentMessages { - _$showRecentMessagesAtom.reportRead(); - return super.showRecentMessages; + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; } @override - set showRecentMessages(bool value) { - _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { - super.showRecentMessages = value; - }); + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite( + value, + super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }, + ); } late final _$persistChatTabsAtom = Atom( @@ -981,6 +771,216 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$landscapeChatLeftSideAtom = Atom( + name: '_SettingsStoreBase.landscapeChatLeftSide', + context: context, + ); + + @override + bool get landscapeChatLeftSide { + _$landscapeChatLeftSideAtom.reportRead(); + return super.landscapeChatLeftSide; + } + + @override + set landscapeChatLeftSide(bool value) { + _$landscapeChatLeftSideAtom.reportWrite( + value, + super.landscapeChatLeftSide, + () { + super.landscapeChatLeftSide = value; + }, + ); + } + + late final _$landscapeForceVerticalChatAtom = Atom( + name: '_SettingsStoreBase.landscapeForceVerticalChat', + context: context, + ); + + @override + bool get landscapeForceVerticalChat { + _$landscapeForceVerticalChatAtom.reportRead(); + return super.landscapeForceVerticalChat; + } + + @override + set landscapeForceVerticalChat(bool value) { + _$landscapeForceVerticalChatAtom.reportWrite( + value, + super.landscapeForceVerticalChat, + () { + super.landscapeForceVerticalChat = value; + }, + ); + } + + late final _$landscapeCutoutAtom = Atom( + name: '_SettingsStoreBase.landscapeCutout', + context: context, + ); + + @override + LandscapeCutoutType get landscapeCutout { + _$landscapeCutoutAtom.reportRead(); + return super.landscapeCutout; + } + + @override + set landscapeCutout(LandscapeCutoutType value) { + _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { + super.landscapeCutout = value; + }); + } + + late final _$chatWidthAtom = Atom( + name: '_SettingsStoreBase.chatWidth', + context: context, + ); + + @override + double get chatWidth { + _$chatWidthAtom.reportRead(); + return super.chatWidth; + } + + @override + set chatWidth(double value) { + _$chatWidthAtom.reportWrite(value, super.chatWidth, () { + super.chatWidth = value; + }); + } + + late final _$fullScreenChatOverlayOpacityAtom = Atom( + name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', + context: context, + ); + + @override + double get fullScreenChatOverlayOpacity { + _$fullScreenChatOverlayOpacityAtom.reportRead(); + return super.fullScreenChatOverlayOpacity; + } + + @override + set fullScreenChatOverlayOpacity(double value) { + _$fullScreenChatOverlayOpacityAtom.reportWrite( + value, + super.fullScreenChatOverlayOpacity, + () { + super.fullScreenChatOverlayOpacity = value; + }, + ); + } + + late final _$keepScreenAwakeAtom = Atom( + name: '_SettingsStoreBase.keepScreenAwake', + context: context, + ); + + @override + bool get keepScreenAwake { + _$keepScreenAwakeAtom.reportRead(); + return super.keepScreenAwake; + } + + @override + set keepScreenAwake(bool value) { + _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { + super.keepScreenAwake = value; + }); + } + + late final _$autocompleteAtom = Atom( + name: '_SettingsStoreBase.autocomplete', + context: context, + ); + + @override + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; + } + + @override + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = value; + }); + } + + late final _$showRecentMessagesAtom = Atom( + name: '_SettingsStoreBase.showRecentMessages', + context: context, + ); + + @override + bool get showRecentMessages { + _$showRecentMessagesAtom.reportRead(); + return super.showRecentMessages; + } + + @override + set showRecentMessages(bool value) { + _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { + super.showRecentMessages = value; + }); + } + + late final _$autoSyncChatDelayAtom = Atom( + name: '_SettingsStoreBase.autoSyncChatDelay', + context: context, + ); + + @override + bool get autoSyncChatDelay { + _$autoSyncChatDelayAtom.reportRead(); + return super.autoSyncChatDelay; + } + + @override + set autoSyncChatDelay(bool value) { + _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { + super.autoSyncChatDelay = value; + }); + } + + late final _$chatDelayAtom = Atom( + name: '_SettingsStoreBase.chatDelay', + context: context, + ); + + @override + double get chatDelay { + _$chatDelayAtom.reportRead(); + return super.chatDelay; + } + + @override + set chatDelay(double value) { + _$chatDelayAtom.reportWrite(value, super.chatDelay, () { + super.chatDelay = value; + }); + } + + late final _$syncedChatDelayAtom = Atom( + name: '_SettingsStoreBase.syncedChatDelay', + context: context, + ); + + @override + double get syncedChatDelay { + _$syncedChatDelayAtom.reportRead(); + return super.syncedChatDelay; + } + + @override + set syncedChatDelay(double value) { + _$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () { + super.syncedChatDelay = value; + }); + } + late final _$mutedWordsAtom = Atom( name: '_SettingsStoreBase.mutedWords', context: context, @@ -1185,7 +1185,6 @@ launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, -keepScreenAwake: ${keepScreenAwake}, useNativePlayer: ${useNativePlayer}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, @@ -1198,18 +1197,8 @@ fontSize: ${fontSize}, showDeletedMessages: ${showDeletedMessages}, showChatMessageDividers: ${showChatMessageDividers}, timestampType: ${timestampType}, -autoSyncChatDelay: ${autoSyncChatDelay}, -chatDelay: ${chatDelay}, -syncedChatDelay: ${syncedChatDelay}, highlightFirstTimeChatter: ${highlightFirstTimeChatter}, showUserNotices: ${showUserNotices}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -autocomplete: ${autocomplete}, showTwitchEmotes: ${showTwitchEmotes}, showTwitchBadges: ${showTwitchBadges}, show7TVEmotes: ${show7TVEmotes}, @@ -1217,9 +1206,20 @@ showBTTVEmotes: ${showBTTVEmotes}, showBTTVBadges: ${showBTTVBadges}, showFFZEmotes: ${showFFZEmotes}, showFFZBadges: ${showFFZBadges}, -showRecentMessages: ${showRecentMessages}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, persistChatTabs: ${persistChatTabs}, secondaryTabs: ${secondaryTabs}, +landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeForceVerticalChat: ${landscapeForceVerticalChat}, +landscapeCutout: ${landscapeCutout}, +chatWidth: ${chatWidth}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, +keepScreenAwake: ${keepScreenAwake}, +autocomplete: ${autocomplete}, +showRecentMessages: ${showRecentMessages}, +autoSyncChatDelay: ${autoSyncChatDelay}, +chatDelay: ${chatDelay}, +syncedChatDelay: ${syncedChatDelay}, mutedWords: ${mutedWords}, matchWholeWord: ${matchWholeWord}, shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 714694d9..4e2aedd4 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -20,7 +20,7 @@ class VideoSettings extends StatelessWidget { children: [ const SectionHeader('Player', isFirst: true), SettingsListSwitch( - title: 'Enable video', + title: 'Show video player', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), @@ -51,14 +51,6 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useTextureRendering = newValue, ), - SettingsListSwitch( - title: 'Keep screen awake', - subtitle: const Text( - 'Prevents the screen from sleeping while a channel is open.', - ), - value: settingsStore.keepScreenAwake, - onChanged: (newValue) => settingsStore.keepScreenAwake = newValue, - ), const SectionHeader('Overlay'), SettingsListSwitch( title: 'Use custom video overlay', @@ -69,9 +61,9 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.showOverlay = newValue, ), SettingsListSwitch( - title: 'Long-press player to toggle overlay', + title: 'Toggle overlay on long-press', subtitle: const Text( - 'Allows switching between Twitch\'s overlay and the custom overlay.', + 'Switch between Twitch\'s overlay and the custom overlay.', ), value: settingsStore.toggleableOverlay, onChanged: (newValue) => diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 877df882..6ac20ac2 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -38,10 +38,12 @@ class SectionHeader extends StatelessWidget { Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurfaceVariant, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 50864ddb..d77be3f6 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -41,7 +41,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 116 : 92, + top: isPortrait ? 124 : 100, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); From 0a52ca3724c30132e4caa04d0a90561f75694411 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 23:20:29 -0700 Subject: [PATCH 20/28] move web session indicator next to username with info icon Co-Authored-By: Claude Opus 4.6 --- .../account/widgets/profile_card.dart | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 169c2c71..8592dcaa 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -45,22 +45,17 @@ class ProfileCard extends StatelessWidget { userLogin: authStore.user.details!.login, radius: 12, ), - title: Text(authStore.user.details!.displayName), - trailing: Row( - mainAxisSize: MainAxisSize.min, + title: Row( children: [ - IconButton( - tooltip: hasToken - ? 'Web session linked' - : 'Web session not linked', - icon: Icon( - hasToken - ? Icons.check_circle_outline_rounded - : Icons.link_off_rounded, - size: 20, - color: hasToken ? Colors.green : null, + Flexible( + child: Text( + authStore.user.details!.displayName, + overflow: TextOverflow.ellipsis, ), - onPressed: () { + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () { showDialog( context: context, builder: (context) => AlertDialog( @@ -92,10 +87,17 @@ class ProfileCard extends StatelessWidget { ), ); }, + child: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded, + size: 16, + color: hasToken ? Colors.green : Colors.amber, + ), ), - const Icon(Icons.chevron_right_rounded), ], ), + trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), ); } From 3b0e7f9c08cd12d1ebbd83203a4b6e6e27893da1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 23:37:32 -0700 Subject: [PATCH 21/28] use FrostyDialog for web session info dialog Co-Authored-By: Claude Opus 4.6 --- .../account/widgets/profile_card.dart | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 8592dcaa..bd3de82b 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -5,6 +5,7 @@ import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/account/account_options.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/profile_picture.dart'; class ProfileCard extends StatelessWidget { @@ -58,16 +59,19 @@ class ProfileCard extends StatelessWidget { onTap: () { showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Web session'), - content: Text( - hasToken - ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' - : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', - ), + builder: (context) => FrostyDialog( + title: 'Web session', + message: hasToken + ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' + : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', actions: [ if (!hasToken) TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + if (!hasToken) + FilledButton( onPressed: () { Navigator.pop(context); Navigator.push( @@ -79,10 +83,11 @@ class ProfileCard extends StatelessWidget { }, child: const Text('Log in'), ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), + if (hasToken) + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('OK'), + ), ], ), ); From 7839dc9135851a3734600e8699ca8de11c1d4537 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 23:50:32 -0700 Subject: [PATCH 22/28] mute unfocused tab icons on home page Co-Authored-By: Claude Opus 4.6 --- lib/screens/home/home.dart | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 7bc450c7..434d172f 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -166,9 +166,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.favorite_border_rounded, - color: _homeStore.selectedIndex == 0 - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.favorite_rounded, @@ -180,11 +179,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 1 : 0) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.arrow_upward_rounded, @@ -196,11 +192,8 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 2 : 1) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.search_rounded, From ad718e1bb2232167ce40ab5d6f21146fea52ebe7 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 00:04:42 -0700 Subject: [PATCH 23/28] refine list tile and section header typography Co-Authored-By: Claude Opus 4.6 --- lib/theme.dart | 17 +++++++++++++++++ lib/widgets/section_header.dart | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/theme.dart b/lib/theme.dart index 06216a11..a811afc1 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -196,6 +196,23 @@ class FrostyThemes { ), ), ), + listTileTheme: ListTileThemeData( + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: -0.011, + height: 1.5, + ), + subtitleTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 13, + height: 1.4, + ), + leadingAndTrailingTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), navigationBarTheme: NavigationBarThemeData( elevation: 0, backgroundColor: backgroundColor, diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 6ac20ac2..ed598760 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -42,7 +42,7 @@ class SectionHeader extends StatelessWidget { fontWeight: FontWeight.w500, letterSpacing: 0.8, color: context.colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, + alpha: 0.5, ), ), ), From f9141e3baed6b30642a7750de7fc8e56434d4dc1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 00:29:05 -0700 Subject: [PATCH 24/28] fix vertical alignment of web session icon in profile card Co-Authored-By: Claude Opus 4.6 --- .../account/widgets/profile_card.dart | 105 +++++++++--------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index bd3de82b..666be38a 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -46,61 +46,62 @@ class ProfileCard extends StatelessWidget { userLogin: authStore.user.details!.login, radius: 12, ), - title: Row( - children: [ - Flexible( - child: Text( - authStore.user.details!.displayName, - overflow: TextOverflow.ellipsis, + title: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Web session', + message: hasToken + ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' + : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', + actions: [ + if (!hasToken) + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + if (!hasToken) + FilledButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginWebView(), + ), + ); + }, + child: const Text('Log in'), + ), + if (hasToken) + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('OK'), + ), + ], ), - ), - const SizedBox(width: 6), - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Web session', - message: hasToken - ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' - : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', - actions: [ - if (!hasToken) - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('Cancel'), - ), - if (!hasToken) - FilledButton( - onPressed: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LoginWebView(), - ), - ); - }, - child: const Text('Log in'), - ), - if (hasToken) - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('OK'), - ), - ], + ); + }, + child: Text.rich( + TextSpan( + text: authStore.user.details!.displayName, + children: [ + const WidgetSpan(child: SizedBox(width: 6)), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded, + size: 16, + color: hasToken ? Colors.green : Colors.amber, ), - ); - }, - child: Icon( - hasToken - ? Icons.check_circle_outline_rounded - : Icons.info_outline_rounded, - size: 16, - color: hasToken ? Colors.green : Colors.amber, - ), + ), + ], ), - ], + overflow: TextOverflow.ellipsis, + ), ), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), From 3dbdd3501ab353f3995177ddb07e7b8261e99c82 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 00:34:14 -0700 Subject: [PATCH 25/28] fix inconsistent chat styles and section header typography Co-Authored-By: Claude Opus 4.6 --- lib/models/irc.dart | 4 +++- lib/screens/channel/chat/details/chat_modes.dart | 2 +- lib/screens/channel/chat/widgets/chat_message.dart | 8 ++++++-- lib/screens/channel/chat/widgets/reply_thread.dart | 2 +- lib/widgets/expandable_section_header.dart | 14 ++++++++------ 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 0cb0a6bf..8572d01f 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -176,7 +176,9 @@ class IRCMessage { size: badgeSize, color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? - Colors.grey.withValues(alpha: 0.5), + Theme.of(context).colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), ), ), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index b43ec476..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index d7645750..8beb8008 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -482,10 +482,14 @@ class ChatMessage extends StatelessWidget { messageHeaderIcon = Icon( Icons.campaign_rounded, size: messageHeaderIconSize, + color: messageHeaderTextColor, ); - messageHeader = const Text( + messageHeader = Text( 'Announcement', - style: TextStyle(fontWeight: messageHeaderFontWeight), + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), ); } diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 734408b3..7dc83394 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget { padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Text( '$replyName: $replyBody', - style: const TextStyle(fontWeight: FontWeight.w500), + style: const TextStyle(fontWeight: FontWeight.w600), ), ), Flexible( diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 64935101..ede5540f 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -51,11 +51,11 @@ class ExpandableSectionHeader extends StatelessWidget { child: Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurface.withValues( - alpha: 0.6, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, ), ), ), @@ -65,7 +65,9 @@ class ExpandableSectionHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), child: Icon( Icons.keyboard_arrow_down, - color: context.colorScheme.onSurfaceVariant, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), size: 20, ), ), From d22c98045440ce8e59aa759462cec1df844990d1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 01:30:36 -0700 Subject: [PATCH 26/28] fix native player freezing indefinitely on buffer stall Handle buffering/error activity states that were silently ignored, causing a frozen frame with no loading indicator. Add two-stage stall recovery (seek to live edge after 8s, full HLS refresh after 16s). Stop forcing paused state on transient API failures and restore playback on app resume unless the user explicitly paused. Co-Authored-By: Claude Opus 4.6 --- .../channel/video/native_video_store.dart | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 2f2f4244..704c9a99 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -37,9 +37,12 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { Timer? _overlayTimer; Timer? _latencyTimer; + Timer? _stallRecoveryTimer; + var _stallRecoveryAttempt = 0; DateTime? _lastStreamInfoUpdate; Future? _streamInfoRequest; bool _overlayWasVisibleBeforePip = true; + var _userPaused = false; List _qualityObjects = []; var _firstTimeSettingQuality = true; @@ -228,22 +231,53 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { case PlayerActivityState.playing: _loading = false; _paused = false; + _stallRecoveryTimer?.cancel(); + _stallRecoveryAttempt = 0; if (_pendingQualityIndex != null) { final index = _pendingQualityIndex!; _pendingQualityIndex = null; _setStreamQualityIndex(index); } + case PlayerActivityState.buffering: + case PlayerActivityState.loading: + _loading = true; + if (!_userPaused) _startStallRecoveryTimer(); + case PlayerActivityState.error: + _loading = false; + _stallRecoveryTimer?.cancel(); + _error = event.data?['message'] as String? ?? + 'Playback error. Try refreshing or switch to the standard player in Settings.'; case PlayerActivityState.paused: case PlayerActivityState.stopped: case PlayerActivityState.completed: case PlayerActivityState.idle: _paused = true; + _stallRecoveryTimer?.cancel(); default: break; } }); } + void _startStallRecoveryTimer() { + _stallRecoveryTimer?.cancel(); + _stallRecoveryTimer = Timer(const Duration(seconds: 8), () { + if (_disposed || !_loading || _userPaused) return; + _stallRecoveryAttempt++; + if (_stallRecoveryAttempt <= 1) { + // Light recovery: seek to live edge and resume. + debugPrint('NativeVideoStore: stall detected, seeking to live edge'); + _controller?.seekToLiveEdge(); + _controller?.play(); + _startStallRecoveryTimer(); + } else { + // Heavy recovery: new HLS token + full player restart. + debugPrint('NativeVideoStore: stall persists, refreshing player'); + handleRefresh(); + } + }); + } + void _startLatencyPolling() { _latencyTimer?.cancel(); if (settingsStore.autoSyncChatDelay) { @@ -314,8 +348,10 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { void handlePausePlay() { if (_controller == null) return; if (_paused) { + _userPaused = false; _controller!.play(); } else { + _userPaused = true; _controller!.pause(); } } @@ -341,6 +377,8 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _disposed = false; _loading = true; _paused = true; + _userPaused = false; + _stallRecoveryAttempt = 0; _firstTimeSettingQuality = true; _pendingQualityIndex = null; _isInPipMode = false; @@ -349,6 +387,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _error = null; _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); _pipSub?.cancel(); _qualitiesSub?.cancel(); _controller?.removeActivityListener(_handleActivityEvent); @@ -457,7 +496,6 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { runInAction(() { _overlayTimer?.cancel(); _streamInfo = null; - _paused = true; _offlineChannelInfo = channel; }); } @@ -467,6 +505,9 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action void handleAppResume() { updateStreamInfo(forceUpdate: true); + if (!_userPaused && _controller != null) { + _controller!.play(); + } } @override @@ -482,6 +523,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { _overlayTimer?.cancel(); _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); _pipSub?.cancel(); _qualitiesSub?.cancel(); _disposeAndroidAutoPipReaction?.call(); From 6695c13455183b89b0b1ca2a7e80bef703ee8163 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 01:45:15 -0700 Subject: [PATCH 27/28] clean up native video player implementation Extract shared stream quality key constant, add null guard to playback token parsing, fix handleRefresh race condition and concurrency, and replace Container with SizedBox placeholder. Co-Authored-By: Claude Opus 4.6 --- lib/constants.dart | 3 +++ lib/models/playback_access_token.dart | 10 ++++++++-- lib/screens/channel/channel.dart | 2 +- lib/screens/channel/video/native_video_store.dart | 8 ++++---- lib/screens/channel/video/video_overlay.dart | 3 ++- lib/screens/channel/video/video_store.dart | 3 ++- 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index eb17910b..a09e6d9e 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -6,6 +6,9 @@ const clientId = String.fromEnvironment('CLIENT_ID'); /// Twitch GQL client ID (web player, used for playback access tokens). const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; +/// SharedPreferences key for the last-selected stream quality. +const kLastStreamQualityKey = 'last_stream_quality'; + /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart index 244765d8..5c926a56 100644 --- a/lib/models/playback_access_token.dart +++ b/lib/models/playback_access_token.dart @@ -8,8 +8,14 @@ class PlaybackAccessToken { }); factory PlaybackAccessToken.fromGqlResponse(Map json) { - final data = json['data']['streamPlaybackAccessToken'] - as Map; + final data = + (json['data'] as Map?)?['streamPlaybackAccessToken'] + as Map?; + if (data == null) { + throw const FormatException( + 'streamPlaybackAccessToken missing from GQL response', + ); + } return PlaybackAccessToken( value: data['value'] as String, signature: data['signature'] as String, diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index f0a75617..8218c6ad 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -727,7 +727,7 @@ class _VideoChatState extends State if (settingsStore.showVideo) ...[ AspectRatio( aspectRatio: 16 / 9, - child: Container(), // Placeholder for video space + child: SizedBox(), ), ], Expanded(child: chat), diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 704c9a99..837634f5 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; @@ -188,7 +189,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { } else { SharedPreferences.getInstance().then((prefs) { if (_disposed) return; - final lastQuality = prefs.getString('last_stream_quality'); + final lastQuality = prefs.getString(kLastStreamQualityKey); if (lastQuality != null) { final index = _availableStreamQualities.indexOf(lastQuality); if (index != -1) _pendingQualityIndex = index; @@ -210,7 +211,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { } catch (e) { if (_disposed) return; // Wait for stream info so we can distinguish "offline" from "broken". - await updateStreamInfo(); + await updateStreamInfo(forceUpdate: true); if (_disposed) return; runInAction(() { _loading = false; @@ -374,7 +375,6 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { @action Future handleRefresh() async { HapticFeedback.lightImpact(); - _disposed = false; _loading = true; _paused = true; _userPaused = false; @@ -399,7 +399,7 @@ abstract class NativeVideoStoreBase with Store implements VideoPlayerInterface { ); _controller!.addActivityListener(_handleActivityEvent); - await _initPlayer(); + _initPlayer(); updateStreamInfo(); } diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 3ba00545..fff3f885 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/video/native_video_store.dart'; @@ -109,7 +110,7 @@ class VideoOverlay extends StatelessWidget { videoStore.setStreamQuality(quality); SharedPreferences.getInstance().then( (prefs) => prefs.setString( - 'last_stream_quality', + kLastStreamQualityKey, quality, ), ); diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 9634df86..f3db3b97 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/video/video_player_interface.dart'; @@ -88,7 +89,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { return; } final prefs = await SharedPreferences.getInstance(); - final lastStreamQuality = prefs.getString('last_stream_quality'); + final lastStreamQuality = prefs.getString(kLastStreamQualityKey); if (lastStreamQuality == null) return; setStreamQuality(lastStreamQuality); } From e73d9b255f6c9f4c013c458663535d08cb5ba653 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 9 Mar 2026 01:37:02 -0700 Subject: [PATCH 28/28] add VOD browsing with synchronized chat replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements VOD (past broadcast) browsing and chat replay that reuses the existing live chat rendering pipeline in read-only mode. Models & API: - video.dart: Helix video model with duration parsing (3h26m15s → Duration) - gql_chat_comment.dart: GQL VOD chat comment models (manual parsing) - IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format so the existing chat rendering pipeline (generateSpan, badges, emotes) works unchanged for VOD chat - TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination - TwitchGqlApi.getVideoComments(): GQL persisted query using mobile client ID (bypasses Client-Integrity) for VOD chat comments Architecture: - ChatRenderContext interface: minimal abstraction over ChatStore so ChatMessage widget works with both live ChatStore and VodChatStore - ChatStore now implements ChatRenderContext (one-line change) - ChatMessage accepts ChatRenderContext instead of ChatStore, with type guards for live-only actions (reply, paste, name tap navigation) VOD stores: - VodChatStore: MobX store implementing ChatRenderContext with identical message batching (200ms timer, 5000 limit, 100 render limit, 20% trim), scroll/autoscroll, playback position tracking, seek detection (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with pre-fetching when < 50 comments remain - VodListStore: standard paginated list store for VOD browsing VOD UI: - VodPlayer: matches live channel layout exactly (no AppBar, SafeArea, 16:9 WebView + expanded chat) with JS position polling every 1s + seeked event listener via VodPosition JS channel - VodList: paginated VOD list with scroll-based loading - VodCard: thumbnail card with duration overlay and relative dates - Entry point: "Past broadcasts" in chat details drawer Tests (21 new): - video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing - gql_chat_comment_test.dart: comment parsing, response error handling, IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user) - twitch_gql_api_test.dart: getVideoComments mock tests - twitch_api_test.dart: getVideos mock tests Co-Authored-By: Claude Opus 4.6 --- lib/apis/twitch_api.dart | 18 ++ lib/apis/twitch_gql_api.dart | 50 ++- lib/constants.dart | 3 + lib/models/gql_chat_comment.dart | 137 +++++++++ lib/models/irc.dart | 51 ++++ lib/models/video.dart | 60 ++++ lib/models/video.g.dart | 28 ++ .../channel/chat/chat_render_context.dart | 18 ++ .../channel/chat/details/chat_details.dart | 15 + .../channel/chat/stores/chat_store.dart | 10 +- .../channel/chat/widgets/chat_message.dart | 102 ++++--- .../channel/vod/stores/vod_chat_store.dart | 284 ++++++++++++++++++ .../channel/vod/stores/vod_chat_store.g.dart | 203 +++++++++++++ .../channel/vod/stores/vod_list_store.dart | 73 +++++ .../channel/vod/stores/vod_list_store.g.dart | 108 +++++++ lib/screens/channel/vod/vod_card.dart | 184 ++++++++++++ lib/screens/channel/vod/vod_list.dart | 82 +++++ lib/screens/channel/vod/vod_player.dart | 282 +++++++++++++++++ pubspec.lock | 16 +- test/apis/twitch_api_test.dart | 60 ++++ test/apis/twitch_gql_api_test.dart | 208 +++++++++++++ test/models/gql_chat_comment_test.dart | 216 +++++++++++++ test/models/video_test.dart | 128 ++++++++ 23 files changed, 2279 insertions(+), 57 deletions(-) create mode 100644 lib/models/gql_chat_comment.dart create mode 100644 lib/models/video.dart create mode 100644 lib/models/video.g.dart create mode 100644 lib/screens/channel/chat/chat_render_context.dart create mode 100644 lib/screens/channel/vod/stores/vod_chat_store.dart create mode 100644 lib/screens/channel/vod/stores/vod_chat_store.g.dart create mode 100644 lib/screens/channel/vod/stores/vod_list_store.dart create mode 100644 lib/screens/channel/vod/stores/vod_list_store.g.dart create mode 100644 lib/screens/channel/vod/vod_card.dart create mode 100644 lib/screens/channel/vod/vod_list.dart create mode 100644 lib/screens/channel/vod/vod_player.dart create mode 100644 test/apis/twitch_gql_api_test.dart create mode 100644 test/models/gql_chat_comment_test.dart create mode 100644 test/models/video_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..b03f2f3c 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; +import 'package:frosty/models/video.dart'; /// The Twitch service for making API calls. class TwitchApi extends BaseApiClient { @@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } + /// Returns a [VideosTwitch] object containing past broadcasts for the given [userId]. + Future getVideos({ + required String userId, + String? cursor, + }) async { + final queryParams = { + 'user_id': userId, + 'first': '20', + 'type': 'archive', + }; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get('/videos', queryParameters: queryParams); + + return VideosTwitch.fromJson(data); + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 11f67102..24a9b83f 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,12 +3,13 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; +import 'package:frosty/models/gql_chat_comment.dart'; import 'package:frosty/models/playback_access_token.dart'; -/// Twitch GQL API client for fetching playback access tokens. +/// Twitch GQL API client for playback access tokens and VOD chat. /// -/// Uses the Twitch web client ID (not the app's registered client ID) -/// since the GQL endpoint requires it. +/// Uses the Twitch web client ID for playback tokens and the mobile +/// client ID for VOD chat (bypasses Client-Integrity checks). class TwitchGqlApi extends BaseApiClient { TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); @@ -66,4 +67,47 @@ class TwitchGqlApi extends BaseApiClient { '&playlist_include_framerate=true' '&p=$random'; } + + /// Fetches VOD chat comments for the given [videoId]. + /// + /// Pass [contentOffsetSeconds] for initial fetch at a position, or + /// [cursor] for pagination from a previous response. + Future getVideoComments({ + required String videoId, + int? contentOffsetSeconds, + String? cursor, + }) async { + final headers = { + 'Client-ID': twitchGqlMobileClientId, + }; + + final variables = { + 'videoID': videoId, + }; + if (cursor != null) { + variables['cursor'] = cursor; + } else { + variables['contentOffsetSeconds'] = contentOffsetSeconds ?? 0; + } + + final body = { + 'operationName': 'VideoCommentsByOffsetOrCursor', + 'variables': variables, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': + 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', + }, + }, + }; + + final response = await post( + '/gql', + data: body, + headers: headers, + ); + + return GqlChatResponse.fromJson(response); + } } diff --git a/lib/constants.dart b/lib/constants.dart index a09e6d9e..a81d1943 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -9,6 +9,9 @@ const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; /// SharedPreferences key for the last-selected stream quality. const kLastStreamQualityKey = 'last_stream_quality'; +/// Twitch GQL mobile client ID (bypasses Client-Integrity checks, used for VOD chat). +const twitchGqlMobileClientId = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp'; + /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); diff --git a/lib/models/gql_chat_comment.dart b/lib/models/gql_chat_comment.dart new file mode 100644 index 00000000..bdbb628a --- /dev/null +++ b/lib/models/gql_chat_comment.dart @@ -0,0 +1,137 @@ +// Models for Twitch GQL VOD chat comments. +// +// Manually parsed (no codegen) because the GQL response uses camelCase +// and deeply nested edges/node patterns. + +class GqlChatEmote { + final String emoteId; + + const GqlChatEmote({required this.emoteId}); + + factory GqlChatEmote.fromJson(Map json) { + return GqlChatEmote(emoteId: json['emoteID'] as String); + } +} + +class GqlChatFragment { + final String? text; + final GqlChatEmote? emote; + + const GqlChatFragment({this.text, this.emote}); + + factory GqlChatFragment.fromJson(Map json) { + return GqlChatFragment( + text: json['text'] as String?, + emote: json['emote'] != null + ? GqlChatEmote.fromJson(json['emote'] as Map) + : null, + ); + } +} + +class GqlChatBadge { + final String setId; + final String version; + + const GqlChatBadge({required this.setId, required this.version}); + + factory GqlChatBadge.fromJson(Map json) { + return GqlChatBadge( + setId: json['setID'] as String, + version: json['version'] as String, + ); + } +} + +class GqlChatComment { + final String id; + final int contentOffsetSeconds; + final String? commenterLogin; + final String? commenterDisplayName; + final String? commenterId; + final String? userColor; + final List userBadges; + final List messageFragments; + + const GqlChatComment({ + required this.id, + required this.contentOffsetSeconds, + this.commenterLogin, + this.commenterDisplayName, + this.commenterId, + this.userColor, + required this.userBadges, + required this.messageFragments, + }); + + factory GqlChatComment.fromGqlNode(Map node) { + final commenter = node['commenter'] as Map?; + final message = node['message'] as Map; + + return GqlChatComment( + id: node['id'] as String, + contentOffsetSeconds: node['contentOffsetSeconds'] as int, + commenterLogin: commenter?['login'] as String?, + commenterDisplayName: commenter?['displayName'] as String?, + commenterId: commenter?['id'] as String?, + userColor: message['userColor'] as String?, + userBadges: (message['userBadges'] as List) + .map((b) => GqlChatBadge.fromJson(b as Map)) + .toList(), + messageFragments: (message['fragments'] as List) + .map((f) => GqlChatFragment.fromJson(f as Map)) + .toList(), + ); + } +} + +class GqlChatResponse { + final List comments; + final bool hasNextPage; + final String? cursor; + + const GqlChatResponse({ + required this.comments, + required this.hasNextPage, + this.cursor, + }); + + factory GqlChatResponse.fromJson(Map json) { + // GQL returns 200 even on errors — check for errors array. + if (json['errors'] != null) { + final errors = json['errors'] as List; + final message = errors.isNotEmpty + ? (errors.first as Map)['message'] as String? + : 'Unknown GQL error'; + throw Exception('GQL error: $message'); + } + + final video = json['data']?['video'] as Map?; + if (video == null) { + return const GqlChatResponse( + comments: [], + hasNextPage: false, + ); + } + + final commentsData = video['comments'] as Map; + final edges = commentsData['edges'] as List; + final pageInfo = commentsData['pageInfo'] as Map; + + final comments = edges + .map((edge) => GqlChatComment.fromGqlNode( + (edge as Map)['node'] as Map, + )) + .toList(); + + final cursor = edges.isNotEmpty + ? (edges.last as Map)['cursor'] as String? + : null; + + return GqlChatResponse( + comments: comments, + hasNextPage: pageInfo['hasNextPage'] as bool, + cursor: cursor, + ); + } +} diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 8572d01f..cb214177 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -8,6 +8,7 @@ import 'package:frosty/cache_manager.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/gql_chat_comment.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -1155,6 +1156,56 @@ class IRCMessage { ); } + /// Creates an [IRCMessage] from a GQL VOD chat comment. + /// + /// Maps GQL fields to IRC tags so [generateSpan] works identically + /// for both live and VOD chat messages. + factory IRCMessage.fromGqlComment(GqlChatComment comment) { + final tags = { + 'display-name': comment.commenterDisplayName ?? 'Deleted User', + 'user-id': comment.commenterId ?? '', + 'id': comment.id, + 'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}', + if (comment.userColor != null) 'color': comment.userColor!, + if (comment.userBadges.isNotEmpty) + 'badges': comment.userBadges + .map((b) => '${b.setId}/${b.version}') + .join(','), + }; + + // Reconstruct message text from fragments. + final messageText = comment.messageFragments + .map((f) => f.text ?? '') + .join(); + + // Build local emotes map from fragments that have Twitch emotes. + // generateSpan() will match these by name in the split words. + final localEmotes = {}; + for (final fragment in comment.messageFragments) { + if (fragment.emote != null && fragment.text != null) { + localEmotes[fragment.text!] = Emote( + name: fragment.text!, + zeroWidth: false, + url: + '$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix', + type: EmoteType.twitchSub, + ); + } + } + + return IRCMessage( + raw: '', + command: Command.privateMessage, + tags: tags, + user: comment.commenterLogin, + localEmotes: localEmotes.isNotEmpty ? localEmotes : null, + message: messageText, + split: messageText.split(' ').where((w) => w.isNotEmpty).toList(), + action: false, + mention: false, + ); + } + factory IRCMessage.createNotice({ required String message, VoidCallback? actionCallback, diff --git a/lib/models/video.dart b/lib/models/video.dart new file mode 100644 index 00000000..26bdc882 --- /dev/null +++ b/lib/models/video.dart @@ -0,0 +1,60 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'video.g.dart'; + +/// Object for Twitch VODs (past broadcasts). +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class VideoTwitch { + final String id; + final String? streamId; + final String userId; + final String userLogin; + final String userName; + final String title; + final String createdAt; + final String publishedAt; + final String duration; + final String thumbnailUrl; + final int viewCount; + + const VideoTwitch( + this.id, + this.streamId, + this.userId, + this.userLogin, + this.userName, + this.title, + this.createdAt, + this.publishedAt, + this.duration, + this.thumbnailUrl, + this.viewCount, + ); + + factory VideoTwitch.fromJson(Map json) => + _$VideoTwitchFromJson(json); +} + +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class VideosTwitch { + final List data; + final Map pagination; + + const VideosTwitch(this.data, this.pagination); + + factory VideosTwitch.fromJson(Map json) => + _$VideosTwitchFromJson(json); +} + +/// Parses a Twitch duration string like "3h26m15s" into a [Duration]. +Duration parseTwitchDuration(String duration) { + final regex = RegExp(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?'); + final match = regex.firstMatch(duration); + if (match == null) return Duration.zero; + + final hours = int.tryParse(match.group(1) ?? '') ?? 0; + final minutes = int.tryParse(match.group(2) ?? '') ?? 0; + final seconds = int.tryParse(match.group(3) ?? '') ?? 0; + + return Duration(hours: hours, minutes: minutes, seconds: seconds); +} diff --git a/lib/models/video.g.dart b/lib/models/video.g.dart new file mode 100644 index 00000000..202fa376 --- /dev/null +++ b/lib/models/video.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'video.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VideoTwitch _$VideoTwitchFromJson(Map json) => VideoTwitch( + json['id'] as String, + json['stream_id'] as String?, + json['user_id'] as String, + json['user_login'] as String, + json['user_name'] as String, + json['title'] as String, + json['created_at'] as String, + json['published_at'] as String, + json['duration'] as String, + json['thumbnail_url'] as String, + (json['view_count'] as num).toInt(), +); + +VideosTwitch _$VideosTwitchFromJson(Map json) => VideosTwitch( + (json['data'] as List) + .map((e) => VideoTwitch.fromJson(e as Map)) + .toList(), + Map.from(json['pagination'] as Map), +); diff --git a/lib/screens/channel/chat/chat_render_context.dart b/lib/screens/channel/chat/chat_render_context.dart new file mode 100644 index 00000000..533dc668 --- /dev/null +++ b/lib/screens/channel/chat/chat_render_context.dart @@ -0,0 +1,18 @@ +import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mobx/mobx.dart'; + +/// Minimal interface for rendering chat messages. +/// +/// Implemented by both [ChatStore] (live chat) and [VodChatStore] (VOD replay) +/// so that [ChatMessage] can render messages from either source. +abstract class ChatRenderContext { + ChatAssetsStore get assetsStore; + AuthStore get auth; + SettingsStore get settings; + String get channelId; + ObservableSet get revealedMessageIds; + void revealMessage(String id); + void updateNotification(String message); +} diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 987e15bf..e8563d78 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/vod/vod_list.dart'; import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; @@ -371,6 +372,20 @@ class _ChatDetailsState extends State { ), ), ), + ListTile( + leading: const Icon(Icons.video_library_rounded), + title: const Text('Past broadcasts'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VodList( + userId: widget.chatStore.channelId, + displayName: widget.chatStore.displayName, + ), + ), + ), + ), ListTile( leading: const Icon(Icons.add_comment_rounded), title: const Text('Add chat'), diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..3526c955 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/events.dart'; import 'package:frosty/models/irc.dart'; +import 'package:frosty/screens/channel/chat/chat_render_context.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -22,7 +23,7 @@ part 'chat_store.g.dart'; /// The store and view-model for chat-related activities. class ChatStore = ChatStoreBase with _$ChatStore; -abstract class ChatStoreBase with Store { +abstract class ChatStoreBase with Store implements ChatRenderContext { /// The total maximum amount of messages in chat. static const _messageLimit = 5000; @@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store { final _messagesToRemove = (_messageLimit * 0.2).toInt(); /// The provided auth store to determine login status, get the token, and use the headers for requests. + @override final AuthStore auth; /// The provided setting store to account for any user-defined behaviors. + @override final SettingsStore settings; /// The focus node for the textfield to allow for showing and hiding the keyboard/focus. @@ -111,6 +114,7 @@ abstract class ChatStoreBase with Store { final String channelName; /// The channel's ID for API requests. + @override final String channelId; /// The channel's display name to show on widgets. @@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store { final ChatDetailsStore chatDetailsStore; /// The assets store responsible for badges, emotes, and the emote menu. + @override final ChatAssetsStore assetsStore; /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. @@ -175,8 +180,10 @@ abstract class ChatStoreBase with Store { final _pendingSevenTVCallbacks = <_DelayedCallback>[]; /// The set of message IDs that have been revealed by the user (for deleted messages). + @override final revealedMessageIds = ObservableSet(); + @override @action void revealMessage(String id) { revealedMessageIds.add(id); @@ -1269,6 +1276,7 @@ abstract class ChatStoreBase with Store { } /// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage]. + @override @action void updateNotification(String notificationMessage) { // Cancel the previous notification to prevent the notification from phasing in and out diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 8beb8008..bdad638d 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/channel/chat/chat_render_context.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; @@ -15,7 +16,7 @@ import 'package:provider/provider.dart'; class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; - final ChatStore chatStore; + final ChatRenderContext chatStore; final bool isModal; final bool showReplyHeader; final bool isInReplyThread; @@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget { // Ignore if the message is a recent message in the modal bottom sheet. if (isModal) return; + // User modal requires live ChatStore (recent messages, blocking, etc.). + if (chatStore is! ChatStore) return; + // Ignore if long-pressing own username. if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) { @@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget { isScrollControlled: true, context: context, builder: (context) => ChatUserModal( - chatStore: chatStore, + chatStore: chatStore as ChatStore, username: ircMessage.user!, userId: ircMessage.tags['user-id']!, displayName: ircMessage.tags['display-name']!, @@ -73,6 +77,9 @@ class ChatMessage extends StatelessWidget { // Ignore if the message is a recent message in the modal bottom sheet. if (isModal) return; + // User modal requires live ChatStore. + if (chatStore is! ChatStore) return; + final twitchApi = context.read(); twitchApi.getUser(userLogin: nickname).then((user) { if (context.mounted) { @@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget { isScrollControlled: true, context: context, builder: (context) => ChatUserModal( - chatStore: chatStore, + chatStore: chatStore as ChatStore, username: user.login, userId: user.id, displayName: user.displayName, @@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget { } /// The store to use for notifications and emote menu in merged mode. - ChatStore get _effectiveInputStore => inputChatStore ?? chatStore; + ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore; Future copyMessage() async { await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); @@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.copy), title: const Text('Copy message'), ), - ListTile( - onTap: () async { - await copyMessage(); + if (chatStore is ChatStore) ...[ + ListTile( + onTap: () async { + await copyMessage(); + final liveChatStore = chatStore as ChatStore; - final effectiveChatDelay = chatStore.settings.effectiveChatDelay; + final effectiveChatDelay = + liveChatStore.settings.effectiveChatDelay; - final hasChatDelay = - chatStore.settings.showVideo && effectiveChatDelay > 0; + final hasChatDelay = + liveChatStore.settings.showVideo && effectiveChatDelay > 0; - if (hasChatDelay) { - _effectiveInputStore.updateNotification( - 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', - ); + if (hasChatDelay) { + _effectiveInputStore.updateNotification( + 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', + ); + if (context.mounted) { + Navigator.pop(context); + } + return; + } + + // In merged mode, switch to the source tab so the paste + // targets the correct channel's composer. + onActivateSourceTab?.call(); + + // Paste the copied message into the text controller + liveChatStore.textController.text = ircMessage.message ?? ''; + liveChatStore.safeRequestFocus(); if (context.mounted) { Navigator.pop(context); } - return; - } + }, + leading: const Icon(Icons.content_paste), + title: const Text('Copy message and paste'), + ), + ListTile( + onTap: () { + final liveChatStore = chatStore as ChatStore; + // In merged mode, switch to the source tab so the reply + // targets the correct channel (reply-parent-msg-id is + // channel-scoped). + onActivateSourceTab?.call(); - // In merged mode, switch to the source tab so the paste - // targets the correct channel's composer. - onActivateSourceTab?.call(); - - // Paste the copied message into the text controller - chatStore.textController.text = ircMessage.message ?? ''; - chatStore.safeRequestFocus(); - if (context.mounted) { + liveChatStore.replyingToMessage = ircMessage; + liveChatStore.safeRequestFocus(); Navigator.pop(context); - } - }, - leading: const Icon(Icons.content_paste), - title: const Text('Copy message and paste'), - ), - ListTile( - onTap: () { - // In merged mode, switch to the source tab so the reply - // targets the correct channel (reply-parent-msg-id is - // channel-scoped). - onActivateSourceTab?.call(); - - chatStore.replyingToMessage = ircMessage; - chatStore.safeRequestFocus(); - Navigator.pop(context); - }, - leading: const Icon(Icons.reply), - title: const Text('Reply to message'), - ), + }, + leading: const Icon(Icons.reply), + title: const Text('Reply to message'), + ), + ], ], ), ); @@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget { color: messageHeaderTextColor, ); messageHeader = GestureDetector( - onTap: isModal + onTap: isModal || chatStore is! ChatStore ? null : () => showModalBottomSheetWithProperFocus( context: context, builder: (context) { return ReplyThread( selectedMessage: ircMessage, - chatStore: chatStore, + chatStore: chatStore as ChatStore, ); }, ), diff --git a/lib/screens/channel/vod/stores/vod_chat_store.dart b/lib/screens/channel/vod/stores/vod_chat_store.dart new file mode 100644 index 00000000..f1c81b94 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/gql_chat_comment.dart'; +import 'package:frosty/models/irc.dart'; +import 'package:frosty/screens/channel/chat/chat_render_context.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mobx/mobx.dart'; + +part 'vod_chat_store.g.dart'; + +class VodChatStore = VodChatStoreBase with _$VodChatStore; + +abstract class VodChatStoreBase with Store implements ChatRenderContext { + /// The total maximum amount of messages in chat. + static const _messageLimit = 5000; + + /// The maximum amount of messages to render when autoscroll is enabled. + static const _renderMessageLimit = 100; + + @override + final ChatAssetsStore assetsStore; + + @override + final AuthStore auth; + + @override + final SettingsStore settings; + + @override + final String channelId; + + @override + final revealedMessageIds = ObservableSet(); + + final TwitchGqlApi twitchGqlApi; + final String videoId; + + /// The scroll controller that controls auto-scroll and resume-scroll behavior. + final scrollController = ScrollController(); + + /// The amount of messages to free (remove) when the [_messageLimit] is reached. + final _messagesToRemove = (_messageLimit * 0.2).toInt(); + + /// The periodic timer used for batching chat message re-renders. + Timer? _messageBufferTimer; + + /// The list of chat messages to add once autoscroll is resumed. + final messageBuffer = ObservableList(); + + /// Timer used for dismissing the notification. + Timer? _notificationTimer; + + /// Fetched GQL comments waiting to be advanced based on playback position. + final _commentBuffer = Queue(); + + /// Cursor for fetching the next page of comments. + String? _nextCursor; + + /// Whether we're currently fetching comments from the API. + bool _isFetching = false; + + /// The last rendered content offset to avoid duplicates. + int _lastRenderedOffset = -1; + + /// A notification message to display above the chat. + @readonly + String? _notification; + + /// The list of chat messages to render and display. + @readonly + var _messages = ObservableList(); + + /// If the chat should automatically scroll/jump to the latest message. + @readonly + var _autoScroll = true; + + /// Current playback position in seconds. + @observable + int playbackPosition = 0; + + /// Whether the store has been initialized. + @readonly + var _isInitialized = false; + + /// The list of chat messages that should be rendered. Used to prevent jank when resuming scroll. + @computed + List get renderMessages { + // If autoscroll is disabled, render ALL messages in chat. + // The second condition is to prevent an out of index error with sublist. + if (!_autoScroll || _messages.length < _renderMessageLimit) { + return _messages; + } + + // When autoscroll is enabled, only show the first [_renderMessageLimit] messages. + return _messages.sublist(_messages.length - _renderMessageLimit); + } + + VodChatStoreBase({ + required this.assetsStore, + required this.auth, + required this.settings, + required this.channelId, + required this.twitchGqlApi, + required this.videoId, + }); + + Future init() async { + await Future.wait([ + assetsStore.init().then((_) => _loadAssets()), + _fetchComments(contentOffsetSeconds: 0), + ]); + _startMessageBufferTimer(); + _setupScrollListener(); + _isInitialized = true; + } + + Future _loadAssets() async { + await assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showBTTVBadges: settings.showBTTVBadges, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + } + + void _startMessageBufferTimer() { + _messageBufferTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => addMessages(), + ); + } + + void _setupScrollListener() { + scrollController.addListener(() { + if (!scrollController.hasClients) return; + final isAtBottom = scrollController.offset <= 0; + if (isAtBottom != _autoScroll) { + _autoScroll = isAtBottom; + if (_autoScroll) addMessages(); + } + }); + } + + @action + void addMessages() { + if (!_autoScroll || messageBuffer.isEmpty) return; + + _messages.addAll(messageBuffer); + messageBuffer.clear(); + + if (_messages.length > _messageLimit) { + _messages.removeRange(0, _messagesToRemove); + } + } + + /// Re-enables [_autoScroll] and jumps to the latest message. + @action + void resumeScroll() { + _autoScroll = true; + + scrollController.jumpTo(0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.jumpTo(0); + }); + } + + @override + @action + void revealMessage(String id) { + revealedMessageIds.add(id); + } + + @override + @action + void updateNotification(String message) { + _notificationTimer?.cancel(); + _notification = message; + _notificationTimer = Timer(const Duration(seconds: 5), () { + _notification = null; + }); + } + + @action + void clearNotification() { + _notificationTimer?.cancel(); + _notification = null; + } + + /// Called from the JS channel with the current video position. + @action + void updatePlaybackPosition(int offsetSeconds) { + final previousPosition = playbackPosition; + playbackPosition = offsetSeconds; + + // Seek detection: if position jumped significantly, re-fetch. + final delta = offsetSeconds - previousPosition; + if (delta < -5 || delta > 10) { + handleSeek(offsetSeconds); + return; + } + + // Advance comments from buffer to messageBuffer. + _advanceComments(offsetSeconds); + + // Pre-fetch when running low. + if (_commentBuffer.length < 50 && !_isFetching && _nextCursor != null) { + _fetchComments(cursor: _nextCursor); + } + } + + void _advanceComments(int offsetSeconds) { + while (_commentBuffer.isNotEmpty && + _commentBuffer.first.contentOffsetSeconds <= offsetSeconds) { + final comment = _commentBuffer.removeFirst(); + if (comment.contentOffsetSeconds > _lastRenderedOffset || + _lastRenderedOffset == -1) { + _lastRenderedOffset = comment.contentOffsetSeconds; + messageBuffer.add(IRCMessage.fromGqlComment(comment)); + } + } + } + + /// Handles an explicit seek to [offsetSeconds], clearing state and re-fetching. + @action + Future handleSeek(int offsetSeconds) async { + playbackPosition = offsetSeconds; + _commentBuffer.clear(); + messageBuffer.clear(); + _messages.clear(); + _lastRenderedOffset = -1; + _nextCursor = null; + await _fetchComments(contentOffsetSeconds: offsetSeconds); + } + + Future _fetchComments({ + int? contentOffsetSeconds, + String? cursor, + }) async { + if (_isFetching) return; + _isFetching = true; + try { + final response = await twitchGqlApi.getVideoComments( + videoId: videoId, + contentOffsetSeconds: contentOffsetSeconds, + cursor: cursor, + ); + _commentBuffer.addAll(response.comments); + if (response.hasNextPage) { + _nextCursor = response.cursor; + } else { + _nextCursor = null; + } + } catch (e) { + debugPrint('Failed to fetch VOD comments: $e'); + } finally { + _isFetching = false; + } + } + + void dispose() { + _messageBufferTimer?.cancel(); + _notificationTimer?.cancel(); + scrollController.dispose(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_chat_store.g.dart b/lib/screens/channel/vod/stores/vod_chat_store.g.dart new file mode 100644 index 00000000..dbf39b1a --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_chat_store.g.dart @@ -0,0 +1,203 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vod_chat_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$VodChatStore on VodChatStoreBase, Store { + Computed>? _$renderMessagesComputed; + + @override + List get renderMessages => + (_$renderMessagesComputed ??= Computed>( + () => super.renderMessages, + name: 'VodChatStoreBase.renderMessages', + )).value; + + late final _$_notificationAtom = Atom( + name: 'VodChatStoreBase._notification', + context: context, + ); + + String? get notification { + _$_notificationAtom.reportRead(); + return super._notification; + } + + @override + String? get _notification => notification; + + @override + set _notification(String? value) { + _$_notificationAtom.reportWrite(value, super._notification, () { + super._notification = value; + }); + } + + late final _$_messagesAtom = Atom( + name: 'VodChatStoreBase._messages', + context: context, + ); + + ObservableList get messages { + _$_messagesAtom.reportRead(); + return super._messages; + } + + @override + ObservableList get _messages => messages; + + @override + set _messages(ObservableList value) { + _$_messagesAtom.reportWrite(value, super._messages, () { + super._messages = value; + }); + } + + late final _$_autoScrollAtom = Atom( + name: 'VodChatStoreBase._autoScroll', + context: context, + ); + + bool get autoScroll { + _$_autoScrollAtom.reportRead(); + return super._autoScroll; + } + + @override + bool get _autoScroll => autoScroll; + + @override + set _autoScroll(bool value) { + _$_autoScrollAtom.reportWrite(value, super._autoScroll, () { + super._autoScroll = value; + }); + } + + late final _$playbackPositionAtom = Atom( + name: 'VodChatStoreBase.playbackPosition', + context: context, + ); + + @override + int get playbackPosition { + _$playbackPositionAtom.reportRead(); + return super.playbackPosition; + } + + @override + set playbackPosition(int value) { + _$playbackPositionAtom.reportWrite(value, super.playbackPosition, () { + super.playbackPosition = value; + }); + } + + late final _$_isInitializedAtom = Atom( + name: 'VodChatStoreBase._isInitialized', + context: context, + ); + + bool get isInitialized { + _$_isInitializedAtom.reportRead(); + return super._isInitialized; + } + + @override + bool get _isInitialized => isInitialized; + + @override + set _isInitialized(bool value) { + _$_isInitializedAtom.reportWrite(value, super._isInitialized, () { + super._isInitialized = value; + }); + } + + late final _$VodChatStoreBaseActionController = ActionController( + name: 'VodChatStoreBase', + context: context, + ); + + @override + void addMessages() { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.addMessages', + ); + try { + return super.addMessages(); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void resumeScroll() { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.resumeScroll', + ); + try { + return super.resumeScroll(); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void revealMessage(String id) { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.revealMessage', + ); + try { + return super.revealMessage(id); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void updateNotification(String message) { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.updateNotification', + ); + try { + return super.updateNotification(message); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void clearNotification() { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.clearNotification', + ); + try { + return super.clearNotification(); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void updatePlaybackPosition(int offsetSeconds) { + final _$actionInfo = _$VodChatStoreBaseActionController.startAction( + name: 'VodChatStoreBase.updatePlaybackPosition', + ); + try { + return super.updatePlaybackPosition(offsetSeconds); + } finally { + _$VodChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +playbackPosition: ${playbackPosition}, +renderMessages: ${renderMessages} + '''; + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.dart b/lib/screens/channel/vod/stores/vod_list_store.dart new file mode 100644 index 00000000..997723ed --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/video.dart'; +import 'package:mobx/mobx.dart'; + +part 'vod_list_store.g.dart'; + +class VodListStore = VodListStoreBase with _$VodListStore; + +abstract class VodListStoreBase with Store { + final TwitchApi twitchApi; + final String userId; + + @readonly + var _videos = ObservableList(); + + @readonly + var _isLoading = false; + + @readonly + var _error = ''; + + String? _cursor; + bool _hasMore = true; + + VodListStoreBase({ + required this.twitchApi, + required this.userId, + }); + + @action + Future fetchVideos() async { + _isLoading = true; + _error = ''; + try { + final response = await twitchApi.getVideos(userId: userId); + _videos = response.data.asObservable(); + _cursor = response.pagination['cursor']; + _hasMore = response.data.length >= 20; + } catch (e) { + _error = e.toString(); + debugPrint('Failed to fetch VODs: $e'); + } finally { + _isLoading = false; + } + } + + @action + Future fetchMore() async { + if (_isLoading || !_hasMore || _cursor == null) return; + _isLoading = true; + try { + final response = await twitchApi.getVideos( + userId: userId, + cursor: _cursor, + ); + _videos.addAll(response.data); + _cursor = response.pagination['cursor']; + _hasMore = response.data.length >= 20; + } catch (e) { + debugPrint('Failed to fetch more VODs: $e'); + } finally { + _isLoading = false; + } + } + + @action + Future refresh() async { + _cursor = null; + _hasMore = true; + await fetchVideos(); + } +} diff --git a/lib/screens/channel/vod/stores/vod_list_store.g.dart b/lib/screens/channel/vod/stores/vod_list_store.g.dart new file mode 100644 index 00000000..dbd132b2 --- /dev/null +++ b/lib/screens/channel/vod/stores/vod_list_store.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vod_list_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$VodListStore on VodListStoreBase, Store { + late final _$_videosAtom = Atom( + name: 'VodListStoreBase._videos', + context: context, + ); + + ObservableList get videos { + _$_videosAtom.reportRead(); + return super._videos; + } + + @override + ObservableList get _videos => videos; + + @override + set _videos(ObservableList value) { + _$_videosAtom.reportWrite(value, super._videos, () { + super._videos = value; + }); + } + + late final _$_isLoadingAtom = Atom( + name: 'VodListStoreBase._isLoading', + context: context, + ); + + bool get isLoading { + _$_isLoadingAtom.reportRead(); + return super._isLoading; + } + + @override + bool get _isLoading => isLoading; + + @override + set _isLoading(bool value) { + _$_isLoadingAtom.reportWrite(value, super._isLoading, () { + super._isLoading = value; + }); + } + + late final _$_errorAtom = Atom( + name: 'VodListStoreBase._error', + context: context, + ); + + String get error { + _$_errorAtom.reportRead(); + return super._error; + } + + @override + String get _error => error; + + @override + set _error(String value) { + _$_errorAtom.reportWrite(value, super._error, () { + super._error = value; + }); + } + + late final _$fetchVideosAsyncAction = AsyncAction( + 'VodListStoreBase.fetchVideos', + context: context, + ); + + @override + Future fetchVideos() { + return _$fetchVideosAsyncAction.run(() => super.fetchVideos()); + } + + late final _$fetchMoreAsyncAction = AsyncAction( + 'VodListStoreBase.fetchMore', + context: context, + ); + + @override + Future fetchMore() { + return _$fetchMoreAsyncAction.run(() => super.fetchMore()); + } + + late final _$refreshAsyncAction = AsyncAction( + 'VodListStoreBase.refresh', + context: context, + ); + + @override + Future refresh() { + return _$refreshAsyncAction.run(() => super.refresh()); + } + + @override + String toString() { + return ''' + + '''; + } +} diff --git a/lib/screens/channel/vod/vod_card.dart b/lib/screens/channel/vod/vod_card.dart new file mode 100644 index 00000000..0fed02c5 --- /dev/null +++ b/lib/screens/channel/vod/vod_card.dart @@ -0,0 +1,184 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/video.dart'; +import 'package:frosty/screens/channel/vod/vod_player.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/skeleton_loader.dart'; + +/// A tappable card widget that displays a VOD's thumbnail and details. +class VodCard extends StatelessWidget { + final VideoTwitch video; + + const VodCard({super.key, required this.video}); + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h${minutes.toString().padLeft(2, '0')}m${seconds.toString().padLeft(2, '0')}s'; + } + return '${minutes}m${seconds.toString().padLeft(2, '0')}s'; + } + + String _formatRelativeDate(String createdAt) { + final date = DateTime.tryParse(createdAt); + if (date == null) return createdAt; + + final now = DateTime.now().toUtc(); + final difference = now.difference(date); + + if (difference.inDays >= 365) { + final years = difference.inDays ~/ 365; + return '$years ${years == 1 ? 'year' : 'years'} ago'; + } else if (difference.inDays >= 30) { + final months = difference.inDays ~/ 30; + return '$months ${months == 1 ? 'month' : 'months'} ago'; + } else if (difference.inDays >= 1) { + return '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago'; + } else { + return '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago'; + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920); + final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080); + + final duration = parseTwitchDuration(video.duration); + + final thumbnailUrl = video.thumbnailUrl + .replaceFirst('%{width}', '$thumbnailWidth') + .replaceFirst('%{height}', '$thumbnailHeight'); + + final thumbnail = AspectRatio( + aspectRatio: 16 / 9, + child: thumbnailUrl.isEmpty + ? Container(color: Colors.black12) + : FrostyCachedNetworkImage( + imageUrl: thumbnailUrl, + placeholder: (context, url) => const SkeletonLoader( + borderRadius: kCardBorderRadius, + ), + useOldImageOnUrlChange: true, + ), + ); + + final fontColor = DefaultTextStyle.of(context).style.color; + const subFontSize = 14.0; + + final imageSection = ClipRRect( + borderRadius: kCardBorderRadius, + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + thumbnail, + Container( + margin: const EdgeInsets.all(3), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: context.frostyColors.overlayOnSurface, + ), + ), + ), + ], + ), + ); + + final vodInfoSection = Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Tooltip( + message: video.title.trim(), + preferBelow: false, + child: Text( + video.title.trim(), + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: fontColor, + ), + ), + ), + Text( + _formatRelativeDate(video.createdAt), + style: TextStyle( + fontSize: subFontSize, + color: fontColor?.withValues(alpha: 0.8), + ), + ), + Text( + '${_formatViewCount(video.viewCount)} views', + style: TextStyle( + fontSize: subFontSize, + color: fontColor?.withValues(alpha: 0.8), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + + return InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VodPlayer( + videoId: video.id, + channelId: video.userId, + title: video.title, + ), + ), + ), + child: Padding( + padding: EdgeInsets.only( + top: 8, + bottom: 8, + left: 16 + MediaQuery.of(context).padding.left, + right: 16 + MediaQuery.of(context).padding.right, + ), + child: Row( + children: [ + Flexible(child: imageSection), + Flexible(flex: 2, child: vodInfoSection), + ], + ), + ), + ); + } + + String _formatViewCount(int count) { + if (count >= 1000000) { + return '${(count / 1000000).toStringAsFixed(1)}M'; + } else if (count >= 1000) { + return '${(count / 1000).toStringAsFixed(1)}K'; + } + return count.toString(); + } +} diff --git a/lib/screens/channel/vod/vod_list.dart b/lib/screens/channel/vod/vod_list.dart new file mode 100644 index 00000000..d505c7d6 --- /dev/null +++ b/lib/screens/channel/vod/vod_list.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/vod/stores/vod_list_store.dart'; +import 'package:frosty/screens/channel/vod/vod_card.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/alert_message.dart'; + +class VodList extends StatefulWidget { + final String userId; + final String displayName; + + const VodList({ + super.key, + required this.userId, + required this.displayName, + }); + + @override + State createState() => _VodListState(); +} + +class _VodListState extends State { + late final VodListStore _store = VodListStore( + twitchApi: context.twitchApi, + userId: widget.userId, + ); + + @override + void initState() { + super.initState(); + _store.fetchVideos(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('${widget.displayName} — Past broadcasts')), + body: Observer( + builder: (context) { + if (_store.error.isNotEmpty && _store.videos.isEmpty) { + return Center( + child: AlertMessage(message: _store.error), + ); + } + + if (_store.videos.isEmpty && !_store.isLoading) { + return const Center( + child: AlertMessage(message: 'No past broadcasts found'), + ); + } + + return RefreshIndicator.adaptive( + onRefresh: _store.refresh, + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollEndNotification && + notification.metrics.extentAfter < 200) { + _store.fetchMore(); + } + return false; + }, + child: ListView.builder( + itemCount: _store.videos.length + (_store.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index == _store.videos.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ); + } + return VodCard(video: _store.videos[index]); + }, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/channel/vod/vod_player.dart b/lib/screens/channel/vod/vod_player.dart new file mode 100644 index 00000000..f579a686 --- /dev/null +++ b/lib/screens/channel/vod/vod_player.dart @@ -0,0 +1,282 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; +import 'package:frosty/screens/channel/vod/stores/vod_chat_store.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_notification.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +/// VOD player + chat replay screen. +/// +/// Layout matches the live channel view [VideoChat]: no AppBar, +/// video on top (16:9), chat below (expanded). +class VodPlayer extends StatefulWidget { + final String videoId; + final String channelId; + final String title; + + const VodPlayer({ + super.key, + required this.videoId, + required this.channelId, + required this.title, + }); + + @override + State createState() => _VodPlayerState(); +} + +class _VodPlayerState extends State { + late final VodChatStore _vodChatStore; + late final WebViewController _webViewController; + + String get _videoUrl => + 'https://player.twitch.tv/?video=v${widget.videoId}&parent=frosty&muted=false'; + + @override + void initState() { + super.initState(); + + _vodChatStore = VodChatStore( + assetsStore: ChatAssetsStore( + twitchApi: context.twitchApi, + bttvApi: context.bttvApi, + ffzApi: context.ffzApi, + sevenTVApi: context.sevenTVApi, + globalAssetsStore: context.globalAssetsStore, + ), + auth: context.authStore, + settings: context.settingsStore, + channelId: widget.channelId, + twitchGqlApi: context.twitchGqlApi, + videoId: widget.videoId, + ); + + _vodChatStore.init(); + _initWebView(); + } + + void _initWebView() { + // Platform-specific params for autoplay. + late final PlatformWebViewControllerCreationParams params; + if (WebViewPlatform.instance is WebKitWebViewPlatform) { + params = WebKitWebViewControllerCreationParams( + allowsInlineMediaPlayback: true, + mediaTypesRequiringUserAction: const {}, + ); + } else { + params = const PlatformWebViewControllerCreationParams(); + } + + _webViewController = + WebViewController.fromPlatformCreationParams(params) + ..setBackgroundColor(Colors.black) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'VodPosition', + onMessageReceived: (message) { + final text = message.message; + if (text.startsWith('seek:')) { + final seconds = + double.tryParse(text.substring(5))?.toInt() ?? 0; + _vodChatStore.handleSeek(seconds); + } else { + final seconds = double.tryParse(text)?.toInt() ?? 0; + _vodChatStore.updatePlaybackPosition(seconds); + } + }, + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageFinished: (url) async { + if (!url.contains('player.twitch.tv')) return; + await _injectPositionTracker(); + }, + ), + ); + + // Android: disable user gesture requirement for autoplay. + if (_webViewController.platform is AndroidWebViewController) { + (_webViewController.platform as AndroidWebViewController) + .setMediaPlaybackRequiresUserGesture(false); + } + + _webViewController.loadRequest(Uri.parse(_videoUrl)); + } + + Future _injectPositionTracker() async { + // Wait for the video element to be available, then poll currentTime + // and listen for seek events. + await _webViewController.runJavaScript(''' +(function() { + function tryAttach() { + var video = document.querySelector('video'); + if (!video) { + setTimeout(tryAttach, 500); + return; + } + setInterval(function() { + VodPosition.postMessage(Math.floor(video.currentTime).toString()); + }, 1000); + video.addEventListener('seeked', function() { + VodPosition.postMessage('seek:' + Math.floor(video.currentTime).toString()); + }); + } + tryAttach(); +})(); +'''); + } + + Widget _buildWebView() { + if (Platform.isAndroid && + WebViewPlatform.instance is AndroidWebViewPlatform) { + return WebViewWidget.fromPlatformCreationParams( + params: AndroidWebViewWidgetCreationParams( + controller: _webViewController.platform, + displayWithHybridComposition: + !context.settingsStore.useTextureRendering, + ), + ); + } + return WebViewWidget(controller: _webViewController); + } + + Widget _buildChatList() { + return Observer( + builder: (context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: _vodChatStore.settings.messageScale.textScaler, + ), + child: DefaultTextStyle( + style: context.defaultTextStyle.copyWith( + fontSize: _vodChatStore.settings.fontSize, + ), + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + FrostyScrollbar( + controller: _vodChatStore.scrollController, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Observer( + builder: (context) { + final messages = _vodChatStore.renderMessages; + return ListView.builder( + reverse: true, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + addAutomaticKeepAlives: false, + controller: _vodChatStore.scrollController, + itemCount: messages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: + messages[messages.length - 1 - index], + chatStore: _vodChatStore, + ), + ); + }, + ), + ), + _buildResumeScrollButton(), + ], + ), + ), + ); + }, + ); + } + + Widget _buildResumeScrollButton() { + return Builder( + builder: (context) { + return AnimatedPadding( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + left: 4, + top: 4, + right: 4, + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Observer( + builder: (_) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _vodChatStore.autoScroll + ? null + : ElevatedButton.icon( + onPressed: _vodChatStore.resumeScroll, + icon: const Icon(Icons.arrow_downward_rounded), + label: Text( + _vodChatStore.messageBuffer.isNotEmpty + ? '${_vodChatStore.messageBuffer.length} new ${_vodChatStore.messageBuffer.length == 1 ? 'message' : 'messages'}' + : 'Resume scroll', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + bottom: false, + child: Column( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: _buildWebView(), + ), + Expanded( + child: Stack( + children: [ + _buildChatList(), + Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _vodChatStore.notification != null + ? Align( + alignment: Alignment.topCenter, + child: FrostyNotification( + message: _vodChatStore.notification!, + onDismissed: _vodChatStore.clearNotification, + ), + ) + : null, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _vodChatStore.dispose(); + super.dispose(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 898cb8d2..4c60704f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -190,10 +190,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -797,18 +797,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1267,10 +1267,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 5b5df81a..d100ace3 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/video.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import '../fixtures/api_responses.dart'; @@ -389,4 +390,63 @@ void main() { expect(emotes[2].type, EmoteType.twitchChannel); }); }); + + group('getVideos', () { + test('returns videos for a user', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/videos', + (server) => server.reply(200, { + 'data': [ + { + 'id': '12345', + 'stream_id': '67890', + 'user_id': '111', + 'user_login': 'testuser', + 'user_name': 'TestUser', + 'title': 'Test VOD', + 'created_at': '2024-01-15T10:30:00Z', + 'published_at': '2024-01-15T10:30:00Z', + 'duration': '3h26m15s', + 'thumbnail_url': 'https://cdn/thumb.jpg', + 'view_count': 500, + }, + ], + 'pagination': {'cursor': 'next_page'}, + }), + queryParameters: { + 'user_id': '111', + 'first': '20', + 'type': 'archive', + }, + ); + + final videos = await api.getVideos(userId: '111'); + + expect(videos.data, hasLength(1)); + expect(videos.data.first.id, '12345'); + expect(videos.data.first.title, 'Test VOD'); + expect(videos.data.first.duration, '3h26m15s'); + expect(videos.pagination['cursor'], 'next_page'); + }); + + test('passes cursor for pagination', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/videos', + (server) => server.reply(200, { + 'data': >[], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '111', + 'first': '20', + 'type': 'archive', + 'after': 'page2', + }, + ); + + final videos = await api.getVideos(userId: '111', cursor: 'page2'); + + expect(videos.data, isEmpty); + }); + }); } diff --git a/test/apis/twitch_gql_api_test.dart b/test/apis/twitch_gql_api_test.dart new file mode 100644 index 00000000..d4817997 --- /dev/null +++ b/test/apis/twitch_gql_api_test.dart @@ -0,0 +1,208 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/constants.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchGqlApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchGqlApi(dio); + }); + + group('getVideoComments', () { + test('fetches comments by content offset', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'video': { + 'comments': { + 'edges': [ + { + 'node': { + 'id': 'c1', + 'contentOffsetSeconds': 10, + 'commenter': { + 'id': 'u1', + 'displayName': 'User1', + 'login': 'user1', + }, + 'message': { + 'userColor': '#FF0000', + 'userBadges': >[], + 'fragments': [ + {'text': 'hello', 'emote': null}, + ], + }, + }, + 'cursor': 'cursor-1', + }, + ], + 'pageInfo': {'hasNextPage': true}, + }, + }, + }, + }), + data: { + 'operationName': 'VideoCommentsByOffsetOrCursor', + 'variables': { + 'videoID': 'v123', + 'contentOffsetSeconds': 0, + }, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': + 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', + }, + }, + }, + headers: {'Client-ID': twitchGqlMobileClientId}, + ); + + final response = await api.getVideoComments( + videoId: 'v123', + contentOffsetSeconds: 0, + ); + + expect(response.comments, hasLength(1)); + expect(response.comments.first.id, 'c1'); + expect(response.comments.first.commenterLogin, 'user1'); + expect(response.hasNextPage, isTrue); + expect(response.cursor, 'cursor-1'); + }); + + test('fetches comments by cursor', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'video': { + 'comments': { + 'edges': [ + { + 'node': { + 'id': 'c2', + 'contentOffsetSeconds': 20, + 'commenter': { + 'id': 'u2', + 'displayName': 'User2', + 'login': 'user2', + }, + 'message': { + 'userColor': null, + 'userBadges': >[], + 'fragments': [ + {'text': 'world', 'emote': null}, + ], + }, + }, + 'cursor': 'cursor-2', + }, + ], + 'pageInfo': {'hasNextPage': false}, + }, + }, + }, + }), + data: { + 'operationName': 'VideoCommentsByOffsetOrCursor', + 'variables': { + 'videoID': 'v123', + 'cursor': 'cursor-1', + }, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': + 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', + }, + }, + }, + headers: {'Client-ID': twitchGqlMobileClientId}, + ); + + final response = await api.getVideoComments( + videoId: 'v123', + cursor: 'cursor-1', + ); + + expect(response.comments, hasLength(1)); + expect(response.comments.first.id, 'c2'); + expect(response.hasNextPage, isFalse); + }); + + test('handles null video (VOD not found)', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': {'video': null}, + }), + data: { + 'operationName': 'VideoCommentsByOffsetOrCursor', + 'variables': { + 'videoID': 'nonexistent', + 'contentOffsetSeconds': 0, + }, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': + 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', + }, + }, + }, + headers: {'Client-ID': twitchGqlMobileClientId}, + ); + + final response = await api.getVideoComments( + videoId: 'nonexistent', + contentOffsetSeconds: 0, + ); + + expect(response.comments, isEmpty); + expect(response.hasNextPage, isFalse); + }); + + test('throws on GQL errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'Something went wrong'}, + ], + 'data': null, + }), + data: { + 'operationName': 'VideoCommentsByOffsetOrCursor', + 'variables': { + 'videoID': 'v123', + 'contentOffsetSeconds': 0, + }, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': + 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', + }, + }, + }, + headers: {'Client-ID': twitchGqlMobileClientId}, + ); + + expect( + () => api.getVideoComments( + videoId: 'v123', + contentOffsetSeconds: 0, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/models/gql_chat_comment_test.dart b/test/models/gql_chat_comment_test.dart new file mode 100644 index 00000000..640ee07c --- /dev/null +++ b/test/models/gql_chat_comment_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/gql_chat_comment.dart'; +import 'package:frosty/models/irc.dart'; + +void main() { + group('GqlChatComment', () { + test('fromGqlNode parses a complete comment', () { + final node = { + 'id': 'comment-1', + 'contentOffsetSeconds': 120, + 'commenter': { + 'id': 'user-123', + 'displayName': 'TestUser', + 'login': 'testuser', + }, + 'message': { + 'userColor': '#FF0000', + 'userBadges': [ + {'setID': 'subscriber', 'version': '12'}, + ], + 'fragments': [ + {'text': 'Hello world ', 'emote': null}, + { + 'text': 'Kappa', + 'emote': {'emoteID': '25'}, + }, + ], + }, + }; + + final comment = GqlChatComment.fromGqlNode(node); + + expect(comment.id, 'comment-1'); + expect(comment.contentOffsetSeconds, 120); + expect(comment.commenterLogin, 'testuser'); + expect(comment.commenterDisplayName, 'TestUser'); + expect(comment.commenterId, 'user-123'); + expect(comment.userColor, '#FF0000'); + expect(comment.userBadges.length, 1); + expect(comment.userBadges.first.setId, 'subscriber'); + expect(comment.userBadges.first.version, '12'); + expect(comment.messageFragments.length, 2); + expect(comment.messageFragments[0].text, 'Hello world '); + expect(comment.messageFragments[0].emote, isNull); + expect(comment.messageFragments[1].text, 'Kappa'); + expect(comment.messageFragments[1].emote!.emoteId, '25'); + }); + + test('fromGqlNode handles deleted commenter (null)', () { + final node = { + 'id': 'comment-2', + 'contentOffsetSeconds': 60, + 'commenter': null, + 'message': { + 'userColor': null, + 'userBadges': >[], + 'fragments': [ + {'text': 'deleted message', 'emote': null}, + ], + }, + }; + + final comment = GqlChatComment.fromGqlNode(node); + + expect(comment.commenterLogin, isNull); + expect(comment.commenterDisplayName, isNull); + expect(comment.commenterId, isNull); + expect(comment.userColor, isNull); + expect(comment.userBadges, isEmpty); + }); + }); + + group('GqlChatResponse', () { + test('fromJson parses a valid response', () { + final json = { + 'data': { + 'video': { + 'comments': { + 'edges': [ + { + 'node': { + 'id': 'c1', + 'contentOffsetSeconds': 10, + 'commenter': { + 'id': 'u1', + 'displayName': 'User1', + 'login': 'user1', + }, + 'message': { + 'userColor': '#00FF00', + 'userBadges': >[], + 'fragments': [ + {'text': 'hello', 'emote': null}, + ], + }, + }, + 'cursor': 'cursor-abc', + }, + ], + 'pageInfo': {'hasNextPage': true}, + }, + }, + }, + }; + + final response = GqlChatResponse.fromJson(json); + + expect(response.comments.length, 1); + expect(response.comments.first.id, 'c1'); + expect(response.hasNextPage, isTrue); + expect(response.cursor, 'cursor-abc'); + }); + + test('fromJson throws on GQL errors', () { + final json = { + 'errors': [ + {'message': 'Something went wrong'}, + ], + 'data': null, + }; + + expect( + () => GqlChatResponse.fromJson(json), + throwsA(isA()), + ); + }); + + test('fromJson handles null video (VOD not found)', () { + final json = { + 'data': {'video': null}, + }; + + final response = GqlChatResponse.fromJson(json); + expect(response.comments, isEmpty); + expect(response.hasNextPage, isFalse); + }); + }); + + group('IRCMessage.fromGqlComment', () { + test('converts comment to IRCMessage with correct tags', () { + final comment = GqlChatComment( + id: 'msg-1', + contentOffsetSeconds: 300, + commenterLogin: 'testuser', + commenterDisplayName: 'TestUser', + commenterId: 'user-123', + userColor: '#FF0000', + userBadges: [ + const GqlChatBadge(setId: 'subscriber', version: '3'), + const GqlChatBadge(setId: 'moderator', version: '1'), + ], + messageFragments: [ + const GqlChatFragment(text: 'Hello world'), + ], + ); + + final irc = IRCMessage.fromGqlComment(comment); + + expect(irc.command, Command.privateMessage); + expect(irc.user, 'testuser'); + expect(irc.tags['display-name'], 'TestUser'); + expect(irc.tags['user-id'], 'user-123'); + expect(irc.tags['id'], 'msg-1'); + expect(irc.tags['color'], '#FF0000'); + expect(irc.tags['badges'], 'subscriber/3,moderator/1'); + expect(irc.tags['tmi-sent-ts'], '300000'); + expect(irc.message, 'Hello world'); + expect(irc.split, ['Hello', 'world']); + expect(irc.localEmotes, isNull); + }); + + test('populates localEmotes for Twitch emotes', () { + final comment = GqlChatComment( + id: 'msg-2', + contentOffsetSeconds: 10, + commenterLogin: 'user', + commenterDisplayName: 'User', + commenterId: 'u1', + userBadges: [], + messageFragments: [ + const GqlChatFragment(text: 'Hey '), + const GqlChatFragment( + text: 'Kappa', + emote: GqlChatEmote(emoteId: '25'), + ), + const GqlChatFragment(text: ' nice'), + ], + ); + + final irc = IRCMessage.fromGqlComment(comment); + + expect(irc.message, 'Hey Kappa nice'); + expect(irc.localEmotes, isNotNull); + expect(irc.localEmotes!['Kappa'], isNotNull); + expect(irc.localEmotes!['Kappa']!.name, 'Kappa'); + expect(irc.localEmotes!['Kappa']!.url, contains('25')); + }); + + test('handles deleted commenter', () { + final comment = GqlChatComment( + id: 'msg-3', + contentOffsetSeconds: 0, + userBadges: [], + messageFragments: [ + const GqlChatFragment(text: 'deleted message'), + ], + ); + + final irc = IRCMessage.fromGqlComment(comment); + + expect(irc.tags['display-name'], 'Deleted User'); + expect(irc.user, isNull); + expect(irc.tags['user-id'], ''); + }); + }); +} diff --git a/test/models/video_test.dart b/test/models/video_test.dart new file mode 100644 index 00000000..2cc218bc --- /dev/null +++ b/test/models/video_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/video.dart'; + +void main() { + group('VideoTwitch', () { + test('fromJson parses all fields correctly', () { + final json = { + 'id': '12345', + 'stream_id': '67890', + 'user_id': '111', + 'user_login': 'testuser', + 'user_name': 'TestUser', + 'title': 'Test Stream VOD', + 'created_at': '2024-01-15T10:30:00Z', + 'published_at': '2024-01-15T10:30:00Z', + 'duration': '3h26m15s', + 'thumbnail_url': + 'https://static-cdn.jtvnw.net/cf_vods/abc/%{width}x%{height}.jpg', + 'view_count': 12345, + }; + + final video = VideoTwitch.fromJson(json); + + expect(video.id, '12345'); + expect(video.streamId, '67890'); + expect(video.userId, '111'); + expect(video.userLogin, 'testuser'); + expect(video.userName, 'TestUser'); + expect(video.title, 'Test Stream VOD'); + expect(video.createdAt, '2024-01-15T10:30:00Z'); + expect(video.publishedAt, '2024-01-15T10:30:00Z'); + expect(video.duration, '3h26m15s'); + expect(video.thumbnailUrl, contains('%{width}')); + expect(video.viewCount, 12345); + }); + + test('fromJson handles null streamId', () { + final json = { + 'id': '12345', + 'stream_id': null, + 'user_id': '111', + 'user_login': 'testuser', + 'user_name': 'TestUser', + 'title': 'Test', + 'created_at': '2024-01-15T10:30:00Z', + 'published_at': '2024-01-15T10:30:00Z', + 'duration': '1h0m0s', + 'thumbnail_url': '', + 'view_count': 0, + }; + + final video = VideoTwitch.fromJson(json); + expect(video.streamId, isNull); + }); + }); + + group('VideosTwitch', () { + test('fromJson parses data list and pagination', () { + final json = { + 'data': [ + { + 'id': '1', + 'stream_id': null, + 'user_id': '111', + 'user_login': 'user', + 'user_name': 'User', + 'title': 'Title', + 'created_at': '2024-01-01T00:00:00Z', + 'published_at': '2024-01-01T00:00:00Z', + 'duration': '1h0m0s', + 'thumbnail_url': '', + 'view_count': 100, + }, + ], + 'pagination': {'cursor': 'abc123'}, + }; + + final videos = VideosTwitch.fromJson(json); + expect(videos.data.length, 1); + expect(videos.data.first.id, '1'); + expect(videos.pagination['cursor'], 'abc123'); + }); + + test('fromJson handles empty data', () { + final json = { + 'data': >[], + 'pagination': {}, + }; + + final videos = VideosTwitch.fromJson(json); + expect(videos.data, isEmpty); + }); + }); + + group('parseTwitchDuration', () { + test('parses hours, minutes, and seconds', () { + expect( + parseTwitchDuration('3h26m15s'), + const Duration(hours: 3, minutes: 26, seconds: 15), + ); + }); + + test('parses minutes and seconds only', () { + expect( + parseTwitchDuration('45m30s'), + const Duration(minutes: 45, seconds: 30), + ); + }); + + test('parses seconds only', () { + expect( + parseTwitchDuration('30s'), + const Duration(seconds: 30), + ); + }); + + test('parses hours and minutes only', () { + expect( + parseTwitchDuration('2h10m'), + const Duration(hours: 2, minutes: 10), + ); + }); + + test('returns Duration.zero for empty string', () { + expect(parseTwitchDuration(''), Duration.zero); + }); + }); +}