@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user