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:
parent
872362d5a6
commit
1020b37e2f
20 changed files with 1059 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -54,5 +54,9 @@
|
|||
<true/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
69
lib/apis/twitch_gql_api.dart
Normal file
69
lib/apis/twitch_gql_api.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
18
lib/models/playback_access_token.dart
Normal file
18
lib/models/playback_access_token.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
46
lib/screens/channel/video/native_video.dart
Normal file
46
lib/screens/channel/video/native_video.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
400
lib/screens/channel/video/native_video_store.dart
Normal file
400
lib/screens/channel/video/native_video_store.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
355
lib/screens/channel/video/native_video_store.g.dart
Normal file
355
lib/screens/channel/video/native_video_store.g.dart
Normal 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 '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
30
lib/screens/channel/video/video_player_interface.dart
Normal file
30
lib/screens/channel/video/video_player_interface.dart
Normal 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();
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
24
pubspec.lock
24
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue