refactor(player): centralize playback opening

close #1280
This commit is contained in:
edde746
2026-06-10 02:26:26 +02:00
parent 18642cae15
commit cd9498abb8
51 changed files with 3016 additions and 1422 deletions
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_display_criteria.dart';
void main() {
group('MediaDisplayCriteria', () {
test('can prime native display criteria from frame rate and dimensions', () {
const criteria = MediaDisplayCriteria(fps: 23.976, width: 1920, height: 1080);
expect(criteria.canPrimeNativeDisplayCriteria, isTrue);
});
test('cannot prime native display criteria without dimensions', () {
const criteria = MediaDisplayCriteria(fps: 23.976);
expect(criteria.canPrimeNativeDisplayCriteria, isFalse);
});
});
}
+96
View File
@@ -245,6 +245,74 @@ void main() {
);
});
test('MPV open(play: true) unpauses after loadfile even when previously paused', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
// Simulate the in-place reload: the old file is paused before the
// replacement opens. mpv's pause property survives loadfile.
await player.pause();
await player.open(Media('https://example.test/next.mkv'));
final loadIndex = _loadfileCallIndex(calls);
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(loadIndex, greaterThanOrEqualTo(0));
expect(unpauseIndex, greaterThan(loadIndex), reason: 'open(play: true) must clear pause after loadfile');
} finally {
await player.dispose();
}
},
);
});
test('MPV open(play: false) opens paused and never unpauses', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
await player.open(Media('https://example.test/next.mkv'), play: false);
final loadIndex = _loadfileCallIndex(calls);
final pauseIndex = _setPropertyCallIndex(calls, 'pause');
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(pauseIndex, greaterThanOrEqualTo(0));
expect(pauseIndex, lessThan(loadIndex));
expect(_setPropertyValue(calls[pauseIndex]), 'yes');
expect(unpauseIndex, -1, reason: 'a paused open must stay paused');
} finally {
await player.dispose();
}
},
);
});
test('MPV maps server-offset streams to absolute timeline positions', () async {
final calls = <MethodCall>[];
@@ -327,6 +395,28 @@ void main() {
},
);
});
test('MPV forwards preserve display mode flag on dispose', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
await player.dispose(preserveDisplayMode: true);
final disposeCall = calls.singleWhere((call) => call.method == 'dispose');
final args = Map<Object?, Object?>.from(disposeCall.arguments as Map);
expect(args['preserveDisplayMode'], isTrue);
},
);
});
});
}
@@ -380,6 +470,12 @@ int _setPropertyCallIndex(List<MethodCall> calls, String name) {
return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name);
}
int _setPropertyValueIndex(List<MethodCall> calls, String name, String value) {
return calls.indexWhere(
(call) => call.method == 'setProperty' && _setPropertyName(call) == name && _setPropertyValue(call) == value,
);
}
String? _setPropertyName(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['name'] as String?;
String? _setPropertyValue(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['value'] as String?;
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/mpv/player/player_base.dart';
import 'package:plezy/mpv/player/player_native.dart';
/// Guards the channel contract: every property [PlayerBase.handlePropertyChange]
/// depends on for core state must be registered by each backend at init.
/// The Android ExoPlayer plugin replays exactly these registrations into a
/// fallback MPV core, so a missing registration here silently breaks the
/// event stream after a backend switch.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const coreNames = {
'time-pos',
'duration',
'seekable',
'pause',
'paused-for-cache',
'eof-reached',
'volume',
'speed',
'aid',
'sid',
'track-list',
};
Future<List<MethodCall>> capturedObservations({
required String channelName,
required Future<void> Function() initialize,
required Future<void> Function() dispose,
}) async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final methodChannel = MethodChannel(channelName);
final observations = <MethodCall>[];
messenger.setMockMethodCallHandler(methodChannel, (call) async {
if (call.method == 'observeProperty') observations.add(call);
if (call.method == 'initialize') return true;
return null;
});
try {
await initialize();
} finally {
await dispose();
messenger.setMockMethodCallHandler(methodChannel, null);
}
return observations;
}
Set<String> names(List<MethodCall> calls) => calls.map((c) => (c.arguments as Map)['name'] as String).toSet();
test('the shared core table covers every state-critical property', () {
final tableNames = PlayerBase.corePropertyObservations.map((e) => e.$1).toSet()..add('track-list');
expect(tableNames, coreNames);
});
test('ExoPlayer registers the core properties (plus its cache extra)', () async {
final player = PlayerAndroid();
final observations = await capturedObservations(
channelName: 'com.plezy/exo_player',
initialize: () => player.requestAudioFocus(), // forces _ensureInitialized
dispose: () => player.dispose(),
);
final registered = names(observations);
expect(registered, containsAll(coreNames));
expect(registered, contains('demuxer-cache-time'));
for (final call in observations) {
final args = call.arguments as Map;
expect(args['format'], isNotNull);
expect(args['id'], isA<int>());
}
});
test('mpv registers the core properties (plus its track/device extras)', () async {
final player = PlayerNative();
final observations = await capturedObservations(
channelName: 'com.plezy/mpv_player',
initialize: () => player.setLogLevel('warn'), // forces _ensureInitialized
dispose: () => player.dispose(),
);
final registered = names(observations);
expect(registered, containsAll(coreNames));
expect(registered, containsAll({'secondary-sid', 'demuxer-cache-state', 'audio-device-list', 'audio-device'}));
});
}
@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
void main() {
CompletionLatch latch() => CompletionLatch(triggerWindowMs: 1000, rearmWindowMs: 2000);
CompletionLatchSignal tick(
CompletionLatch l,
int positionMs, {
int durationMs = 60000,
bool promptVisible = false,
bool countdownActive = false,
}) {
return l.classifyPosition(
positionMs: positionMs,
durationMs: durationMs,
promptVisible: promptVisible,
countdownActive: countdownActive,
);
}
test('signals completed once inside the trigger window, then stays quiet while latched', () {
final l = latch();
expect(tick(l, 58000), CompletionLatchSignal.none);
expect(tick(l, 59200), CompletionLatchSignal.completed);
// The handler latches on success; until then ticks keep retrying.
expect(tick(l, 59300), CompletionLatchSignal.completed);
l.latch();
expect(tick(l, 59400), CompletionLatchSignal.none);
});
test('does not fire while a prompt is visible', () {
final l = latch();
expect(tick(l, 59500, promptVisible: true), CompletionLatchSignal.none);
});
test('ignores ticks with no known duration', () {
final l = latch();
expect(tick(l, 59500, durationMs: 0), CompletionLatchSignal.none);
});
test('re-arms only after moving back past the rearm window', () {
final l = latch();
l.latch();
// Inside the hysteresis gap (between trigger and rearm windows): no flap.
expect(tick(l, 58500), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
// Clearly out of the end region: re-armed.
expect(tick(l, 50000), CompletionLatchSignal.rearmed);
expect(l.triggered, isFalse);
// Returning to the end can fire again.
expect(tick(l, 59500), CompletionLatchSignal.completed);
});
test('refuses to re-arm while a prompt or countdown is active', () {
final l = latch();
l.latch();
expect(tick(l, 50000, promptVisible: true), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
expect(tick(l, 50000, countdownActive: true), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
expect(tick(l, 50000), CompletionLatchSignal.rearmed);
});
test('reset clears unconditionally', () {
final l = latch();
l.latch();
l.reset();
expect(l.triggered, isFalse);
});
test('rearmIfClear honors prompt/countdown directly', () {
final l = latch();
l.latch();
l.rearmIfClear(promptVisible: true, countdownActive: false);
expect(l.triggered, isTrue);
l.rearmIfClear(promptVisible: false, countdownActive: false);
expect(l.triggered, isFalse);
});
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_context.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_session.dart';
PlaybackContext _context(PlaybackInitializationResult result) {
return PlaybackContext(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
result: result,
sourceKind: result.usesLocalMedia ? PlaybackSourceKind.localFile : PlaybackSourceKind.remoteDirect,
reportingMode: PlaybackReportingMode.online,
streamHeaders: const {'X-Test': 'token'},
);
}
void main() {
group('PlaybackSession.fromContext', () {
test('keeps the requested preset when no fallback occurred', () {
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: const [], videoUrl: 'u')),
requestedQualityPreset: TranscodeQualityPreset.p1080_8mbps,
);
expect(session.qualityPreset, TranscodeQualityPreset.p1080_8mbps);
});
test('falls back to original when the backend rejected the preset', () {
final session = PlaybackSession.fromContext(
_context(
PlaybackInitializationResult(
availableVersions: const [],
videoUrl: 'u',
fallbackReason: TranscodeFallbackReason.values.first,
),
),
requestedQualityPreset: TranscodeQualityPreset.p1080_8mbps,
);
expect(session.qualityPreset, TranscodeQualityPreset.original);
});
test('an original-quality request ignores the fallback reason', () {
final session = PlaybackSession.fromContext(
_context(
PlaybackInitializationResult(
availableVersions: const [],
videoUrl: 'u',
fallbackReason: TranscodeFallbackReason.values.first,
),
),
requestedQualityPreset: TranscodeQualityPreset.original,
);
expect(session.qualityPreset, TranscodeQualityPreset.original);
});
test('refines the media source id from the clamped version index', () {
final versions = [MediaVersion(id: 'v0'), MediaVersion(id: 'v1')];
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: versions, videoUrl: 'u', selectedMediaIndex: 1)),
requestedQualityPreset: TranscodeQualityPreset.original,
requestedMediaSourceId: 'requested',
);
expect(session.mediaSourceId, 'v1');
expect(session.mediaIndex, 1);
});
test('keeps the requested source id when the index is out of range', () {
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: const [], videoUrl: 'u', selectedMediaIndex: 2)),
requestedQualityPreset: TranscodeQualityPreset.original,
requestedMediaSourceId: 'requested',
);
expect(session.mediaSourceId, 'requested');
});
});
test('forwarding getters mirror the resolver output', () {
final result = PlaybackInitializationResult(
availableVersions: [MediaVersion(id: 'v0')],
videoUrl: 'u',
isTranscoding: true,
playSessionId: 'psid',
playMethod: 'Transcode',
activeAudioStreamId: 7,
);
final session = PlaybackSession.fromContext(
_context(result),
requestedQualityPreset: TranscodeQualityPreset.original,
);
expect(session.isTranscoding, isTrue);
expect(session.isOffline, isFalse);
expect(session.playSessionId, 'psid');
expect(session.playMethod, 'Transcode');
expect(session.audioStreamId, 7);
expect(session.availableVersions, hasLength(1));
expect(session.streamHeaders, containsPair('X-Test', 'token'));
expect(session.metadata.id, 'item-1');
});
}
+12 -10
View File
@@ -6,7 +6,7 @@ import 'package:plezy/services/video_filter_manager.dart';
void main() {
test('zoom scale snaps to whole percentages', () {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
expect(manager.setZoomScale(1.234), 1.23);
@@ -18,7 +18,7 @@ void main() {
test('zoom scale snaps near 100 percent to exact default', () {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
manager.setZoomScale(1.5);
@@ -30,7 +30,7 @@ void main() {
test('video zoom property is exact zero at normalized default', () async {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
expect(VideoFilterManager.videoZoomPropertyForScale(1.00008), 0.0);
@@ -48,13 +48,7 @@ void main() {
test('stretch mode applies the initial player size before a resize event', () async {
final player = _RecordingPlayer();
final manager = VideoFilterManager(
player: player,
availableVersions: const [],
selectedMediaIndex: 0,
initialBoxFitMode: 2,
initialPlayerSize: const Size(1920, 1080),
);
final manager = VideoFilterManager(player: player, initialBoxFitMode: 2, initialPlayerSize: const Size(1920, 1080));
addTearDown(manager.dispose);
await manager.updateVideoFilter();
@@ -73,6 +67,14 @@ class _RecordingPlayer implements Player {
writes.add(MapEntry(name, value));
}
@override
// ignore: no-empty-block - native-layer call, irrelevant to property recording
Future<void> setBoxFitMode(int mode) async {}
@override
// ignore: no-empty-block - native-layer call, irrelevant to property recording
Future<void> setVideoZoom(double scale) async {}
@override
PlayerState get state => const PlayerState();
@@ -144,6 +144,54 @@ void main() {
await player.dispose();
await peerService.close();
});
test('media-switch attachment cycle re-announces readiness and re-arms the initial-play gate', () async {
final peerService = _FakeWatchTogetherPeerService(peerId: 'host');
final player = _FakePlayer(playing: false, position: const Duration(minutes: 3));
final manager = _hostManager(peerService);
final deferredStates = <bool>[];
manager.onDeferredPlayChanged = deferredStates.add;
manager.initializeParticipants(['host', 'guest']);
manager.attachPlayer(player);
peerService.emit(SyncMessage.playerReady(peerId: 'guest', ready: true));
await _settle();
await player.emitPlaying(true);
expect(deferredStates, isNot(contains(true)));
await player.emitPlaying(false);
peerService.broadcasts.clear();
// In-place media switch: the reload cycles the attachment exactly like
// the provider does (re-initialize participants, then re-attach).
manager.detachPlayer();
expect(
peerService.broadcasts.where(
(m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == false,
),
isNotEmpty,
);
manager.initializeParticipants(['host', 'guest']);
manager.attachPlayer(player);
// The already-loaded (non-buffering) player re-announces ready for the
// new item on attach.
expect(
peerService.broadcasts.where(
(m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == true,
),
isNotEmpty,
);
// First play after the switch defers again until the guest is ready.
await player.emitPlaying(true);
expect(deferredStates, contains(true));
expect(player.state.playing, isFalse);
manager.dispose();
await player.dispose();
await peerService.close();
});
});
}
@@ -278,7 +326,7 @@ class _FakePlayer implements Player {
bool get disposed => _disposed;
@override
Future<void> dispose() async {
Future<void> dispose({bool preserveDisplayMode = false}) async {
_disposed = true;
await _playingController.close();
await _bufferingController.close();