From 1020b37e2f86be26ec4de290b4a9dc2cecf5e868 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 8 Mar 2026 03:13:00 -0700 Subject: [PATCH] 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: