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 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-08 03:13:00 -07:00
parent 872362d5a6
commit 1020b37e2f
20 changed files with 1059 additions and 14 deletions

View file

@ -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

View file

@ -54,5 +54,9 @@
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View file

@ -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<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
}) async {
final headers = <String, String>{
'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<JsonMap>(
'/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';
}
}

View file

@ -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');
}
}

View file

@ -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');

View file

@ -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<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
],
child: MyApp(firstRun: firstRun),

View file

@ -0,0 +1,18 @@
class PlaybackAccessToken {
final String value;
final String signature;
const PlaybackAccessToken({
required this.value,
required this.signature,
});
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
final data = json['data']['streamPlaybackAccessToken']
as Map<String, dynamic>;
return PlaybackAccessToken(
value: data['value'] as String,
signature: data['signature'] as String,
);
}
}

View file

@ -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<VideoChat>
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<VideoChat>
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(

View file

@ -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,
),
),
),
],
),
);
},
);
}
}

View file

@ -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<void>? _streamInfoRequest;
bool _overlayWasVisibleBeforePip = true;
List<NativeVideoPlayerQuality> _qualityObjects = [];
var _firstTimeSettingQuality = true;
StreamSubscription<bool>? _pipSub;
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
@readonly
var _paused = true;
@readonly
var _overlayVisible = true;
@readonly
StreamTwitch? _streamInfo;
@readonly
Channel? _offlineChannelInfo;
@readonly
var _availableStreamQualities = <String>[];
@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<void> _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 = <String>{};
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<void> 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<void> updateStreamQualities() async {
// Qualities are populated automatically via qualitiesStream.
}
@override
@action
Future<void> setStreamQuality(String quality) async {
final index = _availableStreamQualities.indexOf(quality);
if (index == -1) return;
await _setStreamQualityIndex(index);
}
@action
Future<void> _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<void> 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<void> _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;
}
}

View file

@ -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<String> get availableStreamQualities {
_$_availableStreamQualitiesAtom.reportRead();
return super._availableStreamQualities;
}
@override
List<String> get _availableStreamQualities => availableStreamQualities;
@override
set _availableStreamQualities(List<String> 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<void> _initPlayer() {
return _$_initPlayerAsyncAction.run(() => super._initPlayer());
}
late final _$handleRefreshAsyncAction = AsyncAction(
'NativeVideoStoreBase.handleRefresh',
context: context,
);
@override
Future<void> handleRefresh() {
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
}
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamQualities',
context: context,
);
@override
Future<void> updateStreamQualities() {
return _$updateStreamQualitiesAsyncAction.run(
() => super.updateStreamQualities(),
);
}
late final _$setStreamQualityAsyncAction = AsyncAction(
'NativeVideoStoreBase.setStreamQuality',
context: context,
);
@override
Future<void> setStreamQuality(String quality) {
return _$setStreamQualityAsyncAction.run(
() => super.setStreamQuality(quality),
);
}
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
'NativeVideoStoreBase._setStreamQualityIndex',
context: context,
);
@override
Future<void> _setStreamQualityIndex(int index) {
return _$_setStreamQualityIndexAsyncAction.run(
() => super._setStreamQualityIndex(index),
);
}
late final _$updateStreamInfoAsyncAction = AsyncAction(
'NativeVideoStoreBase.updateStreamInfo',
context: context,
);
@override
Future<void> 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 '''
''';
}
}

View file

@ -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(

View file

@ -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<String> get availableStreamQualities;
String get streamQuality;
String? get latency;
void handleVideoTap();
void handlePausePlay();
void handleToggleOverlay();
Future<void> handleRefresh();
void requestPictureInPicture();
void togglePictureInPicture();
Future<void> updateStreamQualities();
Future<void> setStreamQuality(String quality);
Future<void> updateStreamInfo({bool forceUpdate});
void handleAppResume();
void dispose();
}

View file

@ -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<void> updateStreamQualities() async {
try {
@ -382,6 +386,7 @@ abstract class VideoStoreBase with Store {
}
}
@override
@action
Future<void> 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<void> 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<void> 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.

View file

@ -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;

View file

@ -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<String, dynamic> _$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},

View file

@ -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',

View file

@ -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<TwitchApi>();
/// Gets TwitchGqlApi from provider
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
/// Gets AuthStore from provider
AuthStore get authStore => read<AuthStore>();

View file

@ -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

View file

@ -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: