diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 3784dae0..3f41c721 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -146,7 +146,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { if (widget.isLive) { onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; } else { - onPrevious = (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null; + final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; + onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; } return Video( @@ -172,19 +173,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onAudioTrackChanged: _onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, - onSeekCompleted: (position) { - // Notify Watch Together of seek for sync - // Note: canControl() check is done in sync manager, not here - // This matches play/pause behavior and avoids timing issues - try { - final watchTogether = this.context.read(); - if (watchTogether.isInSession) { - watchTogether.onLocalSeek(position); - } - } catch (e) { - // Watch Together not available, ignore - } - }, + onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, onReachedEnd: ({skipAutoPlayCountdown = false}) => _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index 13ca25ec..19e2679b 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -10,7 +10,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { if (mounted && _nextEpisode != null) _playNext(); }; receiver.onPreviousTrack = () { - if (mounted && _previousEpisode != null) _playPrevious(); + if (mounted) unawaited(_restartOrPlayPrevious()); }; receiver.onSeekForward = () async { if (player == null) return; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 566abbf9..a9bc2e60 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -29,6 +29,31 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { await _navigateToEpisode(_previousEpisode!); } + Future _restartOrPlayPrevious() async { + final currentPlayer = player; + if (!mounted || currentPlayer == null || _isLoadingPrevious) return; + + if (!shouldRestartBeforePreviousItem(currentPlayer.state.position) && _previousEpisode != null) { + await _playPrevious(); + return; + } + + _autoPlayTimer?.cancel(); + _dismissStillWatching(); + + _setPlayerState(() { + _showPlayNextDialog = false; + _completionTriggered = false; + }); + + final target = clampSeekPosition(currentPlayer, Duration.zero); + await currentPlayer.seek(target); + if (!mounted || currentPlayer != player) return; + + _notifyWatchTogetherSeek(target); + _updateMediaControlsPlaybackState(); + } + /// Navigates to a new episode, preserving playback state and track selections. /// When PiP is active, swaps the media source in-place to keep the PiP window alive. Future _navigateToEpisode(MediaItem episodeMetadata) async { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index daa9699f..52635a7f 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -123,7 +123,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (_nextEpisode != null) _playNext(); } else if (event is PreviousTrackEvent) { appLogger.d('Media control: Previous track event received'); - if (_previousEpisode != null) _playPrevious(); + unawaited(_restartOrPlayPrevious()); } }); diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index 408f17a4..c72254ae 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -63,6 +63,18 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { } } + void _notifyWatchTogetherSeek(Duration position) { + try { + final watchTogether = context.read(); + if (watchTogether.isInSession) { + // Sync manager applies canControl checks; matching play/pause avoids timing gaps. + watchTogether.onLocalSeek(position); + } + } catch (e) { + appLogger.d('Could not notify watch together of seek', error: e); + } + } + /// Handle media switch from host (guest only) /// Uses VideoPlayerScreen's context for proper navigation (pushReplacement) Future _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async { diff --git a/lib/utils/player_utils.dart b/lib/utils/player_utils.dart index 96978c5b..000c49a0 100644 --- a/lib/utils/player_utils.dart +++ b/lib/utils/player_utils.dart @@ -1,5 +1,11 @@ import '../mpv/mpv.dart'; +const restartBeforePreviousItemThreshold = Duration(seconds: 3); + +bool shouldRestartBeforePreviousItem(Duration position) { + return position > restartBeforePreviousItemThreshold; +} + Duration clampSeekPosition(Player player, Duration position) { final duration = player.state.duration; if (position.isNegative) return Duration.zero; diff --git a/test/utils/player_utils_test.dart b/test/utils/player_utils_test.dart new file mode 100644 index 00000000..91edf179 --- /dev/null +++ b/test/utils/player_utils_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/player_utils.dart'; + +void main() { + group('shouldRestartBeforePreviousItem', () { + test('keeps previous item behavior within the restart threshold', () { + expect(shouldRestartBeforePreviousItem(Duration.zero), isFalse); + expect(shouldRestartBeforePreviousItem(const Duration(seconds: 3)), isFalse); + }); + + test('restarts the current item after the threshold', () { + expect(shouldRestartBeforePreviousItem(const Duration(milliseconds: 3001)), isTrue); + }); + }); +}