@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T?> invoke<T>(String method, [dynamic args]) async {
|
||||
if (_disposed) return null;
|
||||
|
||||
@@ -236,6 +236,7 @@ class _TimelineSliderState extends State<TimelineSlider> {
|
||||
disableScale: true,
|
||||
focusColor: Colors.transparent,
|
||||
semanticLabel: t.videoControls.timelineSlider,
|
||||
descendantsAreFocusable: false,
|
||||
child: slider,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user