fix(player): wait for mpv subtitles after load

close #1331
This commit is contained in:
edde746
2026-06-13 23:52:19 +02:00
parent c39b5a5cf1
commit f3cb508649
11 changed files with 235 additions and 56 deletions
@@ -27,6 +27,7 @@ class ExoPlayerPlugin :
private const val TAG = "ExoPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/exo_player"
private const val EVENT_CHANNEL = "com.plezy/exo_player/events"
private const val MPV_FALLBACK_SWITCH_TIMEOUT_MS = 15_000L
}
private lateinit var methodChannel: MethodChannel
@@ -36,6 +37,7 @@ class ExoPlayerPlugin :
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
private var pendingMpvFallbackSwitchGeneration: Int? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
@@ -93,6 +95,7 @@ class ExoPlayerPlugin :
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
pendingMpvProperties.clear()
activity = null
activityBinding = null
@@ -108,6 +111,7 @@ class ExoPlayerPlugin :
override fun onDetachedFromActivityForConfigChanges() {
sessionGeneration++
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
@@ -210,6 +214,7 @@ class ExoPlayerPlugin :
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
}
try {
@@ -246,6 +251,7 @@ class ExoPlayerPlugin :
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
pendingMpvProperties.clear()
Log.d(TAG, "Disposed")
result.success(null)
@@ -747,13 +753,44 @@ class ExoPlayerPlugin :
mainHandler.post { eventSink?.success(listOf(propId, value)) }
}
override fun onEvent(name: String, data: Map<String, Any>?) {
private fun eventPayload(name: String, data: Map<String, Any>? = null): Map<String, Any> {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
mainHandler.post { eventSink?.success(event) }
return event
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val pendingFallbackSwitch =
name == "file-loaded" && pendingMpvFallbackSwitchGeneration == sessionGeneration && usingMpvFallback
if (pendingFallbackSwitch) {
clearPendingMpvFallbackSwitch()
}
val event = eventPayload(name, data)
mainHandler.post {
eventSink?.success(event)
if (pendingFallbackSwitch) {
eventSink?.success(eventPayload("backend-switched"))
}
}
}
private fun armPendingMpvFallbackSwitch(generation: Int) {
pendingMpvFallbackSwitchGeneration = generation
mainHandler.postDelayed({
if (pendingMpvFallbackSwitchGeneration == generation && sessionGeneration == generation && usingMpvFallback) {
Log.w(TAG, "Timed out waiting for MPV fallback file-loaded before backend-switched")
clearPendingMpvFallbackSwitch()
onEvent("backend-switched", null)
}
}, MPV_FALLBACK_SWITCH_TIMEOUT_MS)
}
private fun clearPendingMpvFallbackSwitch() {
pendingMpvFallbackSwitchGeneration = null
}
/**
@@ -806,6 +843,7 @@ class ExoPlayerPlugin :
if (mpvCore !== core) {
core.dispose()
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
return
}
// Configure basic MPV properties for Plex playback
@@ -840,6 +878,7 @@ class ExoPlayerPlugin :
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
armPendingMpvFallbackSwitch(sessionGeneration)
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
@@ -858,11 +897,6 @@ class ExoPlayerPlugin :
// Request audio focus
core.requestAudioFocus()
// Emit backend-switched event on main thread
activity?.runOnUiThread {
onEvent("backend-switched", null)
}
Log.i(TAG, "Successfully switched to MPV fallback")
}
@@ -900,17 +934,20 @@ class ExoPlayerPlugin :
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false // Clear before handoff
clearPendingMpvFallbackSwitch()
val generation = sessionGeneration
Handler(Looper.getMainLooper()).post {
if (generation != sessionGeneration) {
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
return@post
}
val act = activity
if (act == null) {
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
return@post
}
@@ -927,6 +964,7 @@ class ExoPlayerPlugin :
mpvCore = null
}
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
return@initialize
}
if (!success) {
@@ -935,6 +973,7 @@ class ExoPlayerPlugin :
mpvCore = null
}
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
Log.e(TAG, "Failed to initialize MPV fallback")
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage"))
return@initialize
@@ -947,12 +986,14 @@ class ExoPlayerPlugin :
}
} catch (e: Exception) {
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
Log.e(TAG, "Failed to switch to MPV fallback", e)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
}
}
} catch (e: Exception) {
fallbackInProgress = false
clearPendingMpvFallbackSwitch()
Log.e(TAG, "Failed to switch to MPV fallback", e)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
}
+1
View File
@@ -388,6 +388,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'file-loaded':
_state = _state.copyWith(completed: false);
completedController.add(false);
fileLoadedController.add(null);
break;
case 'playback-restart':
@@ -21,6 +21,7 @@ mixin PlayerStreamControllersMixin {
final audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
final bufferRangesController = StreamController<List<BufferRange>>.broadcast();
final playbackRestartController = StreamController<void>.broadcast();
final fileLoadedController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
PlayerStreams createStreams() {
@@ -42,6 +43,7 @@ mixin PlayerStreamControllersMixin {
audioDevices: audioDevicesController.stream,
bufferRanges: bufferRangesController.stream,
playbackRestart: playbackRestartController.stream,
fileLoaded: fileLoadedController.stream,
backendSwitched: backendSwitchedController.stream,
);
}
@@ -64,6 +66,7 @@ mixin PlayerStreamControllersMixin {
await audioDevicesController.close();
await bufferRangesController.close();
await playbackRestartController.close();
await fileLoadedController.close();
await backendSwitchedController.close();
}
}
+4
View File
@@ -53,6 +53,9 @@ class PlayerStreams {
/// Stream that emits when playback restarts (first frame ready after load/seek).
final Stream<void> playbackRestart;
/// Stream that emits when the player has loaded the current media file.
final Stream<void> fileLoaded;
/// Stream of seekable buffer ranges from the demuxer cache.
final Stream<List<BufferRange>> bufferRanges;
@@ -78,6 +81,7 @@ class PlayerStreams {
required this.audioDevices,
required this.bufferRanges,
required this.playbackRestart,
this.fileLoaded = const Stream<void>.empty(),
required this.backendSwitched,
});
}
@@ -341,8 +341,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
);
if (!isCurrentReload()) return true;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final displayCriteria = result.mediaInfo?.displayCriteria;
final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return true;
@@ -384,6 +382,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (!isCurrentReload()) return true;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
);
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
@@ -395,8 +397,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs),
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
onOpened: () {
// The player now owns the new file — publish the session at the
@@ -438,9 +440,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
trackManager.cacheExternalSubtitles(result.externalSubtitles);
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: trackManager,
externalSubtitles: result.externalSubtitles,
externalSubtitlePlan: externalSubtitlePlan,
// Same guard as the start path: don't resume a player a newer flow
// owns, and let a pending startup gate (or Watch Together's group
// start) own the resume instead.
@@ -456,8 +457,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart,
),
@@ -41,6 +41,19 @@ class _FrameRateStartupPlan {
}
}
class _ExternalSubtitleOpenPlan {
const _ExternalSubtitleOpenPlan({required this.externalSubtitles, required this.attachesAtOpen, this.readyAfterOpen});
final List<SubtitleTrack> externalSubtitles;
final bool attachesAtOpen;
final Future<void>? readyAfterOpen;
bool get hasExternalSubtitles => externalSubtitles.isNotEmpty;
bool get requiresPostOpenAdd => !attachesAtOpen && hasExternalSubtitles;
bool get canStartBeforeTrackSetup => attachesAtOpen || !hasExternalSubtitles;
List<SubtitleTrack>? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null;
}
/// Shared building blocks for opening media on the live player.
///
/// The initial start flow ([_startPlayback]), the in-place reload flow
@@ -267,15 +280,14 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
/// the start and reload flows.
Future<void> _resumeAfterFrameRateStartupGate({
required Player currentPlayer,
required bool attachesSubsAtOpen,
required bool hasExternalSubs,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
}) async {
if (!mounted || player != currentPlayer) return;
final trackManager = _trackManager;
if (trackManager == null) return;
appLogger.d('Frame rate matching: resuming playback after $reason');
if (!attachesSubsAtOpen && hasExternalSubs) {
if (externalSubtitlePlan.requiresPostOpenAdd) {
await trackManager.resumeAfterSubtitleLoad();
} else {
await currentPlayer.play();
@@ -289,8 +301,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
/// flows.
Future<void> _resumeAfterStartupGateOrYieldToWatchTogether({
required Player currentPlayer,
required bool attachesSubsAtOpen,
required bool hasExternalSubs,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
required bool wtOwnsStart,
Completer<void>? wtStartupHold,
@@ -298,14 +309,13 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
if (!wtOwnsStart) {
return _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
);
}
appLogger.d('Frame rate matching: yielding post-gate resume to Watch Together ($reason)');
final trackManager = _trackManager;
if (trackManager != null && !attachesSubsAtOpen && hasExternalSubs) {
if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
@@ -331,6 +341,28 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
);
}
_ExternalSubtitleOpenPlan _prepareExternalSubtitleOpenPlan({
required Player player,
required List<SubtitleTrack> externalSubtitles,
bool waitForFileLoaded = true,
}) {
final attachesAtOpen = player.attachesExternalSubtitlesAtOpen;
final hasExternalSubtitles = externalSubtitles.isNotEmpty;
return _ExternalSubtitleOpenPlan(
externalSubtitles: externalSubtitles,
attachesAtOpen: attachesAtOpen,
readyAfterOpen: waitForFileLoaded && !attachesAtOpen && hasExternalSubtitles
? player.streams.fileLoaded.first.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for file-loaded before adding external subtitles');
},
)
: null,
);
}
/// Build the per-item [TrackManager] for a freshly opened source. The
/// start and reload flows construct it identically apart from where the
/// preferred tracks and profile settings come from.
@@ -370,16 +402,18 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
/// paused (e.g. a transcode restart while paused): selection is still
/// armed and the waiting flag cleared instead of leaving both dangling.
Future<void> _applyTracksAfterOpen({
required Player forPlayer,
required TrackManager trackManager,
required List<SubtitleTrack> externalSubtitles,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required bool Function() shouldResumeAfterSubtitleLoad,
bool applySelectionWhenResumeSkipped = false,
}) async {
if (!forPlayer.attachesExternalSubtitlesAtOpen && externalSubtitles.isNotEmpty) {
if (externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = true;
try {
await trackManager.addExternalSubtitles(externalSubtitles);
await trackManager.addExternalSubtitles(
externalSubtitlePlan.externalSubtitles,
waitUntilReady: externalSubtitlePlan.readyAfterOpen,
);
} finally {
if (shouldResumeAfterSubtitleLoad()) {
await trackManager.resumeAfterSubtitleLoad();
@@ -153,8 +153,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final settingsService = await SettingsService.getInstance();
if (!attempt.isCurrent) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
var audioFocusReady = false;
Future<void> ensureAudioFocus() async {
@@ -184,6 +182,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// simultaneous group start.
final wtOwnsStart = _watchTogetherOwnsPlaybackStart();
Completer<void>? wtStartupHold;
late _ExternalSubtitleOpenPlan externalSubtitlePlan;
// Open video through Player
if (result.videoUrl != null) {
@@ -215,8 +214,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
isTranscoding: result.isTranscoding,
);
final shouldAutoPlay = !shouldHoldPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs);
frameRatePlan.armStartupRefreshGate(currentPlayer);
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
);
final shouldAutoPlay =
!shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup;
// ExoPlayer: attach external subs at open time so it discovers
// them in a single prepare() — no media reload needed for selection.
@@ -237,7 +241,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
timing: openTiming,
headers: streamHeaders,
play: shouldAutoPlay,
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => attempt.isCurrent,
);
if (!didOpen || !attempt.isCurrent) return;
@@ -252,6 +256,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
_notifyWatchTogetherMediaChange();
}
} else {
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
waitForFileLoaded: false,
);
}
// Versions/mediaInfo come from the committed session; rebuild so the
@@ -311,9 +321,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: _trackManager!,
externalSubtitles: result.externalSubtitles,
externalSubtitlePlan: externalSubtitlePlan,
// When a startup gate below owns the resume, skip this one to
// avoid a double-play. Watch Together stays paused for the group
// start, so selection is armed through the resume-skipped branch.
@@ -328,8 +337,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold,
+7 -6
View File
@@ -87,9 +87,11 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
requestedMediaSourceId: _requestedMediaSourceId,
);
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
final shouldAutoPlay = wasPlaying && (attachesSubsAtOpen || !hasExternalSubs);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
);
final shouldAutoPlay = wasPlaying && externalSubtitlePlan.canStartBeforeTrackSetup;
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
@@ -106,7 +108,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
),
headers: playbackContext.streamHeaders,
play: shouldAutoPlay,
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => mounted && player == currentPlayer,
onOpened: () {
// A pre-open failure leaves the previous session (and ids)
@@ -142,9 +144,8 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
trackManager.mediaInfo = _currentMediaInfo;
trackManager.cacheExternalSubtitles(result.externalSubtitles);
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: trackManager,
externalSubtitles: result.externalSubtitles,
externalSubtitlePlan: externalSubtitlePlan,
// A restart while paused must stay paused — selection is still
// applied through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () => wasPlaying && mounted && player == currentPlayer,
+32 -15
View File
@@ -62,6 +62,7 @@ class TrackManager {
// ── Internal state ─────────────────────────────────────────────────
bool waitingForExternalSubsTrackSelection = false;
bool _externalSubtitleAddsInFlight = false;
bool _isApplyingTrackSelection = false;
List<SubtitleTrack> _lastExternalSubtitles = const [];
StreamSubscription<Tracks>? _trackLoadingSubscription;
@@ -98,26 +99,40 @@ class TrackManager {
/// adds dominate startup (~170ms × N). Firing them in parallel lets
/// libavformat's network IO overlap and stops Dart → method channel → native
/// round-trips from stacking.
Future<void> addExternalSubtitles(List<SubtitleTrack> externalSubtitles) async {
Future<void> addExternalSubtitles(List<SubtitleTrack> externalSubtitles, {Future<void>? waitUntilReady}) async {
if (externalSubtitles.isEmpty) return;
appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player');
await Future.wait(
externalSubtitles.where((s) => s.uri != null).map((subtitleTrack) async {
_externalSubtitleAddsInFlight = true;
try {
if (waitUntilReady != null) {
try {
await player.addSubtitleTrack(
uri: subtitleTrack.uri!,
title: subtitleTrack.title,
language: subtitleTrack.language,
select: subtitleTrack.isDefault,
);
appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}');
await waitUntilReady;
} catch (e) {
appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e);
appLogger.w('Continuing external subtitle load after readiness wait failed', error: e);
}
}),
);
if (!isActive()) return;
}
appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player');
await Future.wait(
externalSubtitles.where((s) => s.uri != null).map((subtitleTrack) async {
try {
await player.addSubtitleTrack(
uri: subtitleTrack.uri!,
title: subtitleTrack.title,
language: subtitleTrack.language,
select: subtitleTrack.isDefault,
);
appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}');
} catch (e) {
appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e);
}
}),
);
} finally {
_externalSubtitleAddsInFlight = false;
}
}
/// Resume playback after external subtitles have been loaded (or failed).
@@ -230,6 +245,7 @@ class TrackManager {
/// Called when playbackRestart fires — checks the flag and applies selection.
void onPlaybackRestart() {
if (waitingForExternalSubsTrackSelection) {
if (_externalSubtitleAddsInFlight) return;
waitingForExternalSubsTrackSelection = false;
applyTrackSelection();
}
@@ -478,6 +494,7 @@ class TrackManager {
/// Clean up subscriptions.
void dispose() {
_externalSubtitleAddsInFlight = false;
_trackLoadingSubscription?.cancel();
_trackLoadingSubscription = null;
_subtitleFallbackTimer?.cancel();
+19
View File
@@ -313,6 +313,25 @@ void main() {
);
});
test('MPV exposes file-loaded events through PlayerStreams', () async {
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
final fileLoaded = expectLater(player.streams.fileLoaded, emits(isNull));
player.handlePlayerEvent('file-loaded', null);
await fileLoaded;
} finally {
await player.dispose();
}
},
);
});
test('MPV maps server-offset streams to absolute timeline positions', () async {
final calls = <MethodCall>[];
+51
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -47,6 +49,9 @@ class _FakePlayer implements Player {
@override
PlayerState get state => _state;
@override
bool get disposed => false;
set tracks(Tracks t) {
_state = _state.copyWith(tracks: t);
}
@@ -253,6 +258,26 @@ void main() {
// One add failed, one succeeded.
expect(player.addSubtitleCalls, hasLength(1));
});
test('waits for player readiness before adding subtitles', () async {
final player = _FakePlayer();
final mgr = _make(player: player);
addTearDown(mgr.dispose);
final ready = Completer<void>();
final addFuture = mgr.addExternalSubtitles([
SubtitleTrack.uri('https://example/ready.srt', title: 'EN'),
], waitUntilReady: ready.future);
await Future<void>.delayed(Duration.zero);
expect(player.addSubtitleCalls, isEmpty);
ready.complete();
await addFuture;
expect(player.addSubtitleCalls, hasLength(1));
expect(player.addSubtitleCalls.single.uri, 'https://example/ready.srt');
});
});
// ============================================================
@@ -331,6 +356,32 @@ void main() {
// No exception is the contract.
expect(mgr.waitingForExternalSubsTrackSelection, isFalse);
});
test('keeps selection pending while external subtitle add is in flight', () async {
final player = _FakePlayer();
final mgr = _make(player: player);
addTearDown(mgr.dispose);
final ready = Completer<void>();
mgr.waitingForExternalSubsTrackSelection = true;
final addFuture = mgr.addExternalSubtitles([
SubtitleTrack.uri('https://example/pending.srt', title: 'EN'),
], waitUntilReady: ready.future);
await Future<void>.delayed(Duration.zero);
mgr.onPlaybackRestart();
expect(mgr.waitingForExternalSubsTrackSelection, isTrue);
expect(player.addSubtitleCalls, isEmpty);
ready.complete();
await addFuture;
mgr.onPlaybackRestart();
expect(mgr.waitingForExternalSubsTrackSelection, isFalse);
expect(player.addSubtitleCalls, hasLength(1));
});
});
group('onSecondarySubtitleTrackChanged', () {