@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user