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:
@@ -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")
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user