@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user