diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index dddfb1212..32f77e593 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -91,6 +91,8 @@ class PlaybackModel { Future playbackStopped(Duration position, Duration? totalDuration, Ref ref) => throw UnimplementedError(); + void dispose() {} + final MediaStreamsModel? mediaStreams; List? get subStreams => throw UnimplementedError(); List? get audioStreams => throw UnimplementedError(); diff --git a/lib/models/playback/tv_playback_model.dart b/lib/models/playback/tv_playback_model.dart index 7bc54a126..610c74bfa 100644 --- a/lib/models/playback/tv_playback_model.dart +++ b/lib/models/playback/tv_playback_model.dart @@ -21,11 +21,12 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; class TvPlaybackModel extends PlaybackModel { - Timer? _refreshTimer; - Timer? _tickTimer; + static Timer? _refreshTimer; + static Timer? _tickTimer; - DateTime? _lastScheduledAt; - String? _lastGuideProgId; + static DateTime? _lastScheduledAt; + static String? _lastGuideProgId; + static bool _isSwitching = false; final ChannelModel channel; final bool isNativePlayerBackend; @@ -61,6 +62,13 @@ class TvPlaybackModel extends PlaybackModel { void stopTracking() { _stopTimers(); + _isSwitching = false; + _lastGuideProgId = null; + } + + @override + void dispose() { + stopTracking(); } void _stopTimers() { @@ -72,7 +80,13 @@ class TvPlaybackModel extends PlaybackModel { } void _tick(Ref ref) { - final currentProgram = playingProgram; + final model = ref.read(playBackModel); + if (model is! TvPlaybackModel) { + _stopTimers(); + return; + } + + final currentProgram = model.playingProgram; if (currentProgram == null || !ref.read(mediaPlaybackProvider).playing) { return; } @@ -89,7 +103,7 @@ class TvPlaybackModel extends PlaybackModel { final newPosition = now.isBefore(start) ? Duration.zero : now.difference(start); final newDuration = end.difference(start); - ref.read(playBackModel.notifier).update((state) => copyWith( + ref.read(playBackModel.notifier).update((state) => model.copyWith( position: newPosition, duration: newDuration, )); @@ -100,33 +114,53 @@ class TvPlaybackModel extends PlaybackModel { } Future _switchProgram(Ref ref) async { - final tempState = await ref.read(liveTvProvider.notifier).fetchDashboard(); - final updatedChannel = tempState.channels.firstWhereOrNull((c) => c.id == channel.id) ?? channel; - final currentChannelPrograms = await ref.read(liveTvProvider.notifier).fetchPrograms(updatedChannel); - final channelWithPrograms = updatedChannel.copyChannelWith(programs: currentChannelPrograms); - - final now = DateTime.now(); - final prog = channelWithPrograms.currentProgram; - - final start = prog?.startDate ?? now; - final end = prog?.endDate ?? now; - final newPosition = now.isBefore(start) ? Duration.zero : now.difference(start); - final newDuration = end.difference(start); + if (_isSwitching) return; + _isSwitching = true; + try { + final currentModel = ref.read(playBackModel); + if (currentModel is! TvPlaybackModel) { + _stopTimers(); + return; + } + + final tempState = await ref.read(liveTvProvider.notifier).fetchDashboard(); + final updatedChannel = + tempState.channels.firstWhereOrNull((c) => c.id == currentModel.channel.id) ?? currentModel.channel; + final currentChannelPrograms = await ref.read(liveTvProvider.notifier).fetchPrograms(updatedChannel); + final channelWithPrograms = updatedChannel.copyChannelWith(programs: currentChannelPrograms); + + final now = DateTime.now(); + final prog = channelWithPrograms.currentProgram; + + final start = prog?.startDate ?? now; + final end = prog?.endDate ?? now; + final newPosition = now.isBefore(start) ? Duration.zero : now.difference(start); + final newDuration = end.difference(start); + + // Re-read in case model changed during async operations + final latestModel = ref.read(playBackModel); + if (latestModel is! TvPlaybackModel) { + _stopTimers(); + return; + } + + final newModel = latestModel.copyWith( + channel: channelWithPrograms, + currentProgram: prog, + position: newPosition, + duration: newDuration, + ); - final newModel = copyWith( - channel: channelWithPrograms, - currentProgram: prog, - position: newPosition, - duration: newDuration, - ); + ref.read(playBackModel.notifier).update((state) => newModel); - ref.read(playBackModel.notifier).update((state) => newModel); + if (prog != null && prog.endDate.isAfter(now)) { + _scheduleRefreshAt(prog.endDate, ref); + } - if (prog != null && prog.endDate.isAfter(now)) { - _scheduleRefreshAt(prog.endDate, ref); + await _sendNativeGuideUpdate(ref, prog, channelWithPrograms, tempState, latestModel.isNativePlayerBackend); + } finally { + _isSwitching = false; } - - await _sendNativeGuideUpdate(ref, prog, channelWithPrograms, tempState); } Future _sendNativeGuideUpdate( @@ -134,9 +168,9 @@ class TvPlaybackModel extends PlaybackModel { ChannelProgram? prog, ChannelModel channelWithPrograms, LiveTvModel tempState, + bool isNativePlayerBackend, ) async { - final isNativePlayer = isNativePlayerBackend; - if (!isNativePlayer || tempState.channels.isEmpty || _lastGuideProgId == prog?.id) { + if (!isNativePlayerBackend || tempState.channels.isEmpty || _lastGuideProgId == prog?.id) { return; } diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 8e46f0e50..7a0b3449a 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -80,6 +80,7 @@ class VideoPlayerNotifier extends StateNotifier { Future updatePlaying(bool event) async { final currentState = playbackState; if (!state.hasPlayer || currentState.playing == event) return; + if (currentState.state == VideoPlayerState.disposed) return; mediaState.update( (state) => state.copyWith(playing: event), ); @@ -90,6 +91,7 @@ class VideoPlayerNotifier extends StateNotifier { if (!state.hasPlayer) return; if (playbackState.playing == false) return; final currentState = playbackState; + if (currentState.state == VideoPlayerState.disposed) return; final currentPosition = currentState.position; if ((currentPosition - event).inSeconds.abs() < 1) return; @@ -113,6 +115,7 @@ class VideoPlayerNotifier extends StateNotifier { } Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { + ref.read(playBackModel)?.dispose(); await state.stop(); ref.read(playbackRateProvider.notifier).state = 1.0; mediaState.update((state) => state.copyWith( diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 8dbef29e2..48806270a 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -133,6 +134,21 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future openPlayer(BuildContext context) async => _player?.open(context); + // Update playback play/pause state with single retry + Future _updatePositionWithRetry(PlaybackModel model, Duration position, bool isPlaying) async { + try { + await model.updatePlaybackPosition(position, isPlaying, ref); + } catch (error, stackTrace) { + log('Failed to send playing: $isPlaying state to server. Retrying once. Error: $error\n$stackTrace'); + try { + await Future.delayed(const Duration(milliseconds: 250)); + await model.updatePlaybackPosition(position, isPlaying, ref); + } catch (retryError, retryStackTrace) { + log('Retry failed for playing: $isPlaying state update. Error: $retryError\n$retryStackTrace'); + } + } + } + void _subscribePlayer() { if (Platform.isWindows && !kIsWeb) { smtc = SMTCWindows( @@ -218,7 +234,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro WakelockPlus.disable(); final playerState = _player; if (playerState != null) { - ref.read(playBackModel)?.updatePlaybackPosition(playerState.lastState.position, false, ref); + final model = ref.read(playBackModel); + if (model != null) { + await _updatePositionWithRetry(model, playerState.lastState.position, false); + } } } @@ -305,13 +324,12 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.disposed)); WakelockPlus.disable(); - super.stop(); _player?.stop(); final position = _player?.lastState.position; final totalDuration = _player?.lastState.duration; - // //Small delay so we don't post right after playback/progress update + // Small delay so we don't post right after playback/progress update await Future.delayed(const Duration(seconds: 1)); await playbackModel.playbackStopped(position ?? Duration.zero, totalDuration, ref); @@ -347,9 +365,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final playerState = _player; if (playerState != null) { - ref - .read(playBackModel) - ?.updatePlaybackPosition(playerState.lastState.position, playerState.lastState.playing, ref); + final model = ref.read(playBackModel); + if (model != null) { + await _updatePositionWithRetry(model, playerState.lastState.position, playerState.lastState.playing); + } } }