fix(android): prevent ghost playback after autoplay failures

close #1673
This commit is contained in:
edde746
2026-07-25 04:12:54 +02:00
parent b7d5922b0a
commit 0643787fbe
17 changed files with 1799 additions and 292 deletions
+119
View File
@@ -46,6 +46,119 @@ void main() {
);
});
test('ExoPlayer clears stale timeline state before dispatching a new open', () async {
late PlayerAndroid player;
Duration? positionAtNativeOpen;
Duration? durationAtNativeOpen;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
switch (call.method) {
case 'initialize':
return Future.value(true);
case 'open':
positionAtNativeOpen = player.state.position;
durationAtNativeOpen = player.state.duration;
return Future.value(null);
default:
return Future.value(null);
}
},
testBody: () async {
player = PlayerAndroid();
try {
player.handlePropertyChange('time-pos', 188.0);
player.handlePropertyChange('duration', 439.968);
await player.open(Media('https://example.test/next.mkv'));
expect(positionAtNativeOpen, Duration.zero);
expect(durationAtNativeOpen, Duration.zero);
player.handlePropertyChange('duration', 401.0);
expect(player.state.duration, const Duration(seconds: 401));
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer restores the previous timeline when native open is rejected', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
if (call.method == 'initialize') return Future.value(true);
if (call.method == 'open') {
throw PlatformException(code: 'OPEN_FAILED', message: 'rejected');
}
return Future.value(null);
},
testBody: () async {
final player = _TestPlayerAndroid();
try {
_seedTracks(player);
player.seedExternalSubtitleMetadata(const [
SubtitleTrack(
id: 'old-external',
title: 'Old sidecar',
language: 'eng',
codec: 'srt',
isDefault: false,
isForced: true,
isExternal: true,
uri: 'https://example.test/old.srt',
),
]);
player.handlePropertyChange('time-pos', 188.0);
player.handlePropertyChange('duration', 439.968);
await expectLater(
player.open(
Media('https://example.test/rejected.mkv', start: const Duration(seconds: 12)),
timelineDuration: const Duration(seconds: 401),
externalSubtitles: const [
SubtitleTrack(
id: 'rejected-external',
title: 'Rejected sidecar',
language: 'spa',
codec: 'ass',
isDefault: false,
isForced: false,
isExternal: true,
uri: 'https://example.test/rejected.ass',
),
],
),
throwsA(isA<PlatformException>()),
);
expect(player.state.position, const Duration(seconds: 188));
expect(player.state.duration, const Duration(milliseconds: 439968));
expect(player.state.tracks.audio.single.title, 'English');
expect(player.state.tracks.subtitle.single.title, 'English');
player.handlePropertyChange('track-list', const [
{
'type': 'sub',
'id': 'restored-external',
'external': true,
'external-filename': 'https://example.test/old.srt',
'selected': true,
},
]);
expect(player.state.tracks.subtitle.single.title, 'Old sidecar');
expect(player.state.tracks.subtitle.single.isForced, isTrue);
} finally {
await player.dispose();
}
},
);
});
test('ExoPlayer applies audio settings queued before initialization', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
@@ -826,6 +939,12 @@ void main() {
});
}
class _TestPlayerAndroid extends PlayerAndroid {
void seedExternalSubtitleMetadata(List<SubtitleTrack> subtitles) {
setExternalSubtitleMetadata(subtitles);
}
}
void _seedTracks(dynamic player) {
player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true},
+24
View File
@@ -79,6 +79,30 @@ void main() {
}
});
test('PlayerAndroid forwards only explicit playback restart events', () async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const channel = MethodChannel('com.plezy/exo_player');
messenger.setMockMethodCallHandler(channel, (_) async => null);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final player = PlayerAndroid();
var restartCount = 0;
final subscription = player.streams.playbackRestart.listen((_) => restartCount++);
addTearDown(() async {
await subscription.cancel();
await player.dispose();
});
player.handlePropertyChange('paused-for-cache', false);
player.handlePropertyChange('time-pos', 12.0);
await Future<void>.delayed(Duration.zero);
expect(restartCount, 0);
player.handlePlayerEvent('playback-restart', null);
await Future<void>.delayed(Duration.zero);
expect(restartCount, 1);
});
test('mpv registers the core properties (plus its track/device extras)', () async {
final player = PlayerNative();
final observations = await capturedObservations(
@@ -355,14 +355,19 @@ class _StopMarksWatchedClient extends _FakePlexClient {
const Object _defaultServerId = Object();
MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) =>
testMediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.fromString(type),
title: 'Test Item',
serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?,
);
MediaItem _meta({
String ratingKey = '42',
Object? serverId = _defaultServerId,
String? type = 'movie',
int? viewOffsetMs,
}) => testMediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.fromString(type),
title: 'Test Item',
serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?,
viewOffsetMs: viewOffsetMs,
);
void main() {
setUp(resetSharedPreferencesForTest);
@@ -404,6 +409,82 @@ void main() {
});
});
group('sendProgress: playback readiness', () {
test('blocks non-terminal reports until playback output is ready', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
var ready = false;
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
canReportPlayback: () => ready,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, isEmpty);
await tracker.sendProgress('paused');
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, isEmpty);
ready = true;
await tracker.sendProgress('playing');
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls.map((call) => call.state), ['playing']);
});
test('stopped before rendered output terminates at known progress without scrobbling', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 99), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(viewOffsetMs: 12000),
player: player,
isOffline: false,
canReportPlayback: () => false,
hasRenderedPlayback: () => false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped', positionOverride: player.state.duration);
expect(client.updateProgressCalls, hasLength(1));
expect(client.updateProgressCalls.single.state, 'stopped');
expect(client.updateProgressCalls.single.time, 12000);
expect(client.markWatchedCalls, isEmpty);
});
test('fatal stop uses the last reportable position instead of the advancing native clock', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 50), duration: const Duration(seconds: 100));
var canReport = true;
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
canReportPlayback: () => canReport,
hasRenderedPlayback: () => true,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
await Future<void>.delayed(Duration.zero);
canReport = false;
player.position = const Duration(seconds: 95);
await tracker.sendProgress('stopped');
expect(client.updateProgressCalls.map((call) => (call.time, call.state)), [
(50000, 'playing'),
(50000, 'stopped'),
]);
expect(client.markWatchedCalls, isEmpty);
});
});
// ============================================================
// sendProgress: online routing
// ============================================================
@@ -962,6 +1043,34 @@ void main() {
expect(action.duration, 60000);
});
test('does not queue offline playing progress before playback output is ready', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 60));
var ready = false;
final tracker = PlaybackProgressTracker(
client: null,
metadata: _meta(ratingKey: '42', serverId: ServerId('srv')),
player: player,
isOffline: true,
offlineWatchService: svc,
canReportPlayback: () => ready,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
expect(await db.getLatestWatchAction('srv:42'), isNull);
ready = true;
await tracker.sendProgress('playing');
expect(await db.getLatestWatchAction('srv:42'), isNotNull);
});
test('offline + null serverId is a no-op (does NOT throw, does NOT queue)', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {