Extract shared stream quality key constant, add null guard to playback token parsing, fix handleRefresh race condition and concurrency, and replace Container with SizedBox placeholder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
535 lines
15 KiB
Dart
535 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
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/constants.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';
|
|
import 'package:simple_pip_mode/simple_pip.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? _latencyTimer;
|
|
Timer? _stallRecoveryTimer;
|
|
var _stallRecoveryAttempt = 0;
|
|
DateTime? _lastStreamInfoUpdate;
|
|
Future<void>? _streamInfoRequest;
|
|
bool _overlayWasVisibleBeforePip = true;
|
|
var _userPaused = false;
|
|
|
|
List<NativeVideoPlayerQuality> _qualityObjects = [];
|
|
var _firstTimeSettingQuality = true;
|
|
int? _pendingQualityIndex;
|
|
var _disposed = false;
|
|
|
|
final _pip = SimplePip();
|
|
ReactionDisposer? _disposeAndroidAutoPipReaction;
|
|
|
|
StreamSubscription<bool>? _pipSub;
|
|
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
|
|
|
|
@readonly
|
|
var _loading = true;
|
|
|
|
@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';
|
|
|
|
@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();
|
|
|
|
if (Platform.isAndroid) {
|
|
_disposeAndroidAutoPipReaction = autorun((_) async {
|
|
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
|
|
_pip.setAutoPipMode();
|
|
} else {
|
|
_pip.setAutoPipMode(autoEnter: false);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@action
|
|
Future<void> _initPlayer() async {
|
|
try {
|
|
// Use web cookie token (works with web Client-ID) for ad-free playback.
|
|
final authToken = authStore.gqlToken;
|
|
late final PlaybackAccessToken token;
|
|
try {
|
|
token = await twitchGqlApi.getPlaybackAccessToken(
|
|
login: userLogin,
|
|
authToken: authToken,
|
|
);
|
|
} catch (_) {
|
|
if (authToken != null) {
|
|
token = await twitchGqlApi.getPlaybackAccessToken(
|
|
login: userLogin,
|
|
);
|
|
} else {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
if (_disposed) return;
|
|
|
|
_hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
|
|
|
await _controller!.initialize();
|
|
if (_disposed) return;
|
|
|
|
_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) {
|
|
_pendingQualityIndex = 1;
|
|
} else {
|
|
SharedPreferences.getInstance().then((prefs) {
|
|
if (_disposed) return;
|
|
final lastQuality = prefs.getString(kLastStreamQualityKey);
|
|
if (lastQuality != null) {
|
|
final index = _availableStreamQualities.indexOf(lastQuality);
|
|
if (index != -1) _pendingQualityIndex = index;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
await _controller!.loadUrl(url: _hlsUrl!);
|
|
await _controller!.configureForLivePlayback();
|
|
|
|
_startLatencyPolling();
|
|
|
|
runInAction(() {
|
|
_error = null;
|
|
});
|
|
} catch (e) {
|
|
if (_disposed) return;
|
|
// Wait for stream info so we can distinguish "offline" from "broken".
|
|
await updateStreamInfo(forceUpdate: true);
|
|
if (_disposed) return;
|
|
runInAction(() {
|
|
_loading = false;
|
|
// Only show the error if the stream is actually live — an offline
|
|
// channel is expected to fail here and the overlay handles it.
|
|
if (_streamInfo != null) {
|
|
_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:
|
|
_loading = false;
|
|
_paused = false;
|
|
_stallRecoveryTimer?.cancel();
|
|
_stallRecoveryAttempt = 0;
|
|
if (_pendingQualityIndex != null) {
|
|
final index = _pendingQualityIndex!;
|
|
_pendingQualityIndex = null;
|
|
_setStreamQualityIndex(index);
|
|
}
|
|
case PlayerActivityState.buffering:
|
|
case PlayerActivityState.loading:
|
|
_loading = true;
|
|
if (!_userPaused) _startStallRecoveryTimer();
|
|
case PlayerActivityState.error:
|
|
_loading = false;
|
|
_stallRecoveryTimer?.cancel();
|
|
_error = event.data?['message'] as String? ??
|
|
'Playback error. Try refreshing or switch to the standard player in Settings.';
|
|
case PlayerActivityState.paused:
|
|
case PlayerActivityState.stopped:
|
|
case PlayerActivityState.completed:
|
|
case PlayerActivityState.idle:
|
|
_paused = true;
|
|
_stallRecoveryTimer?.cancel();
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _startStallRecoveryTimer() {
|
|
_stallRecoveryTimer?.cancel();
|
|
_stallRecoveryTimer = Timer(const Duration(seconds: 8), () {
|
|
if (_disposed || !_loading || _userPaused) return;
|
|
_stallRecoveryAttempt++;
|
|
if (_stallRecoveryAttempt <= 1) {
|
|
// Light recovery: seek to live edge and resume.
|
|
debugPrint('NativeVideoStore: stall detected, seeking to live edge');
|
|
_controller?.seekToLiveEdge();
|
|
_controller?.play();
|
|
_startStallRecoveryTimer();
|
|
} else {
|
|
// Heavy recovery: new HLS token + full player restart.
|
|
debugPrint('NativeVideoStore: stall persists, refreshing player');
|
|
handleRefresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _startLatencyPolling() {
|
|
_latencyTimer?.cancel();
|
|
if (settingsStore.autoSyncChatDelay) {
|
|
settingsStore.syncedChatDelay = 0;
|
|
}
|
|
_pollLatency();
|
|
_latencyTimer = Timer.periodic(
|
|
const Duration(seconds: 10),
|
|
(_) => _pollLatency(),
|
|
);
|
|
}
|
|
|
|
Future<void> _pollLatency() async {
|
|
final seconds = await _controller?.getLatencyToLive();
|
|
if (seconds == null) return;
|
|
final rounded = seconds.round();
|
|
|
|
runInAction(() {
|
|
_latency = '${rounded}s';
|
|
});
|
|
|
|
if (!settingsStore.autoSyncChatDelay) return;
|
|
|
|
// Only update when unset or drifted by >2s to avoid restarting
|
|
// the chat countdown on minor fluctuations.
|
|
final current = settingsStore.syncedChatDelay;
|
|
if (current == 0 || (seconds - current).abs() > 2) {
|
|
settingsStore.syncedChatDelay = seconds;
|
|
}
|
|
}
|
|
|
|
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
|
|
_overlayTimer?.cancel();
|
|
|
|
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) {
|
|
_userPaused = false;
|
|
_controller!.play();
|
|
} else {
|
|
_userPaused = true;
|
|
_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();
|
|
_loading = true;
|
|
_paused = true;
|
|
_userPaused = false;
|
|
_stallRecoveryAttempt = 0;
|
|
_firstTimeSettingQuality = true;
|
|
_pendingQualityIndex = null;
|
|
_isInPipMode = false;
|
|
_availableStreamQualities = [];
|
|
_qualityObjects = [];
|
|
_error = null;
|
|
|
|
_latencyTimer?.cancel();
|
|
_stallRecoveryTimer?.cancel();
|
|
_pipSub?.cancel();
|
|
_qualitiesSub?.cancel();
|
|
_controller?.removeActivityListener(_handleActivityEvent);
|
|
_controller?.dispose();
|
|
_controller = NativeVideoPlayerController(
|
|
id: _nextId++,
|
|
autoPlay: true,
|
|
showNativeControls: false,
|
|
);
|
|
_controller!.addActivityListener(_handleActivityEvent);
|
|
|
|
_initPlayer();
|
|
updateStreamInfo();
|
|
}
|
|
|
|
@override
|
|
void requestPictureInPicture() {
|
|
if (Platform.isAndroid) {
|
|
_pip.enterPipMode(autoEnter: true);
|
|
} else {
|
|
_controller?.enterPictureInPicture();
|
|
}
|
|
}
|
|
|
|
@override
|
|
@action
|
|
void togglePictureInPicture() {
|
|
if (Platform.isIOS && _isInPipMode) {
|
|
_controller?.exitPictureInPicture();
|
|
} else {
|
|
requestPictureInPicture();
|
|
}
|
|
}
|
|
|
|
@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 {
|
|
final info = await twitchApi.getStream(userLogin: userLogin);
|
|
runInAction(() {
|
|
_streamInfo = info;
|
|
_offlineChannelInfo = null;
|
|
});
|
|
} catch (e) {
|
|
Channel? channel;
|
|
try {
|
|
channel = await twitchApi.getChannel(userId: userId);
|
|
} catch (_) {}
|
|
|
|
runInAction(() {
|
|
_overlayTimer?.cancel();
|
|
_streamInfo = null;
|
|
_offlineChannelInfo = channel;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
@action
|
|
void handleAppResume() {
|
|
updateStreamInfo(forceUpdate: true);
|
|
if (!_userPaused && _controller != null) {
|
|
_controller!.play();
|
|
}
|
|
}
|
|
|
|
@override
|
|
@action
|
|
void dispose() {
|
|
_disposed = true;
|
|
|
|
if (Platform.isAndroid) {
|
|
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
|
|
if (isAutoPipAvailable) _pip.setAutoPipMode(autoEnter: false);
|
|
});
|
|
}
|
|
|
|
_overlayTimer?.cancel();
|
|
_latencyTimer?.cancel();
|
|
_stallRecoveryTimer?.cancel();
|
|
_pipSub?.cancel();
|
|
_qualitiesSub?.cancel();
|
|
_disposeAndroidAutoPipReaction?.call();
|
|
|
|
_controller?.removeActivityListener(_handleActivityEvent);
|
|
_controller?.dispose();
|
|
_controller = null;
|
|
}
|
|
}
|