fix: catch seek PlatformException at source, remove coordinator

Catch COMMAND_FAILED/NOT_INITIALIZED in PlayerNative.seek() and
PlayerAndroid.seek() directly, preventing unhandled PlatformException
crashes on macOS app resume. Remove the ~200-line
_PendingSeekCoordinator system and revert all callsites to direct
player.seek(clampSeekPosition(...)). Keep seekable property/stream
and media controls gating. Add ExoPlayer seekable emission.
This commit is contained in:
edde746
2026-03-09 07:21:36 +01:00
parent d08c4c905f
commit 0fc64cdf81
17 changed files with 278 additions and 166 deletions
@@ -114,6 +114,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var tunnelingCorrectionInProgress: Boolean = false private var tunnelingCorrectionInProgress: Boolean = false
private var pendingRestoredAudioTrackId: String? = null private var pendingRestoredAudioTrackId: String? = null
private var pendingRestoredSubtitleTrackId: String? = null private var pendingRestoredSubtitleTrackId: String? = null
private var lastSeekable: Boolean? = null
private var lastTunnelingCorrectionTarget: Boolean? = null private var lastTunnelingCorrectionTarget: Boolean? = null
private var lastTunnelingCorrectionAtMs: Long = 0L private var lastTunnelingCorrectionAtMs: Long = 0L
@Volatile private var disposing: Boolean = false @Volatile private var disposing: Boolean = false
@@ -528,6 +529,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
positionUpdateRunnable = null positionUpdateRunnable = null
} }
private fun emitSeekable(seekable: Boolean, force: Boolean = false) {
if (!force && lastSeekable == seekable) return
lastSeekable = seekable
delegate?.onPropertyChange("seekable", seekable)
}
private fun emitCurrentSeekable(force: Boolean = false) {
val player = exoPlayer
val seekable = player?.isCurrentMediaItemSeekable == true && !currentMediaIsLive
emitSeekable(seekable, force)
}
// Player.Listener // Player.Listener
override fun onCues(cueGroup: CueGroup) { override fun onCues(cueGroup: CueGroup) {
@@ -553,6 +566,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
else -> "unknown" else -> "unknown"
} }
emitLog("debug", "state", stateStr) emitLog("debug", "state", stateStr)
emitCurrentSeekable()
when (state) { when (state) {
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
@@ -633,6 +647,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${error.cause?.javaClass?.simpleName}") emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${error.cause?.javaClass?.simpleName}")
stopFrameWatchdog() stopFrameWatchdog()
cancelDecoderHangCheck() cancelDecoderHangCheck()
emitSeekable(false, force = true)
// If native DV7 failed, retry with conversion before falling to MPV // If native DV7 failed, retry with conversion before falling to MPV
if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return
@@ -685,6 +700,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason") Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason")
delegate?.onEvent("file-loaded", null) delegate?.onEvent("file-loaded", null)
delegate?.onPropertyChange("eof-reached", false) delegate?.onPropertyChange("eof-reached", false)
emitCurrentSeekable(force = true)
} }
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -1324,6 +1340,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentTunneledPlayback = false currentTunneledPlayback = false
pendingStartPositionMs = startPositionMs pendingStartPositionMs = startPositionMs
applyTunnelingMode(false) applyTunnelingMode(false)
emitSeekable(false, force = true)
if (isLive) { if (isLive) {
// Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells
@@ -1377,6 +1394,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog() stopFrameWatchdog()
cancelDecoderHangCheck() cancelDecoderHangCheck()
exoPlayer?.stop() exoPlayer?.stop()
emitSeekable(false, force = true)
setVisible(false) setVisible(false)
} }
@@ -1752,6 +1770,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
pendingStartPositionMs = 0L pendingStartPositionMs = 0L
currentMediaIsLive = false currentMediaIsLive = false
currentVisible = false currentVisible = false
emitSeekable(false, force = true)
selectedAudioTrackId = null selectedAudioTrackId = null
selectedSubtitleTrackId = null selectedSubtitleTrackId = null
selectedExternalSubtitleIndex = null selectedExternalSubtitleIndex = null
@@ -647,6 +647,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
// Setup property observers // Setup property observers
mpvCore?.observeProperty("time-pos", "double") mpvCore?.observeProperty("time-pos", "double")
mpvCore?.observeProperty("duration", "double") mpvCore?.observeProperty("duration", "double")
mpvCore?.observeProperty("seekable", "flag")
mpvCore?.observeProperty("pause", "flag") mpvCore?.observeProperty("pause", "flag")
mpvCore?.observeProperty("paused-for-cache", "flag") mpvCore?.observeProperty("paused-for-cache", "flag")
mpvCore?.observeProperty("demuxer-cache-time", "double") mpvCore?.observeProperty("demuxer-cache-time", "double")
+16 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../models.dart'; import '../../models.dart';
import '../../../utils/app_logger.dart';
import '../player_base.dart'; import '../player_base.dart';
/// Android implementation of [Player] using ExoPlayer. /// Android implementation of [Player] using ExoPlayer.
@@ -67,6 +68,7 @@ class PlayerAndroid extends PlayerBase {
// Register property observers so the plugin knows propId mappings // Register property observers so the plugin knows propId mappings
await observeProperty('time-pos', 'double'); await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double'); await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag'); await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag'); await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', 'string'); await observeProperty('track-list', 'string');
@@ -90,6 +92,7 @@ class PlayerAndroid extends PlayerBase {
Future<void> open(Media media, {bool play = true, bool isLive = false}) async { Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
setSeekable(false);
// Show the video layer // Show the video layer
await setVisible(true); await setVisible(true);
@@ -116,12 +119,21 @@ class PlayerAndroid extends PlayerBase {
@override @override
Future<void> stop() async { Future<void> stop() async {
await invoke('stop'); await invoke('stop');
setSeekable(false);
await setVisible(false); await setVisible(false);
} }
@override @override
Future<void> seek(Duration position) async { Future<void> seek(Duration position) async {
await invoke('seek', {'positionMs': position.inMilliseconds}); try {
await invoke('seek', {'positionMs': position.inMilliseconds});
} on PlatformException catch (e) {
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
appLogger.w('Seek failed (${e.code}), player not ready');
return;
}
rethrow;
}
} }
// ============================================ // ============================================
@@ -140,12 +152,7 @@ class PlayerAndroid extends PlayerBase {
@override @override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
await invoke('addSubtitleTrack', { await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
'uri': uri,
'title': title,
'language': language,
'select': select,
});
} }
// ============================================ // ============================================
@@ -232,6 +239,8 @@ class PlayerAndroid extends PlayerBase {
return (state.position.inMilliseconds / 1000.0).toString(); return (state.position.inMilliseconds / 1000.0).toString();
case 'duration': case 'duration':
return (state.duration.inMilliseconds / 1000.0).toString(); return (state.duration.inMilliseconds / 1000.0).toString();
case 'seekable':
return state.seekable ? 'yes' : 'no';
// Video dimensions - query from ExoPlayer stats // Video dimensions - query from ExoPlayer stats
case 'width': case 'width':
case 'dwidth': case 'dwidth':
+14
View File
@@ -169,6 +169,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
} }
break; break;
case 'seekable':
if (value is bool) {
setSeekable(value);
}
break;
case 'demuxer-cache-time': case 'demuxer-cache-time':
if (value is num) { if (value is num) {
final nowMs = _throttleSw.elapsedMilliseconds; final nowMs = _throttleSw.elapsedMilliseconds;
@@ -325,6 +331,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (_disposed) return; if (_disposed) return;
switch (name) { switch (name) {
case 'end-file': case 'end-file':
setSeekable(false);
final rawReason = data?['reason']; final rawReason = data?['reason'];
final reason = switch (rawReason) { final reason = switch (rawReason) {
0 => 'eof', 0 => 'eof',
@@ -462,6 +469,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_state = update(_state); _state = update(_state);
} }
@protected
void setSeekable(bool seekable) {
if (_state.seekable == seekable) return;
_state = _state.copyWith(seekable: seekable);
seekableController.add(seekable);
}
/// Safe method channel invocation — no-ops if player is disposed. /// Safe method channel invocation — no-ops if player is disposed.
@protected @protected
Future<T?> invoke<T>(String method, [dynamic args]) async { Future<T?> invoke<T>(String method, [dynamic args]) async {
+13 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../font_loader.dart'; import '../font_loader.dart';
import '../models.dart'; import '../models.dart';
import '../../utils/app_logger.dart';
import 'player_base.dart'; import 'player_base.dart';
/// Shared native implementation of [Player] for iOS, macOS, Android (MPV fallback), and Linux. /// Shared native implementation of [Player] for iOS, macOS, Android (MPV fallback), and Linux.
@@ -60,6 +61,7 @@ class PlayerNative extends PlayerBase {
// Subscribe to MPV properties // Subscribe to MPV properties
await observeProperty('time-pos', 'double'); await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double'); await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag'); await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag'); await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', _nodeFormat); await observeProperty('track-list', _nodeFormat);
@@ -114,6 +116,7 @@ class PlayerNative extends PlayerBase {
Future<void> open(Media media, {bool play = true, bool isLive = false}) async { Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
setSeekable(false);
// Show the video layer // Show the video layer
await setVisible(true); await setVisible(true);
@@ -164,12 +167,21 @@ class PlayerNative extends PlayerBase {
@override @override
Future<void> stop() async { Future<void> stop() async {
await command(['stop']); await command(['stop']);
setSeekable(false);
await invoke('setVisible', {'visible': false}); await invoke('setVisible', {'visible': false});
} }
@override @override
Future<void> seek(Duration position) async { Future<void> seek(Duration position) async {
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']); try {
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']);
} on PlatformException catch (e) {
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
appLogger.w('Seek failed (${e.code}), player not ready');
return;
}
rethrow;
}
} }
// ============================================ // ============================================
+7 -1
View File
@@ -20,6 +20,9 @@ class PlayerState {
/// Total duration of the media. /// Total duration of the media.
final Duration duration; final Duration duration;
/// Whether the current media item can be seeked.
final bool seekable;
/// Amount of media buffered ahead of current position. /// Amount of media buffered ahead of current position.
final Duration buffer; final Duration buffer;
@@ -59,6 +62,7 @@ class PlayerState {
this.buffering = false, this.buffering = false,
this.position = Duration.zero, this.position = Duration.zero,
this.duration = Duration.zero, this.duration = Duration.zero,
this.seekable = false,
this.buffer = Duration.zero, this.buffer = Duration.zero,
this.volume = 100.0, this.volume = 100.0,
this.rate = 1.0, this.rate = 1.0,
@@ -79,6 +83,7 @@ class PlayerState {
bool? buffering, bool? buffering,
Duration? position, Duration? position,
Duration? duration, Duration? duration,
bool? seekable,
Duration? buffer, Duration? buffer,
double? volume, double? volume,
double? rate, double? rate,
@@ -97,6 +102,7 @@ class PlayerState {
buffering: buffering ?? this.buffering, buffering: buffering ?? this.buffering,
position: position ?? this.position, position: position ?? this.position,
duration: duration ?? this.duration, duration: duration ?? this.duration,
seekable: seekable ?? this.seekable,
buffer: buffer ?? this.buffer, buffer: buffer ?? this.buffer,
volume: volume ?? this.volume, volume: volume ?? this.volume,
rate: rate ?? this.rate, rate: rate ?? this.rate,
@@ -112,5 +118,5 @@ class PlayerState {
} }
@override @override
String toString() => 'PlayerState(playing: $playing, position: $position, duration: $duration)'; String toString() => 'PlayerState(playing: $playing, position: $position, duration: $duration, seekable: $seekable)';
} }
@@ -14,6 +14,7 @@ mixin PlayerStreamControllersMixin {
final bufferingController = StreamController<bool>.broadcast(); final bufferingController = StreamController<bool>.broadcast();
final positionController = StreamController<Duration>.broadcast(); final positionController = StreamController<Duration>.broadcast();
final durationController = StreamController<Duration>.broadcast(); final durationController = StreamController<Duration>.broadcast();
final seekableController = StreamController<bool>.broadcast();
final bufferController = StreamController<Duration>.broadcast(); final bufferController = StreamController<Duration>.broadcast();
final volumeController = StreamController<double>.broadcast(); final volumeController = StreamController<double>.broadcast();
final rateController = StreamController<double>.broadcast(); final rateController = StreamController<double>.broadcast();
@@ -35,6 +36,7 @@ mixin PlayerStreamControllersMixin {
buffering: bufferingController.stream, buffering: bufferingController.stream,
position: positionController.stream, position: positionController.stream,
duration: durationController.stream, duration: durationController.stream,
seekable: seekableController.stream,
buffer: bufferController.stream, buffer: bufferController.stream,
volume: volumeController.stream, volume: volumeController.stream,
rate: rateController.stream, rate: rateController.stream,
@@ -57,6 +59,7 @@ mixin PlayerStreamControllersMixin {
await bufferingController.close(); await bufferingController.close();
await positionController.close(); await positionController.close();
await durationController.close(); await durationController.close();
await seekableController.close();
await bufferController.close(); await bufferController.close();
await volumeController.close(); await volumeController.close();
await rateController.close(); await rateController.close();
+4
View File
@@ -20,6 +20,9 @@ class PlayerStreams {
/// Stream of duration changes (when media is loaded). /// Stream of duration changes (when media is loaded).
final Stream<Duration> duration; final Stream<Duration> duration;
/// Stream of seekability changes for the current media item.
final Stream<bool> seekable;
/// Stream of buffer position updates. /// Stream of buffer position updates.
final Stream<Duration> buffer; final Stream<Duration> buffer;
@@ -63,6 +66,7 @@ class PlayerStreams {
required this.buffering, required this.buffering,
required this.position, required this.position,
required this.duration, required this.duration,
required this.seekable,
required this.buffer, required this.buffer,
required this.volume, required this.volume,
required this.rate, required this.rate,
+70 -38
View File
@@ -156,6 +156,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<bool>? _mediaControlsPlayingSubscription; StreamSubscription<bool>? _mediaControlsPlayingSubscription;
StreamSubscription<Duration>? _mediaControlsPositionSubscription; StreamSubscription<Duration>? _mediaControlsPositionSubscription;
StreamSubscription<double>? _mediaControlsRateSubscription; StreamSubscription<double>? _mediaControlsRateSubscription;
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
bool _isDisposingForNavigation = false; bool _isDisposingForNavigation = false;
bool _waitingForExternalSubsTrackSelection = false; bool _waitingForExternalSubsTrackSelection = false;
@@ -356,7 +357,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (_shouldSkipForPip) break; if (_shouldSkipForPip) break;
// Clear media controls when app truly goes to background // Clear media controls when app truly goes to background
// (we don't support background playback) // (we don't support background playback)
OsMediaControls.clear(); _mediaControlsManager?.clear();
// Disable wakelock when app goes to background // Disable wakelock when app goes to background
_setWakelock(false); _setWakelock(false);
appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded'); appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded');
@@ -364,28 +365,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
// Restore media controls and wakelock when app is resumed // Restore media controls and wakelock when app is resumed
if (_isPlayerInitialized && mounted) { if (_isPlayerInitialized && mounted) {
// Re-enable wakelock since we're back in the video player unawaited(_restoreMediaControlsAfterResume());
_setWakelock(true);
// Restore media metadata (only in online mode - requires client for artwork URLs)
if (!widget.isOffline && _mediaControlsManager != null) {
final client = _getClientForMetadata(context);
_mediaControlsManager!.updateMetadata(
metadata: widget.metadata,
client: client,
duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null,
);
}
// Resume playback if it was playing before going inactive
if (_wasPlayingBeforeInactive && player != null) {
player!.play();
_wasPlayingBeforeInactive = false;
appLogger.d('Video resumed after returning from inactive state');
}
_updateMediaControlsPlaybackState();
appLogger.d('Media controls restored and wakelock re-enabled on app resume');
} }
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
@@ -779,7 +759,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
} else if (event is SeekEvent) { } else if (event is SeekEvent) {
appLogger.d('Media control: Seek event received to ${event.position}'); appLogger.d('Media control: Seek event received to ${event.position}');
player?.seek(event.position); final currentPlayer = player;
if (currentPlayer != null) {
unawaited(currentPlayer.seek(clampSeekPosition(currentPlayer, event.position)));
}
} else if (event is NextTrackEvent) { } else if (event is NextTrackEvent) {
appLogger.d('Media control: Next track event received'); appLogger.d('Media control: Next track event received');
if (_nextEpisode != null) { if (_nextEpisode != null) {
@@ -802,15 +785,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!mounted) return; if (!mounted) return;
// Set controls enabled based on content type await _syncMediaControlsAvailability();
final playbackState = context.read<PlaybackStateProvider>();
final isEpisode = widget.metadata.isEpisode;
final isInPlaylist = playbackState.isPlaylistActive;
await _mediaControlsManager!.setControlsEnabled(
canGoNext: isEpisode || isInPlaylist,
canGoPrevious: isEpisode || isInPlaylist,
);
// Listen to playing state and update media controls // Listen to playing state and update media controls
_mediaControlsPlayingSubscription = player!.streams.playing.listen((isPlaying) { _mediaControlsPlayingSubscription = player!.streams.playing.listen((isPlaying) {
@@ -832,6 +807,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
DiscordRPCService.instance.updatePlaybackSpeed(rate); DiscordRPCService.instance.updatePlaybackSpeed(rate);
}); });
_mediaControlsSeekableSubscription = player!.streams.seekable.listen((_) {
unawaited(_syncMediaControlsAvailability());
});
// Start Discord Rich Presence for current media // Start Discord Rich Presence for current media
if (client != null) { if (client != null) {
DiscordRPCService.instance.startPlayback(widget.metadata, client); DiscordRPCService.instance.startPlayback(widget.metadata, client);
@@ -1527,12 +1506,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
receiver.onSeekForward = () async { receiver.onSeekForward = () async {
if (player == null) return; if (player == null) return;
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
seekWithClamping(player!, Duration(seconds: settings.getSeekTimeSmall())); final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: settings.getSeekTimeSmall()));
await player!.seek(target);
}; };
receiver.onSeekBackward = () async { receiver.onSeekBackward = () async {
if (player == null) return; if (player == null) return;
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
seekWithClamping(player!, Duration(seconds: -settings.getSeekTimeSmall())); final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: settings.getSeekTimeSmall()));
await player!.seek(target);
}; };
receiver.onVolumeUp = () async { receiver.onVolumeUp = () async {
if (player == null) return; if (player == null) return;
@@ -1757,6 +1738,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_mediaControlsPlayingSubscription?.cancel(); _mediaControlsPlayingSubscription?.cancel();
_mediaControlsPositionSubscription?.cancel(); _mediaControlsPositionSubscription?.cancel();
_mediaControlsRateSubscription?.cancel(); _mediaControlsRateSubscription?.cancel();
_mediaControlsSeekableSubscription?.cancel();
// Cancel auto-play timer // Cancel auto-play timer
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
@@ -1903,9 +1885,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (autoPlayEnabled) { if (autoPlayEnabled) {
_startAutoPlayTimer(); _startAutoPlayTimer();
} }
} else if (completed && } else if (completed && _nextEpisode == null && !_completionTriggered) {
_nextEpisode == null &&
!_completionTriggered) {
_completionTriggered = true; _completionTriggered = true;
_handleBackButton(); _handleBackButton();
} }
@@ -1929,6 +1909,58 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// OS Media Controls Integration // OS Media Controls Integration
Future<void> _syncMediaControlsAvailability() async {
final manager = _mediaControlsManager;
final currentPlayer = player;
if (!mounted || manager == null || currentPlayer == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final canNavigateEpisodes = widget.metadata.isEpisode || playbackState.isPlaylistActive;
final canSeek = !widget.isLive && currentPlayer.state.seekable;
if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return;
await manager.setControlsEnabled(
canGoNext: canNavigateEpisodes,
canGoPrevious: canNavigateEpisodes,
canSeek: canSeek,
);
}
Future<void> _restoreMediaControlsAfterResume() async {
if (!_isPlayerInitialized || !mounted) return;
_setWakelock(true);
final manager = _mediaControlsManager;
final currentPlayer = player;
if (manager != null && currentPlayer != null) {
final client = widget.isOffline ? null : _getClientForMetadata(context);
await manager.updateMetadata(
metadata: widget.metadata,
client: client,
duration: widget.metadata.duration != null ? Duration(milliseconds: widget.metadata.duration!) : null,
);
await _syncMediaControlsAvailability();
}
if (!mounted || currentPlayer != player || currentPlayer == null) return;
if (_wasPlayingBeforeInactive) {
try {
await currentPlayer.play();
appLogger.d('Video resumed after returning from inactive state');
} catch (e) {
appLogger.w('Failed to resume playback after returning from inactive state', error: e);
} finally {
_wasPlayingBeforeInactive = false;
}
}
_updateMediaControlsPlaybackState();
appLogger.d('Media controls restored and wakelock re-enabled on app resume');
}
/// Wrapper method to update media controls playback state /// Wrapper method to update media controls playback state
void _updateMediaControlsPlaybackState() { void _updateMediaControlsPlaybackState() {
if (player == null) return; if (player == null) return;
+9 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async' show unawaited;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -250,16 +251,20 @@ class KeyboardShortcutsService {
_settingsService.setVolume(newVolume); _settingsService.setVolume(newVolume);
break; break;
case 'seek_forward': case 'seek_forward':
seekWithClamping(player, Duration(seconds: _seekTimeSmall)); final fwdTarget = clampSeekPosition(player, player.state.position + Duration(seconds: _seekTimeSmall));
unawaited(player.seek(fwdTarget));
break; break;
case 'seek_backward': case 'seek_backward':
seekWithClamping(player, Duration(seconds: -_seekTimeSmall)); final bwdTarget = clampSeekPosition(player, player.state.position - Duration(seconds: _seekTimeSmall));
unawaited(player.seek(bwdTarget));
break; break;
case 'seek_forward_large': case 'seek_forward_large':
seekWithClamping(player, Duration(seconds: _seekTimeLarge)); final fwdLTarget = clampSeekPosition(player, player.state.position + Duration(seconds: _seekTimeLarge));
unawaited(player.seek(fwdLTarget));
break; break;
case 'seek_backward_large': case 'seek_backward_large':
seekWithClamping(player, Duration(seconds: -_seekTimeLarge)); final bwdLTarget = clampSeekPosition(player, player.state.position - Duration(seconds: _seekTimeLarge));
unawaited(player.seek(bwdLTarget));
break; break;
case 'fullscreen_toggle': case 'fullscreen_toggle':
onToggleFullscreen?.call(); onToggleFullscreen?.call();
+29 -18
View File
@@ -23,6 +23,7 @@ class MediaControlsManager {
/// Cached control enabled state to avoid redundant platform calls /// Cached control enabled state to avoid redundant platform calls
bool? _lastCanGoNext; bool? _lastCanGoNext;
bool? _lastCanGoPrevious; bool? _lastCanGoPrevious;
bool? _lastCanSeek;
MediaControlsManager() { MediaControlsManager() {
_throttledUpdate = throttle( _throttledUpdate = throttle(
@@ -109,27 +110,34 @@ class MediaControlsManager {
/// - Episodes: Enable both if there are adjacent episodes /// - Episodes: Enable both if there are adjacent episodes
/// - Playlist items: Enable based on playlist position /// - Playlist items: Enable based on playlist position
/// - Movies: Usually disabled /// - Movies: Usually disabled
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false}) async { Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {
// Skip if unchanged (avoid redundant platform calls)
if (canGoNext == _lastCanGoNext && canGoPrevious == _lastCanGoPrevious) {
return;
}
_lastCanGoNext = canGoNext;
_lastCanGoPrevious = canGoPrevious;
try { try {
final controls = <MediaControl>[]; final controlsToEnable = <MediaControl>[];
if (canGoPrevious) controls.add(MediaControl.previous); final controlsToDisable = <MediaControl>[];
if (canGoNext) controls.add(MediaControl.next);
if (controls.isNotEmpty) { if (canGoPrevious != _lastCanGoPrevious) {
await OsMediaControls.enableControls(controls); (canGoPrevious ? controlsToEnable : controlsToDisable).add(MediaControl.previous);
appLogger.d('Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext');
} else {
await OsMediaControls.disableControls([MediaControl.previous, MediaControl.next]);
appLogger.d('Media controls disabled');
} }
if (canGoNext != _lastCanGoNext) {
(canGoNext ? controlsToEnable : controlsToDisable).add(MediaControl.next);
}
if (canSeek != _lastCanSeek) {
(canSeek ? controlsToEnable : controlsToDisable).add(MediaControl.seek);
}
if (controlsToEnable.isEmpty && controlsToDisable.isEmpty) return;
if (controlsToEnable.isNotEmpty) {
await OsMediaControls.enableControls(controlsToEnable);
}
if (controlsToDisable.isNotEmpty) {
await OsMediaControls.disableControls(controlsToDisable);
}
_lastCanGoNext = canGoNext;
_lastCanGoPrevious = canGoPrevious;
_lastCanSeek = canSeek;
appLogger.d('Media controls updated - Previous: $canGoPrevious, Next: $canGoNext, Seek: $canSeek');
} catch (e) { } catch (e) {
appLogger.w('Failed to set media controls enabled state', error: e); appLogger.w('Failed to set media controls enabled state', error: e);
} }
@@ -142,6 +150,9 @@ class MediaControlsManager {
try { try {
await OsMediaControls.clear(); await OsMediaControls.clear();
_throttledUpdate.cancel(); _throttledUpdate.cancel();
_lastCanGoNext = null;
_lastCanGoPrevious = null;
_lastCanSeek = null;
appLogger.d('Media controls cleared'); appLogger.d('Media controls cleared');
} catch (e) { } catch (e) {
appLogger.w('Failed to clear media controls', error: e); appLogger.w('Failed to clear media controls', error: e);
+4 -19
View File
@@ -1,23 +1,8 @@
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
/// Seeks by the given offset (can be positive or negative) while clamping Duration clampSeekPosition(Player player, Duration position) {
/// the result between 0 and the video duration.
/// Returns the clamped position that was seeked to.
Duration seekWithClamping(Player player, Duration offset) {
final currentPosition = player.state.position;
final duration = player.state.duration; final duration = player.state.duration;
final newPosition = currentPosition + offset; if (position.isNegative) return Duration.zero;
if (duration > Duration.zero && position > duration) return duration;
// Clamp between 0 and video duration return position;
Duration clampedPosition;
if (newPosition.isNegative) {
clampedPosition = Duration.zero;
} else if (newPosition > duration) {
clampedPosition = duration;
} else {
clampedPosition = newPosition;
}
player.seek(clampedPosition);
return clampedPosition;
} }
@@ -83,6 +83,9 @@ class DesktopVideoControls extends StatefulWidget {
/// Called when content strip visibility changes /// Called when content strip visibility changes
final ValueChanged<bool>? onContentStripVisibilityChanged; final ValueChanged<bool>? onContentStripVisibilityChanged;
/// Called when a seek operation completes successfully.
final Function(Duration position)? onSeekCompleted;
const DesktopVideoControls({ const DesktopVideoControls({
super.key, super.key,
required this.player, required this.player,
@@ -115,6 +118,7 @@ class DesktopVideoControls extends StatefulWidget {
this.onCancelAutoHide, this.onCancelAutoHide,
this.onStartAutoHide, this.onStartAutoHide,
this.onContentStripVisibilityChanged, this.onContentStripVisibilityChanged,
this.onSeekCompleted,
}); });
@override @override
@@ -515,6 +519,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
serverId: widget.serverId, serverId: widget.serverId,
showQueueTab: widget.showQueueTab, showQueueTab: widget.showQueueTab,
onQueueItemSelected: widget.onQueueItemSelected, onQueueItemSelected: widget.onQueueItemSelected,
onSeekCompleted: widget.onSeekCompleted,
useFocusNavigation: true, useFocusNavigation: true,
onNavigateUp: _onContentStripNavigateUp, onNavigateUp: _onContentStripNavigateUp,
onFocusActivity: widget.onFocusActivity, onFocusActivity: widget.onFocusActivity,
@@ -793,6 +798,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
chapters: widget.chapters, chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded, chaptersLoaded: widget.chaptersLoaded,
trackControlsState: _trackControlsState, trackControlsState: _trackControlsState,
onSeekCompleted: widget.onSeekCompleted,
focusNodes: _trackControlFocusNodes, focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange, onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume, onNavigateLeft: navigateFromTrackToVolume,
@@ -1,3 +1,5 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -9,6 +11,7 @@ import '../../../services/download_storage_service.dart';
import '../../../models/plex_media_info.dart'; import '../../../models/plex_media_info.dart';
import '../../../theme/mono_tokens.dart'; import '../../../theme/mono_tokens.dart';
import '../../../utils/formatters.dart'; import '../../../utils/formatters.dart';
import '../../../utils/player_utils.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/overlay_sheet.dart';
@@ -21,6 +24,7 @@ class ChapterSheet extends StatefulWidget {
final List<PlexChapter> chapters; final List<PlexChapter> chapters;
final bool chaptersLoaded; final bool chaptersLoaded;
final String? serverId; // Server ID for the metadata these chapters belong to final String? serverId; // Server ID for the metadata these chapters belong to
final Function(Duration position)? onSeekCompleted;
const ChapterSheet({ const ChapterSheet({
super.key, super.key,
@@ -28,6 +32,7 @@ class ChapterSheet extends StatefulWidget {
required this.chapters, required this.chapters,
required this.chaptersLoaded, required this.chaptersLoaded,
this.serverId, this.serverId,
this.onSeekCompleted,
}); });
@override @override
@@ -35,6 +40,15 @@ class ChapterSheet extends StatefulWidget {
} }
class _ChapterSheetState extends State<ChapterSheet> { class _ChapterSheetState extends State<ChapterSheet> {
Future<void> _handleChapterTap(Duration position) async {
final clamped = clampSeekPosition(widget.player, position);
await widget.player.seek(clamped);
if (mounted) {
widget.onSeekCompleted?.call(clamped);
OverlaySheetController.of(context).close();
}
}
/// Get the PlexClient for chapters, or null if unavailable (offline mode) /// Get the PlexClient for chapters, or null if unavailable (offline mode)
PlexClient? _tryGetClientForChapters(BuildContext context) { PlexClient? _tryGetClientForChapters(BuildContext context) {
return context.tryGetClientForServer(widget.serverId); return context.tryGetClientForServer(widget.serverId);
@@ -138,8 +152,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary) ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary)
: null, : null,
onTap: () { onTap: () {
widget.player.seek(chapter.startTime); unawaited(_handleChapterTap(chapter.startTime));
OverlaySheetController.of(context).close();
}, },
); );
}, },
+50 -68
View File
@@ -1,4 +1,4 @@
import 'dart:async' show StreamSubscription, Timer; import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
@@ -255,6 +255,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
StreamSubscription<bool>? _playingSubscription; StreamSubscription<bool>? _playingSubscription;
// Completed subscription to show controls when video ends // Completed subscription to show controls when video ends
StreamSubscription<bool>? _completedSubscription; StreamSubscription<bool>? _completedSubscription;
// Position subscription for marker tracking
StreamSubscription<Duration>? _positionSubscription;
// Auto-skip state // Auto-skip state
bool _autoSkipIntro = false; bool _autoSkipIntro = false;
bool _autoSkipCredits = false; bool _autoSkipCredits = false;
@@ -287,7 +289,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_focusNode = FocusNode(); _focusNode = FocusNode();
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton'); _skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
_seekThrottle = throttle( _seekThrottle = throttle(
(Duration pos) => widget.player.seek(pos), (Duration pos) {
unawaited(_seekToPosition(pos, notifyCompletion: false));
},
const Duration(milliseconds: 200), const Duration(milliseconds: 200),
leading: true, leading: true,
trailing: true, trailing: true,
@@ -356,7 +360,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
} }
void _listenToPosition() { void _listenToPosition() {
widget.player.streams.position.listen((position) { _positionSubscription = widget.player.streams.position.listen((position) {
if (_markers.isEmpty || !_markersLoaded) { if (_markers.isEmpty || !_markersLoaded) {
return; return;
} }
@@ -436,14 +440,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}); });
} }
void _skipMarker() { Future<void> _skipMarker() async {
if (_currentMarker != null) { if (_currentMarker != null) {
final endTime = _currentMarker!.endTime; final endTime = _currentMarker!.endTime;
await _seekToPosition(endTime);
if (!mounted) return;
setState(() { setState(() {
_currentMarker = null; _currentMarker = null;
}); });
widget.player.seek(endTime);
widget.onSeekCompleted?.call(endTime);
} }
_cancelAutoSkipTimer(); _cancelAutoSkipTimer();
_cancelSkipButtonDismissTimer(); _cancelSkipButtonDismissTimer();
@@ -474,11 +478,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (timer.tick >= totalTicks) { if (timer.tick >= totalTicks) {
timer.cancel(); timer.cancel();
try { _performAutoSkip();
_performAutoSkip();
} catch (e) {
// Handle any errors during skip gracefully
}
} }
}); });
} }
@@ -522,7 +522,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (showNextEpisode) { if (showNextEpisode) {
widget.onNext?.call(); widget.onNext?.call();
} else { } else {
_skipMarker(); unawaited(_skipMarker());
} }
} }
@@ -655,6 +655,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_seekThrottle.cancel(); _seekThrottle.cancel();
_playingSubscription?.cancel(); _playingSubscription?.cancel();
_completedSubscription?.cancel(); _completedSubscription?.cancel();
_positionSubscription?.cancel();
_focusNode.dispose(); _focusNode.dispose();
_skipMarkerFocusNode.dispose(); _skipMarkerFocusNode.dispose();
// Restore original rate if long-press was active when disposed // Restore original rate if long-press was active when disposed
@@ -1070,36 +1071,25 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
chapters: _chapters, chapters: _chapters,
chaptersLoaded: _chaptersLoaded, chaptersLoaded: _chaptersLoaded,
trackControlsState: trackControlsState, trackControlsState: trackControlsState,
onSeekCompleted: widget.onSeekCompleted,
hideChaptersAndQueue: hideChaptersAndQueue, hideChaptersAndQueue: hideChaptersAndQueue,
); );
} }
void _seekToPreviousChapter() => _seekToChapter(forward: false); void _seekToPreviousChapter() => unawaited(_seekToChapter(forward: false));
void _seekToNextChapter() => _seekToChapter(forward: true); void _seekToNextChapter() => unawaited(_seekToChapter(forward: true));
void _seekByTime({required bool forward}) { Future<void> _seekByTime({required bool forward}) async {
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
final newPosition = seekWithClamping(widget.player, delta); await _seekByOffset(delta);
widget.onSeekCompleted?.call(newPosition);
} }
void _seekToChapter({required bool forward}) { Future<void> _seekToChapter({required bool forward}) async {
if (_chapters.isEmpty) { if (_chapters.isEmpty) {
// No chapters - seek by configured amount // No chapters - seek by configured amount
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
final duration = widget.player.state.duration; await _seekByOffset(delta);
final unclamped = widget.player.state.position + delta;
Duration newPosition;
if (unclamped < Duration.zero) {
newPosition = Duration.zero;
} else if (unclamped > duration) {
newPosition = duration;
} else {
newPosition = unclamped;
}
seekWithClamping(widget.player, delta);
widget.onSeekCompleted?.call(newPosition);
return; return;
} }
@@ -1110,7 +1100,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
for (final chapter in _chapters) { for (final chapter in _chapters) {
final chapterStart = chapter.startTimeOffset ?? 0; final chapterStart = chapter.startTimeOffset ?? 0;
if (chapterStart > currentPositionMs) { if (chapterStart > currentPositionMs) {
_seekToPosition(Duration(milliseconds: chapterStart)); await _seekToPosition(Duration(milliseconds: chapterStart));
return; return;
} }
} }
@@ -1120,18 +1110,30 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
final chapterStart = _chapters[i].startTimeOffset ?? 0; final chapterStart = _chapters[i].startTimeOffset ?? 0;
if (currentPositionMs > chapterStart + 3000) { if (currentPositionMs > chapterStart + 3000) {
// If more than 3 seconds into chapter, go to start of current chapter // If more than 3 seconds into chapter, go to start of current chapter
_seekToPosition(Duration(milliseconds: chapterStart)); await _seekToPosition(Duration(milliseconds: chapterStart));
return; return;
} }
} }
// If at start of first chapter, go to beginning // If at start of first chapter, go to beginning
_seekToPosition(Duration.zero); await _seekToPosition(Duration.zero);
} }
} }
void _seekToPosition(Duration position) { Future<void> _seekToPosition(Duration position, {bool notifyCompletion = true}) async {
widget.player.seek(position); final clamped = clampSeekPosition(widget.player, position);
widget.onSeekCompleted?.call(position); await widget.player.seek(clamped);
if (notifyCompletion && mounted) {
widget.onSeekCompleted?.call(clamped);
}
}
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
final target = widget.player.state.position + delta;
final clamped = clampSeekPosition(widget.player, target);
await widget.player.seek(clamped);
if (notifyCompletion && mounted) {
widget.onSeekCompleted?.call(clamped);
}
} }
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms /// Throttled seek for timeline slider - executes immediately then throttles to 200ms
@@ -1140,8 +1142,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
/// Finalizes the seek when user stops scrubbing the timeline /// Finalizes the seek when user stops scrubbing the timeline
void _finalizeSeek(Duration position) { void _finalizeSeek(Duration position) {
_seekThrottle.cancel(); _seekThrottle.cancel();
widget.player.seek(position); unawaited(_seekToPosition(position));
widget.onSeekCompleted?.call(position);
} }
/// Handle tap in skip zone for desktop mode /// Handle tap in skip zone for desktop mode
@@ -1179,10 +1180,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) { if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) {
// Stacking skip - add to accumulated // Stacking skip - add to accumulated
_handleStackingSkip(isForward: isForward); unawaited(_handleStackingSkip(isForward: isForward));
} else { } else {
// First double-tap - initiate skip // First double-tap - initiate skip
_handleDoubleTapSkip(isForward: isForward); unawaited(_handleDoubleTapSkip(isForward: isForward));
} }
} else { } else {
// First tap - record timestamp and start timer for single-tap action // First tap - record timestamp and start timer for single-tap action
@@ -1199,7 +1200,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
} }
/// Handle stacking skip - add to accumulated skip when feedback is active /// Handle stacking skip - add to accumulated skip when feedback is active
void _handleStackingSkip({required bool isForward}) { Future<void> _handleStackingSkip({required bool isForward}) async {
if (!widget.canControl) return; if (!widget.canControl) return;
// Add to accumulated skip // Add to accumulated skip
@@ -1207,10 +1208,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Calculate and perform seek // Calculate and perform seek
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
final newPosition = seekWithClamping(widget.player, delta); await _seekByOffset(delta);
// Notify Watch Together
widget.onSeekCompleted?.call(newPosition);
// Refresh feedback (extends timer, updates display) // Refresh feedback (extends timer, updates display)
_showSkipFeedback(isForward: isForward); _showSkipFeedback(isForward: isForward);
@@ -1220,32 +1218,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
} }
/// Handle double-tap skip forward or backward /// Handle double-tap skip forward or backward
void _handleDoubleTapSkip({required bool isForward}) { Future<void> _handleDoubleTapSkip({required bool isForward}) async {
// Ignore if user cannot control playback // Ignore if user cannot control playback
if (!widget.canControl) return; if (!widget.canControl) return;
// Reset accumulated skip for new gesture // Reset accumulated skip for new gesture
_accumulatedSkipSeconds = _seekTimeSmall; _accumulatedSkipSeconds = _seekTimeSmall;
// Calculate the new position (clamped to valid range)
final currentPosition = widget.player.state.position;
final duration = widget.player.state.duration;
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
final unclamped = currentPosition + delta; await _seekByOffset(delta);
Duration newPosition;
if (unclamped < Duration.zero) {
newPosition = Duration.zero;
} else if (unclamped > duration) {
newPosition = duration;
} else {
newPosition = unclamped;
}
// Perform the seek
seekWithClamping(widget.player, delta);
// Notify Watch Together
widget.onSeekCompleted?.call(newPosition);
// Show visual feedback // Show visual feedback
_showSkipFeedback(isForward: isForward); _showSkipFeedback(isForward: isForward);
@@ -1736,7 +1717,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (widget.canControl) { if (widget.canControl) {
final isForward = final isForward =
key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward; key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward;
_seekToChapter(forward: isForward); unawaited(_seekToChapter(forward: isForward));
} }
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -1746,7 +1727,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Uses same behavior as seek keys: chapter navigation or time-based seek // Uses same behavior as seek keys: chapter navigation or time-based seek
if (event is KeyDownEvent && _isMediaTrackKey(key)) { if (event is KeyDownEvent && _isMediaTrackKey(key)) {
if (widget.canControl) { if (widget.canControl) {
_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext); unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext));
} }
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -2080,8 +2061,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
seekTimeSmall: _seekTimeSmall, seekTimeSmall: _seekTimeSmall,
onSeekToPreviousChapter: _seekToPreviousChapter, onSeekToPreviousChapter: _seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter, onSeekToNextChapter: _seekToNextChapter,
onSeekBackward: () => _seekByTime(forward: false), onSeekBackward: () => unawaited(_seekByTime(forward: false)),
onSeekForward: () => _seekByTime(forward: true), onSeekForward: () => unawaited(_seekByTime(forward: true)),
onSeek: _throttledSeek, onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek, onSeekEnd: _finalizeSeek,
getReplayIcon: getReplayIcon, getReplayIcon: getReplayIcon,
@@ -2099,6 +2080,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
onCancelAutoHide: () => _hideTimer?.cancel(), onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer, onStartAutoHide: _startHideTimer,
onSeekCompleted: widget.onSeekCompleted,
onContentStripVisibilityChanged: (visible) { onContentStripVisibilityChanged: (visible) {
setState(() => _isContentStripVisible = visible); setState(() => _isContentStripVisible = visible);
if (visible) { if (visible) {
@@ -1,3 +1,5 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -14,6 +16,7 @@ import '../../../services/download_storage_service.dart';
import '../../../services/plex_client.dart'; import '../../../services/plex_client.dart';
import '../../../theme/mono_tokens.dart'; import '../../../theme/mono_tokens.dart';
import '../../../utils/formatters.dart'; import '../../../utils/formatters.dart';
import '../../../utils/player_utils.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import '../../app_icon.dart'; import '../../app_icon.dart';
import '../../plex_optimized_image.dart'; import '../../plex_optimized_image.dart';
@@ -26,6 +29,7 @@ class ContentStrip extends StatefulWidget {
final String? serverId; final String? serverId;
final bool showQueueTab; final bool showQueueTab;
final Function(PlexMetadata)? onQueueItemSelected; final Function(PlexMetadata)? onQueueItemSelected;
final Function(Duration position)? onSeekCompleted;
/// Whether to use dpad/focus-based navigation (TV mode). /// Whether to use dpad/focus-based navigation (TV mode).
/// When true, no tab bar is shown — pages are navigated via UP/DOWN. /// When true, no tab bar is shown — pages are navigated via UP/DOWN.
@@ -45,6 +49,7 @@ class ContentStrip extends StatefulWidget {
this.serverId, this.serverId,
this.showQueueTab = false, this.showQueueTab = false,
this.onQueueItemSelected, this.onQueueItemSelected,
this.onSeekCompleted,
this.useFocusNavigation = false, this.useFocusNavigation = false,
this.onNavigateUp, this.onNavigateUp,
this.onFocusActivity, this.onFocusActivity,
@@ -120,6 +125,14 @@ class ContentStripState extends State<ContentStrip> {
return null; return null;
} }
Future<void> _handleChapterTap(Duration position) async {
final clamped = clampSeekPosition(widget.player, position);
await widget.player.seek(clamped);
if (mounted) {
widget.onSeekCompleted?.call(clamped);
}
}
int? _getCurrentQueueIndex() { int? _getCurrentQueueIndex() {
try { try {
final playbackState = context.read<PlaybackStateProvider>(); final playbackState = context.read<PlaybackStateProvider>();
@@ -155,13 +168,7 @@ class ContentStripState extends State<ContentStrip> {
}); });
} }
KeyEventResult _handleFocusItemKeyEvent( KeyEventResult _handleFocusItemKeyEvent(FocusNode node, KeyEvent event, int index, int totalItems, _StripTab page) {
FocusNode node,
KeyEvent event,
int index,
int totalItems,
_StripTab page,
) {
if (!event.isActionable) return KeyEventResult.ignored; if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey; final key = event.logicalKey;
@@ -363,7 +370,7 @@ class ContentStripState extends State<ContentStrip> {
? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!)
: null; : null;
void onTap() => widget.player.seek(chapter.startTime); void onTap() => unawaited(_handleChapterTap(chapter.startTime));
final item = _buildStripItem( final item = _buildStripItem(
context: context, context: context,
@@ -29,6 +29,7 @@ class TrackChapterControls extends StatelessWidget {
final List<PlexChapter> chapters; final List<PlexChapter> chapters;
final bool chaptersLoaded; final bool chaptersLoaded;
final TrackControlsState trackControlsState; final TrackControlsState trackControlsState;
final Function(Duration position)? onSeekCompleted;
/// List of FocusNodes for the buttons (passed from parent for navigation) /// List of FocusNodes for the buttons (passed from parent for navigation)
final List<FocusNode>? focusNodes; final List<FocusNode>? focusNodes;
@@ -54,6 +55,7 @@ class TrackChapterControls extends StatelessWidget {
required this.chapters, required this.chapters,
required this.chaptersLoaded, required this.chaptersLoaded,
required this.trackControlsState, required this.trackControlsState,
this.onSeekCompleted,
this.focusNodes, this.focusNodes,
this.onFocusChange, this.onFocusChange,
this.onNavigateLeft, this.onNavigateLeft,
@@ -286,6 +288,7 @@ class TrackChapterControls extends StatelessWidget {
chapters: chapters, chapters: chapters,
chaptersLoaded: chaptersLoaded, chaptersLoaded: chaptersLoaded,
serverId: serverId, serverId: serverId,
onSeekCompleted: onSeekCompleted,
), ),
) )
.whenComplete(() => onStartAutoHide?.call()); .whenComplete(() => onStartAutoHide?.call());