feat(playback): restart before previous episode

close #979
This commit is contained in:
edde746
2026-05-05 08:28:17 +02:00
parent ef2db78310
commit 05c4d6dff3
7 changed files with 63 additions and 16 deletions
+3 -14
View File
@@ -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<WatchTogetherProvider>();
if (watchTogether.isInSession) {
watchTogether.onLocalSeek(position);
}
} catch (e) {
// Watch Together not available, ignore
}
},
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
@@ -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;
@@ -29,6 +29,31 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
await _navigateToEpisode(_previousEpisode!);
}
Future<void> _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<void> _navigateToEpisode(MediaItem episodeMetadata) async {
@@ -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());
}
});
@@ -63,6 +63,18 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
}
}
void _notifyWatchTogetherSeek(Duration position) {
try {
final watchTogether = context.read<WatchTogetherProvider>();
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<void> _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async {
+6
View File
@@ -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;
+15
View File
@@ -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);
});
});
}