fix(android): finish the item when ExoPlayer never ends playback

media3 reports STUCK_PLAYING_NOT_ENDING when the player sits in STATE_READY
past the declared duration with no renderer ending. On a tunneled MTK decoder
the clock ran a full minute past the last frame behind a black screen, so the
item never completed: no Play Next, no auto-play, and a "playing" timeline the
server kept extrapolating past the item duration.

Treat that report as the end of the file when the rendered-frame counter has
stopped as well, which separates a finished file from a container that
under-declares its duration and is still painting. The terminal event is shaped
like the STATE_ENDED one and pins the timeline at the duration first, so the
completion flow cannot mistake it for a stream that died mid-file. Shorten the
detection window to media3's stuck-playing default, and clamp a backend
hand-off to just inside the media so a fallback can no longer resume MPV past
the last frame and park there without reporting it.

close #1673
This commit is contained in:
edde746
2026-07-28 23:53:52 +02:00
parent 41ffaa7f2b
commit 726dfc6507
6 changed files with 576 additions and 5 deletions
@@ -0,0 +1,101 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
import 'package:plezy/services/settings_service.dart';
import '../../test_helpers/prefs.dart';
/// Publishes a starting timeline the way `open()` does, without a platform
/// round-trip. The position stream is throttled to 4Hz, so the seed also keeps
/// the test off the wall clock.
class _SeededPlayerAndroid extends PlayerAndroid {
void seedPosition(Duration position) => resetPlaybackProgress(position);
}
/// End-to-end contract for #1673: a player that runs past its duration without
/// ending is reported by the native side as an ordinary end of file, and the
/// Dart completion flow has to read it as the *real* end so Play Next / auto-play
/// runs. A misread routes into dead-stream recovery instead, leaving the black
/// screen and the "playing" timeline the issue is about.
///
/// The event sequence below is exactly what `ExoPlayerCore.emitPlaybackEofOnce`
/// sends, including the timeline pin that precedes the terminal event.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const durationMs = 2623668;
const duration = Duration(milliseconds: durationMs);
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('a synthesized end of file completes the item at its duration', () 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 = _SeededPlayerAndroid();
final completions = <bool>[];
final subscription = player.streams.completed.listen(completions.add);
addTearDown(() async {
await subscription.cancel();
await player.dispose();
});
// Direct play a few seconds from the end: duration comes from the native
// property, as it does when nothing overrides the timeline.
player.seedPosition(const Duration(milliseconds: 2620000));
player.handlePropertyChange('duration', durationMs / 1000.0);
player.handlePropertyChange('pause', false);
expect(player.state.duration, duration);
expect(player.state.playing, isTrue);
// The renderers never ended; the native side pins the timeline at the end
// and reports the file as finished.
player.handlePropertyChange('time-pos', durationMs / 1000.0);
player.handlePropertyChange('paused-for-cache', false);
player.handlePropertyChange('pause', true);
player.handlePropertyChange('eof-reached', true);
player.handlePlayerEvent('end-file', const {'reason': 'eof'});
await Future<void>.delayed(Duration.zero);
// The pin lands on the unthrottled position immediately; the 4Hz state
// snapshot may still carry the previous tick, which is why the classifier
// below has to hold for both.
expect(player.currentPosition, duration);
expect(player.state.playing, isFalse);
expect(player.state.completed, isTrue);
expect(completions.last, isTrue);
// What the screen does with that state: the EOF is genuine, so the item is
// stopped at its duration and the next episode is presented.
expect(
classifyEofSignal(
positionMs: player.state.position.inMilliseconds,
playerDurationMs: player.state.duration.inMilliseconds,
metadataDurationMs: durationMs,
),
EofSignalClass.genuine,
);
expect(
completionNavigationAction(hasNext: true, adjacentStatus: QueueNavigationStatus.found),
CompletionNavigationAction.presentNext,
);
});
test('the same event from a stale mid-file position stays a dead-stream signal', () {
// Why the terminal path publishes the end position and the position loop
// freezes after it: media3 has already stopped the player, and an EOF
// carrying a stale position routes into spurious-EOF recovery instead.
expect(
classifyEofSignal(positionMs: 1200000, playerDurationMs: durationMs, metadataDurationMs: durationMs),
EofSignalClass.spurious,
);
});
}