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