diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index d4e2027f..6bc5aac5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 6257032e..d8204ebb 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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") diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 1c8ffb75..8467c66b 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -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 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 stop() async { await invoke('stop'); + setSeekable(false); await setVisible(false); } @override Future 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 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': diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index a3bacbaf..defea1c2 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -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 invoke(String method, [dynamic args]) async { diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 345efb7a..aba2ad74 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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 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 stop() async { await command(['stop']); + setSeekable(false); await invoke('setVisible', {'visible': false}); } @override Future 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; + } } // ============================================ diff --git a/lib/mpv/player/player_state.dart b/lib/mpv/player/player_state.dart index 0a11d8c9..85ab996f 100644 --- a/lib/mpv/player/player_state.dart +++ b/lib/mpv/player/player_state.dart @@ -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)'; } diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index f7393f87..978282c4 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -14,6 +14,7 @@ mixin PlayerStreamControllersMixin { final bufferingController = StreamController.broadcast(); final positionController = StreamController.broadcast(); final durationController = StreamController.broadcast(); + final seekableController = StreamController.broadcast(); final bufferController = StreamController.broadcast(); final volumeController = StreamController.broadcast(); final rateController = StreamController.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(); diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 22deac6d..0efb0043 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -20,6 +20,9 @@ class PlayerStreams { /// Stream of duration changes (when media is loaded). final Stream duration; + /// Stream of seekability changes for the current media item. + final Stream seekable; + /// Stream of buffer position updates. final Stream 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, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fbd25331..370cb369 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -156,6 +156,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _mediaControlsPlayingSubscription; StreamSubscription? _mediaControlsPositionSubscription; StreamSubscription? _mediaControlsRateSubscription; + StreamSubscription? _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 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 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 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 with WidgetsBindin if (!mounted) return; - // Set controls enabled based on content type - final playbackState = context.read(); - 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 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 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 with WidgetsBindin _mediaControlsPlayingSubscription?.cancel(); _mediaControlsPositionSubscription?.cancel(); _mediaControlsRateSubscription?.cancel(); + _mediaControlsSeekableSubscription?.cancel(); // Cancel auto-play timer _autoPlayTimer?.cancel(); @@ -1903,9 +1885,7 @@ class VideoPlayerScreenState extends State 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 with WidgetsBindin // OS Media Controls Integration + Future _syncMediaControlsAvailability() async { + final manager = _mediaControlsManager; + final currentPlayer = player; + if (!mounted || manager == null || currentPlayer == null) return; + + final playbackState = context.read(); + 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 _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; diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 2488c67f..d7cbc264 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -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(); diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index f9d58317..c07a6fb8 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 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 setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async { try { - final controls = []; - if (canGoPrevious) controls.add(MediaControl.previous); - if (canGoNext) controls.add(MediaControl.next); + final controlsToEnable = []; + final controlsToDisable = []; - 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); diff --git a/lib/utils/player_utils.dart b/lib/utils/player_utils.dart index b85d9702..96978c5b 100644 --- a/lib/utils/player_utils.dart +++ b/lib/utils/player_utils.dart @@ -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; } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index f4faa5cc..ba7e61b3 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -83,6 +83,9 @@ class DesktopVideoControls extends StatefulWidget { /// Called when content strip visibility changes final ValueChanged? 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 { 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 { chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, trackControlsState: _trackControlsState, + onSeekCompleted: widget.onSeekCompleted, focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index b599498b..5f35f2b9 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -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 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 { + Future _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 { ? 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)); }, ); }, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index b769d927..3a275981 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 with WindowListen StreamSubscription? _playingSubscription; // Completed subscription to show controls when video ends StreamSubscription? _completedSubscription; + // Position subscription for marker tracking + StreamSubscription? _positionSubscription; // Auto-skip state bool _autoSkipIntro = false; bool _autoSkipCredits = false; @@ -287,7 +289,9 @@ class _PlexVideoControlsState extends State 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 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 with WindowListen }); } - void _skipMarker() { + Future _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 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 with WindowListen if (showNextEpisode) { widget.onNext?.call(); } else { - _skipMarker(); + unawaited(_skipMarker()); } } @@ -655,6 +655,7 @@ class _PlexVideoControlsState extends State 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 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 _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 _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 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 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 _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 _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 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 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 with WindowListen } /// Handle stacking skip - add to accumulated skip when feedback is active - void _handleStackingSkip({required bool isForward}) { + Future _handleStackingSkip({required bool isForward}) async { if (!widget.canControl) return; // Add to accumulated skip @@ -1207,10 +1208,7 @@ class _PlexVideoControlsState extends State 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 with WindowListen } /// Handle double-tap skip forward or backward - void _handleDoubleTapSkip({required bool isForward}) { + Future _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 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 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 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 with WindowListen onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, onCancelAutoHide: () => _hideTimer?.cancel(), onStartAutoHide: _startHideTimer, + onSeekCompleted: widget.onSeekCompleted, onContentStripVisibilityChanged: (visible) { setState(() => _isContentStripVisible = visible); if (visible) { diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 69df3458..23bddc6a 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -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 { return null; } + Future _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(); @@ -155,13 +168,7 @@ class ContentStripState extends State { }); } - 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 { ? 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, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index f6c50b11..cb27a71b 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -29,6 +29,7 @@ class TrackChapterControls extends StatelessWidget { final List 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? 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());