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 2283b451..95a2d03a 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 @@ -649,6 +649,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { positionUpdateRunnable = null } + private fun resetPlaybackProgress(startPositionMs: Long) { + lastPosition = startPositionMs + lastDuration = 0L + lastBufferedPosition = 0L + delegate?.onPropertyChange("time-pos", startPositionMs / 1000.0) + delegate?.onPropertyChange("duration", 0.0) + delegate?.onPropertyChange("demuxer-cache-time", 0.0) + delegate?.onPropertyChange("eof-reached", false) + } + private fun emitSeekable(seekable: Boolean, force: Boolean = false) { if (!force && lastSeekable == seekable) return lastSeekable = seekable @@ -1380,6 +1390,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { currentMediaUri = uri currentHeaders = headers currentMediaIsLive = isLive + resetPlaybackProgress(startPositionMs) // Apply auth/custom headers to the HTTP DataSource for this session httpDataSourceFactory?.setDefaultRequestProperties( @@ -1530,7 +1541,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } fun seekTo(positionMs: Long) { - exoPlayer?.seekTo(positionMs) + val player = exoPlayer ?: return + val durationMs = player.duration + val clampedPositionMs = if (!currentMediaIsLive && durationMs != C.TIME_UNSET && durationMs > 0L) { + positionMs.coerceIn(0L, durationMs) + } else { + positionMs.coerceAtLeast(0L) + } + player.seekTo(clampedPositionMs) + lastPosition = clampedPositionMs + delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0) } fun setVolume(volume: Float) { diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 8b351ad4..be2118f6 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -98,6 +98,8 @@ class PlayerAndroid extends PlayerBase { }) async { if (disposed) return; await _ensureInitialized(); + final startPosition = media.start ?? Duration.zero; + resetPlaybackProgress(startPosition); setSeekable(false); // Show the video layer @@ -106,7 +108,7 @@ class PlayerAndroid extends PlayerBase { await invoke('open', { 'uri': media.uri, 'headers': media.headers, - 'startPositionMs': media.start?.inMilliseconds ?? 0, + 'startPositionMs': startPosition.inMilliseconds, 'autoPlay': play, 'isLive': isLive, if (externalSubtitles != null && externalSubtitles.isNotEmpty) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 2c9f88e7..80f83e1c 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -484,6 +484,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { seekableController.add(seekable); } + @protected + void resetPlaybackProgress(Duration position) { + _positionMs = position.inMilliseconds; + _state = _state.copyWith( + completed: false, + position: position, + duration: Duration.zero, + buffer: Duration.zero, + bufferRanges: const [], + ); + completedController.add(false); + positionController.add(position); + durationController.add(Duration.zero); + bufferController.add(Duration.zero); + bufferRangesController.add(const []); + } + @protected Future invoke(String method, [dynamic args]) async { if (_disposed) return null; diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index d4fcd0a6..2efc07e7 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -236,6 +236,7 @@ class _TimelineSliderState extends State { disableScale: true, focusColor: Colors.transparent, semanticLabel: t.videoControls.timelineSlider, + descendantsAreFocusable: false, child: slider, ); } diff --git a/test/utils/player_utils_test.dart b/test/utils/player_utils_test.dart index 91edf179..253754ea 100644 --- a/test/utils/player_utils_test.dart +++ b/test/utils/player_utils_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mpv/mpv.dart' show Player, PlayerState; import 'package:plezy/utils/player_utils.dart'; void main() { @@ -12,4 +13,36 @@ void main() { expect(shouldRestartBeforePreviousItem(const Duration(milliseconds: 3001)), isTrue); }); }); + + group('clampSeekPosition', () { + test('clamps negative positions to zero', () { + final player = _FakePlayer(duration: const Duration(minutes: 5)); + + expect(clampSeekPosition(player, const Duration(seconds: -10)), Duration.zero); + }); + + test('clamps positions beyond a known duration', () { + final player = _FakePlayer(duration: const Duration(minutes: 5)); + + expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 5)); + }); + + test('does not upper-clamp when duration is unknown', () { + final player = _FakePlayer(duration: Duration.zero); + + expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 6)); + }); + }); +} + +class _FakePlayer implements Player { + _FakePlayer({required Duration duration}) : _state = PlayerState(duration: duration); + + final PlayerState _state; + + @override + PlayerState get state => _state; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 348b296f..33f9c18a 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_version.dart'; import 'package:plezy/widgets/video_controls/video_controls.dart'; import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart'; +import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart'; void main() { group('effectiveVersionQualityControls', () { @@ -67,4 +69,47 @@ void main() { expect(mobileSkipZoneForTap(position: const Offset(900, 580), size: size), isNull); }); }); + + group('TimelineSlider', () { + testWidgets('routes keyboard input through the custom focus handler', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var keyEvents = 0; + var seekEvents = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: TimelineSlider( + position: const Duration(minutes: 1), + duration: const Duration(minutes: 10), + chapters: const [], + chaptersLoaded: true, + focusNode: focusNode, + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowRight) { + keyEvents++; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + onSeek: (_) => seekEvents++, + onSeekEnd: (_) {}, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(keyEvents, 1); + expect(seekEvents, 0); + }); + }); }