frosty/lib/screens/channel/video/native_video_store.dart
Tommy Chow 6695c13455 clean up native video player implementation
Extract shared stream quality key constant, add null guard to
playback token parsing, fix handleRefresh race condition and
concurrency, and replace Container with SizedBox placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:45:15 -07:00

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