fix(android): prevent ghost playback after autoplay failures
close #1673
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user