refactor(player): centralize playback opening

close #1280
This commit is contained in:
edde746
2026-06-10 02:26:26 +02:00
parent 18642cae15
commit cd9498abb8
51 changed files with 3016 additions and 1422 deletions
@@ -38,13 +38,26 @@ class ExoPlayerPlugin :
private var fallbackInProgress: Boolean = false
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
// Every Dart observeProperty registration, kept so an ExoPlayer→MPV
// fallback can re-observe exactly what Dart asked for instead of
// maintaining a parallel hard-coded list.
private data class ObservedProperty(val id: Int, val format: String)
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
private val mainHandler = Handler(Looper.getMainLooper())
private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0
private var debugLoggingEnabled: Boolean = false
private val pendingMpvProperties = mutableListOf<Pair<String, String>>()
// mpv properties set while ExoPlayer is active (including before
// initialize — Dart queues its startup properties first), replayed into a
// fallback MPV core. Keyed by property name (last write wins) and cleared
// only at real session boundaries (dispose, engine detach, open while the
// fallback is already active) so one playback's properties never leak into
// the next session's fallback.
private val pendingMpvProperties = LinkedHashMap<String, String>()
// FlutterPlugin
@@ -80,6 +93,7 @@ class ExoPlayerPlugin :
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
pendingMpvProperties.clear()
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
@@ -186,6 +200,9 @@ class ExoPlayerPlugin :
currentActivity.runOnUiThread {
sessionGeneration++
// Do NOT clear pendingMpvProperties here: Dart queues its startup
// properties (sub-ass, subtitle fonts, ...) before initialize, and the
// fallback replay in setupMpvFallback needs them. Dispose/detach clear.
if (mpvCore != null || fallbackInProgress) {
mpvCore?.dispose()
@@ -228,6 +245,7 @@ class ExoPlayerPlugin :
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
pendingMpvProperties.clear()
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
@@ -503,13 +521,14 @@ class ExoPlayerPlugin :
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val id = call.argument<Int>("id")
val format = call.argument<String>("format") ?: "string"
if (name == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'id'", null)
return
}
nameToId[name] = id
observedProperties[name] = ObservedProperty(id, format)
result.success(null)
}
@@ -610,7 +629,7 @@ class ExoPlayerPlugin :
mpvCore?.setProperty(name, value)
} else {
// Store for later application if ExoPlayer falls back to MPV
pendingMpvProperties.add(Pair(name, value))
pendingMpvProperties[name] = value
}
result.success(null)
}
@@ -701,7 +720,7 @@ class ExoPlayerPlugin :
// ExoPlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
val propId = observedProperties[name]?.id ?: return
mainHandler.post { eventSink?.success(listOf(propId, value)) }
}
@@ -736,6 +755,94 @@ class ExoPlayerPlugin :
}
}
/**
* Configure a freshly initialized MPV fallback core: replay the properties
* and observers Dart registered against the ExoPlayer session, then resume
* the media at the handoff position. Runs in MpvPlayerCore.initialize's
* completion callback on the main thread.
*/
private fun setupMpvFallback(
core: MpvPlayerCore,
act: Activity,
uri: String,
headers: Map<String, String>?,
positionMs: Long
) {
// Snapshot Dart-registered state on main thread before clearing
val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
val observedProps = observedProperties.toList()
// Compute content FD on main thread (needs contentResolver)
val mpvUri = openContentFd(uri, act.contentResolver)
?.let { "fdclose://$it" } ?: uri
// Buffer size for closure
val bufferSize = configuredBufferSizeBytes
if (mpvCore !== core) {
core.dispose()
fallbackInProgress = false
return
}
// Configure basic MPV properties for Plex playback
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Apply pending MPV properties from Dart
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
}
// Re-observe exactly what Dart registered via observeProperty, so the
// event stream keeps flowing for every property the Dart side consumes.
for ((propName, observed) in observedProps) {
core.observeProperty(propName, observed.format)
}
// Show the MPV surface (internally posts to UI)
core.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
Thread {
val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
core.setProperty("tone-mapping", "reinhard")
core.setProperty("tone-mapping-param", "0.7")
core.setProperty("tone-mapping-mode", "luma")
}
}.start()
// 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")
}
override fun onFormatUnsupported(
uri: String,
headers: Map<String, String>?,
@@ -813,86 +920,7 @@ class ExoPlayerPlugin :
usingMpvFallback = true
fallbackInProgress = false
// Snapshot pending properties on main thread before clearing
val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
// Compute content FD on main thread (needs contentResolver)
val mpvUri = openContentFd(uri, act.contentResolver)
?.let { "fdclose://$it" } ?: uri
// Buffer size for closure
val bufferSize = configuredBufferSizeBytes
if (mpvCore !== core) {
core.dispose()
fallbackInProgress = false
return@initialize
}
// Configure basic MPV properties for Plex playback
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Apply pending MPV properties from Dart
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
}
// Setup property observers
core.observeProperty("time-pos", "double")
core.observeProperty("duration", "double")
core.observeProperty("seekable", "flag")
core.observeProperty("pause", "flag")
core.observeProperty("paused-for-cache", "flag")
core.observeProperty("demuxer-cache-time", "double")
core.observeProperty("eof-reached", "flag")
core.observeProperty("track-list", "string")
core.observeProperty("aid", "string")
core.observeProperty("sid", "string")
core.observeProperty("volume", "double")
core.observeProperty("speed", "double")
// Show the MPV surface (internally posts to UI)
core.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
Thread {
val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
core.setProperty("tone-mapping", "reinhard")
core.setProperty("tone-mapping-param", "0.7")
core.setProperty("tone-mapping-mode", "luma")
}
}.start()
// 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")
setupMpvFallback(core, act, uri, headers, positionMs)
}
} catch (e: Exception) {
fallbackInProgress = false
+276 -12
View File
@@ -13,10 +13,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private var mainBlankView: UIView?
private var isVisible = false
private var isDisposed = false
private var activeDisplayCriteriaKey: String?
private static var activeDisplayCriteriaKey: String?
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
#if os(tvOS)
private var displayModeSwitchWaiter: DisplayModeSwitchWaiter?
private var displayModeSwitchWaiterGeneration = 0
#endif
var isPipStarting = false
private static func log(_ message: String) {
NSLog("[MpvPlayerCore] %@", message)
}
func initialize(in window: UIWindow) -> Bool {
guard !isInitialized else {
print("[MpvPlayerCore] Already initialized")
@@ -245,6 +254,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
colorMatrix: String?
) -> Bool {
#if os(tvOS)
lastDisplayCriteriaMutation = .skipped
guard let window = containerView?.window ?? self.window else { return false }
let displayManager = window.avDisplayManager
@@ -255,7 +265,9 @@ class MpvPlayerCore: MpvPlayerCoreBase {
let refreshRate = Float(fps > 0 ? fps : 0)
let sourceHasDolbyVision = doviProfile > 0
guard sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil else {
guard
refreshRate > 0 || sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil
else {
clearDisplayCriteria(displayManager, reason: "no display metadata")
return false
}
@@ -315,15 +327,20 @@ class MpvPlayerCore: MpvPlayerCoreBase {
let criteriaKey =
"\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)"
if activeDisplayCriteriaKey == criteriaKey { return true }
if Self.activeDisplayCriteriaKey == criteriaKey && displayManager.preferredDisplayCriteria != nil {
lastDisplayCriteriaMutation = .unchanged
return true
}
displayManager.preferredDisplayCriteria = AVDisplayCriteria(
let displayCriteria = AVDisplayCriteria(
refreshRate: refreshRate,
formatDescription: formatDescription
)
activeDisplayCriteriaKey = criteriaKey
print(
"[MpvPlayerCore] preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
displayManager.preferredDisplayCriteria = displayCriteria
Self.activeDisplayCriteriaKey = criteriaKey
lastDisplayCriteriaMutation = .set
Self.log(
"preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
)
return true
#else
@@ -331,6 +348,49 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
}
func setServerDisplayCriteriaForPlayback(
_ criteria: ServerDisplayCriteria?,
extraDelayMs: Int,
completion: @escaping () -> Void
) {
let apply = { [weak self] in
guard let self else {
completion()
return
}
self.setServerDisplayCriteria(criteria) { [weak self] applied in
guard let self else {
completion()
return
}
#if os(tvOS)
guard applied || self.lastDisplayCriteriaMutation == .cleared else {
completion()
return
}
self.waitForDisplayModeSwitchIfNeeded(extraDelayMs: extraDelayMs, completion: completion)
#else
completion()
#endif
}
}
if Thread.isMainThread {
apply()
} else {
DispatchQueue.main.async(execute: apply)
}
}
private enum DisplayCriteriaMutation {
case skipped
case unchanged
case set
case cleared
}
#if os(tvOS)
private enum DisplayDynamicRange: String {
case sdr = "SDR"
@@ -340,10 +400,197 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) {
if activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
if Self.activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
displayManager.preferredDisplayCriteria = nil
activeDisplayCriteriaKey = nil
print("[MpvPlayerCore] preferredDisplayCriteria cleared (\(reason))")
Self.activeDisplayCriteriaKey = nil
lastDisplayCriteriaMutation = .cleared
Self.log("preferredDisplayCriteria cleared (\(reason))")
} else {
lastDisplayCriteriaMutation = .unchanged
}
}
private func waitForDisplayModeSwitchIfNeeded(extraDelayMs: Int, completion: @escaping () -> Void) {
guard let window = containerView?.window ?? self.window else {
completion()
return
}
let displayManager = window.avDisplayManager
let mutation = lastDisplayCriteriaMutation
let shouldWaitForStart = mutation == .set || mutation == .cleared
if !shouldWaitForStart && !displayManager.isDisplayModeSwitchInProgress {
completion()
return
}
displayModeSwitchWaiter?.cancel(complete: true)
displayModeSwitchWaiterGeneration += 1
let waiterGeneration = displayModeSwitchWaiterGeneration
let waiter = DisplayModeSwitchWaiter(
displayManager: displayManager,
shouldWaitForStart: shouldWaitForStart,
extraDelayMs: extraDelayMs
) { [weak self] in
if let self, self.displayModeSwitchWaiterGeneration == waiterGeneration {
self.displayModeSwitchWaiter = nil
}
completion()
}
displayModeSwitchWaiter = waiter
waiter.start()
}
private final class DisplayModeSwitchWaiter {
private weak var displayManager: AVDisplayManager?
private let shouldWaitForStart: Bool
private let extraDelayMs: Int
private let completion: () -> Void
private var startObserver: NSObjectProtocol?
private var endObserver: NSObjectProtocol?
private var startWatchdog: DispatchWorkItem?
private var endWatchdog: DispatchWorkItem?
private var settleWorkItem: DispatchWorkItem?
private var finished = false
private var completionDelivered = false
private static let startWindowMs = 500
private static let switchWatchdogMs = 8000
private static let settleMs = 200
init(
displayManager: AVDisplayManager,
shouldWaitForStart: Bool,
extraDelayMs: Int,
completion: @escaping () -> Void
) {
self.displayManager = displayManager
self.shouldWaitForStart = shouldWaitForStart
self.extraDelayMs = max(0, min(extraDelayMs, 10_000))
self.completion = completion
}
func start() {
guard let displayManager else {
finish(waited: false, reason: "manager unavailable")
return
}
if displayManager.isDisplayModeSwitchInProgress {
beginWaitingForEnd(reason: "already in progress")
return
}
guard shouldWaitForStart else {
finish(waited: false, reason: "no switch in progress")
return
}
let center = NotificationCenter.default
startObserver = center.addObserver(
forName: .AVDisplayManagerModeSwitchStart,
object: nil,
queue: .main
) { [weak self] _ in
self?.beginWaitingForEnd(reason: "start notification")
}
let watchdog = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.displayManager?.isDisplayModeSwitchInProgress == true {
self.beginWaitingForEnd(reason: "progress poll")
} else {
self.finish(waited: false, reason: "start watchdog")
}
}
startWatchdog = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.startWindowMs), execute: watchdog)
}
func cancel(complete: Bool = false) {
finished = true
cleanup()
if complete { completeOnce() }
}
private func beginWaitingForEnd(reason: String) {
guard !finished else { return }
startWatchdog?.cancel()
startWatchdog = nil
if let startObserver {
NotificationCenter.default.removeObserver(startObserver)
self.startObserver = nil
}
guard displayManager?.isDisplayModeSwitchInProgress == true else {
finish(waited: true, reason: "ended before wait (\(reason))")
return
}
let center = NotificationCenter.default
endObserver = center.addObserver(
forName: .AVDisplayManagerModeSwitchEnd,
object: nil,
queue: .main
) { [weak self] _ in
self?.finish(waited: true, reason: "end notification")
}
let watchdog = DispatchWorkItem { [weak self] in
self?.finish(waited: true, reason: "end watchdog")
}
endWatchdog = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.switchWatchdogMs), execute: watchdog)
}
private func finish(waited: Bool, reason: String) {
guard !finished else { return }
finished = true
cleanup()
let delayMs = waited ? Self.settleMs + extraDelayMs : 0
MpvPlayerCore.log(
"display mode switch wait complete "
+ "(\(reason), waited: \(waited), extraDelayMs: \(extraDelayMs))"
)
guard delayMs > 0 else {
completeOnce()
return
}
let workItem = DispatchWorkItem { [weak self] in
self?.completeOnce()
}
settleWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
private func completeOnce() {
guard !completionDelivered else { return }
completionDelivered = true
settleWorkItem?.cancel()
settleWorkItem = nil
completion()
}
private func cleanup() {
startWatchdog?.cancel()
endWatchdog?.cancel()
settleWorkItem?.cancel()
startWatchdog = nil
endWatchdog = nil
settleWorkItem = nil
if let startObserver {
NotificationCenter.default.removeObserver(startObserver)
self.startObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
}
deinit {
cancel()
}
}
@@ -529,21 +776,37 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
#endif
func dispose() {
func dispose(preserveDisplayCriteria: Bool = false) {
// Guard double-dispose: the plugin calls dispose() then drops the
// strong ref, which fires deinit dispose() again. The second call
// would re-enter and crash on weak-ref formation during dealloc.
guard !isDisposed else { return }
isDisposed = true
#if os(tvOS)
if preserveDisplayCriteria {
Self.log("dispose preserving display criteria (key: \(Self.activeDisplayCriteriaKey ?? "nil"))")
}
#endif
// Reset the HDMI mode hint synchronously while self is still alive
// and on main. An async-to-main dispatch here would be drained after
// dealloc (the plugin sets playerCore = nil right after this call
// returns), leaving the link stuck at the last clip's refresh rate.
// During video-to-video replacement, keep the hint so tvOS doesn't
// renegotiate back to default before the replacement route can set its
// next criteria.
if !preserveDisplayCriteria {
updateDisplayCriteria(
doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil,
fps: 0, width: 0, height: 0, sigPeak: 0,
gamma: nil, primaries: nil, colorMatrix: nil)
}
#if os(tvOS)
displayModeSwitchWaiter?.cancel(complete: true)
displayModeSwitchWaiter = nil
#endif
NotificationCenter.default.removeObserver(self)
#if os(iOS)
@@ -558,7 +821,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
mainBlankView?.removeFromSuperview()
mainBlankView = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
Self.log("Disposed")
}
deinit {
+14 -29
View File
@@ -72,7 +72,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
handleDispose(call: call, result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
@@ -326,45 +326,27 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
private func handleDispose(result: @escaping FlutterResult) {
private func handleDispose(call: FlutterMethodCall, result: @escaping FlutterResult) {
let args = call.arguments as? [String: Any]
let preserveDisplayMode = args?["preserveDisplayMode"] as? Bool ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
NSLog("[MpvPlayerPlugin] dispose preserveDisplayMode=%@", preserveDisplayMode.description)
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
self.pendingInlineRestoreAfterPip = false
self.unregisterSceneActivationObserver()
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore?.dispose(preserveDisplayCriteria: preserveDisplayMode)
self.playerCore = nil
result(nil)
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String
else {
result(
FlutterError(
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
details: nil))
return
}
guard let core = playerCore else {
result(nil)
return
}
core.setPropertyAsync(name, value: value) { [weak self] _ in
if name == "pause" {
self?.pipController?.invalidatePlaybackState()
if core.isPipActive == true { self?.syncPipTimebase() }
}
result(nil)
}
func didSetPauseProperty(value: String) {
pipController?.invalidatePlaybackState()
if playerCore?.isPipActive == true { syncPipTimebase() }
}
private func handleSetDisplayCriteria(call: FlutterMethodCall, result: @escaping FlutterResult) {
@@ -378,11 +360,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
return
}
let extraDelayMs = int64Value(args["extraDelayMs"]).map { Int(clamping: $0) } ?? 0
guard let raw = args["criteria"] as? [String: Any] else {
DispatchQueue.main.async {
core.setServerDisplayCriteria(nil)
core.setServerDisplayCriteriaForPlayback(nil, extraDelayMs: extraDelayMs) {
result(nil)
}
}
return
}
@@ -398,10 +382,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
colorMatrix: stringValue(raw["matrix"])
)
DispatchQueue.main.async {
core.setServerDisplayCriteria(criteria)
core.setServerDisplayCriteriaForPlayback(criteria, extraDelayMs: extraDelayMs) {
result(nil)
}
}
}
private func int64Value(_ value: Any?) -> Int64? {
switch value {
+1 -1
View File
@@ -56,7 +56,7 @@ class MediaDisplayCriteria {
bool get hasDisplayMetadata =>
(doviProfile ?? 0) > 0 || _hasValue(transfer) || _hasValue(primaries) || _hasValue(matrix);
bool get canPrimeNativeDisplayCriteria => hasDimensions && hasDisplayMetadata;
bool get canPrimeNativeDisplayCriteria => hasDimensions && (hasDisplayMetadata || hasFrameRate);
bool get isHdr {
if ((doviProfile ?? 0) > 0 && doviCompatibilityId != 2) return true;
+32 -12
View File
@@ -13,6 +13,11 @@ class PlayerAndroid extends PlayerBase {
bool _tunnelingEnabled = true;
String _dvConversionMode = 'auto';
/// The native plugin switched from ExoPlayer to its mpv fallback for this
/// session. Sticky for the instance lifetime, mirroring the native flag
/// (which resets only on initialize/dispose).
bool _usingMpvFallback = false;
String? _hiddenSubtitleTrackId;
@override
@@ -30,12 +35,32 @@ class PlayerAndroid extends PlayerBase {
@override
bool get supportsSecondarySubtitles => false;
// Under the mpv fallback the native open path drops the externalSubtitles
// argument, so subsequent opens must use the post-open sub-add dance
// (handleAddSubtitleTrack routes to mpv natively).
@override
bool get attachesExternalSubtitlesAtOpen => !_usingMpvFallback;
// The fallback runs mpv over MediaCodec — the same display-switch decoder
// constraint as PlayerNative on Android. The whole startup-gate chain
// (setVideoFrameRate, playback-restart, seek/drop-buffers refresh,
// open-paused) already routes per-core natively.
@override
bool get needsDecoderRefreshAfterDisplaySwitch => _usingMpvFallback;
@override
bool get detectsFpsAfterRender => true;
@override
bool get providesNativeStats => true;
@override
void handlePlayerEvent(String name, Map? data) {
if (name == 'backend-switched') {
// Native player switched from ExoPlayer to MPV due to unsupported format.
// Clear stale ExoPlayer tracks so applyTrackSelectionWhenReady waits for
// mpv's track-list instead of immediately applying with ExoPlayer IDs.
_usingMpvFallback = true;
clearTracks();
backendSwitchedController.add(null);
return;
@@ -70,17 +95,7 @@ class PlayerAndroid extends PlayerBase {
// Register property observers before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
// future would falsely treat as ready.
await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', 'string');
await observeProperty('eof-reached', 'flag');
await observeProperty('volume', 'double');
await observeProperty('speed', 'double');
await observeProperty('aid', 'string');
await observeProperty('sid', 'string');
await observeCoreProperties(trackListFormat: 'string');
await observeProperty('demuxer-cache-time', 'double');
initialized = true;
@@ -283,6 +298,7 @@ class PlayerAndroid extends PlayerBase {
}
}
@override
Future<Map<String, dynamic>> getStats() async {
if (disposed) return {};
try {
@@ -303,7 +319,8 @@ class PlayerAndroid extends PlayerBase {
}
}
Future<String> getPlayerType() async {
@override
Future<String> runtimePlayerType() async {
if (disposed) return 'unknown';
try {
final result = await invoke<String>('getPlayerType');
@@ -352,6 +369,7 @@ class PlayerAndroid extends PlayerBase {
///
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
/// For ASS subtitles, applies font scale via libass setFontScale().
@override
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
@@ -379,12 +397,14 @@ class PlayerAndroid extends PlayerBase {
/// Apply the box-fit mode to the native ExoPlayer layer.
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
@override
Future<void> setBoxFitMode(int mode) async {
if (disposed || !initialized) return;
await invoke('setBoxFitMode', {'mode': mode});
}
/// Apply custom zoom to the native ExoPlayer layer.
@override
Future<void> setVideoZoom(double scale) async {
if (disposed || !initialized) return;
await invoke('setVideoZoom', {'scale': scale});
+70 -2
View File
@@ -99,6 +99,28 @@ abstract class Player {
/// Whether this player backend supports secondary subtitle tracks.
bool get supportsSecondarySubtitles;
/// Whether this backend ingests external subtitles in [open] (single
/// prepare(), safe to auto-play immediately). Backends returning false
/// need external subtitles added after open via [addSubtitleTrack] while
/// paused, and the caller resumes once the tracks are selected.
bool get attachesExternalSubtitlesAtOpen;
/// Whether the backend detects container fps from rendered frame
/// timestamps, so `container-fps` only becomes available a few frames
/// after playback starts (retry the property read instead of giving up).
bool get detectsFpsAfterRender;
/// Whether the video decoder must be refreshed (seek-in-place or
/// drop-buffers) after a display mode switch. True for mpv on Android,
/// where MediaCodec can stall against the reconfigured surface.
bool get needsDecoderRefreshAfterDisplaySwitch;
/// Whether [getStats] aggregates performance stats natively for the
/// active backend (the Android plugin covers both ExoPlayer and its mpv
/// fallback). Backends returning false are sampled via mpv property
/// reads instead.
bool get providesNativeStats;
/// Add an external subtitle track.
///
/// [uri] - URL or path to the subtitle file.
@@ -156,7 +178,10 @@ abstract class Player {
/// Prime native display matching from server metadata before the decoder
/// emits stream properties. Unsupported platforms ignore this.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria);
///
/// [extraDelayMs] is added after a native display-switch completion event,
/// for TVs or AVRs that need extra HDMI settle time.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0});
/// Configure subtitle fonts for libass rendering.
///
@@ -215,6 +240,46 @@ abstract class Player {
/// On other platforms, this is a no-op.
Future<void> clearVideoFrameRate();
/// Apply subtitle styling to the native rendering layer.
///
/// ExoPlayer renders subtitles natively (CaptionStyleCompat for text subs,
/// libass font scale for ASS), so styling must be pushed after [open].
/// No-op on mpv backends, which style subtitles via `sub-*` properties.
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
});
/// Apply the box-fit mode to the native video layer
/// (0=FIT, 1=ZOOM/cover, 2=FILL/stretch).
///
/// ExoPlayer scales via AspectRatioFrameLayout; mpv backends are a no-op
/// here and scale via `panscan`/`video-aspect-override` properties instead.
Future<void> setBoxFitMode(int mode);
/// Apply custom zoom to the native video layer. No-op on mpv backends,
/// which zoom via the `video-zoom` property.
Future<void> setVideoZoom(double scale);
/// Aggregated native playback stats (codecs, dimensions, dropped frames…).
///
/// Returns an empty map on backends without native stats aggregation;
/// query mpv properties directly there instead.
Future<Map<String, dynamic>> getStats();
/// The backend actually playing right now, resolved from the native side.
///
/// Unlike [playerType] (the configured backend), this reflects runtime
/// fallbacks — e.g. 'mpv' after ExoPlayer hit an unsupported format.
Future<String> runtimePlayerType();
/// Request audio focus before starting playback.
///
/// On Android, this notifies the system that the app wants to play audio,
@@ -237,8 +302,11 @@ abstract class Player {
/// Dispose of the player and release resources.
///
/// [preserveDisplayMode] keeps any native display-mode hint active while a
/// replacement video route is being opened. Use false when leaving playback.
///
/// After calling this, the player instance should not be used.
Future<void> dispose();
Future<void> dispose({bool preserveDisplayMode = false});
/// Creates a new player instance.
///
+74 -3
View File
@@ -98,6 +98,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
/// The (name, format) registrations every backend makes at init — the
/// properties [handlePropertyChange] needs for core [PlayerState].
/// `track-list` is registered separately because mpv uses node format on
/// Apple platforms; backend-specific extras (mpv: secondary-sid /
/// demuxer-cache-state / audio-device*; ExoPlayer: demuxer-cache-time)
/// are appended by the subclasses.
static const List<(String, String)> corePropertyObservations = [
('time-pos', 'double'),
('duration', 'double'),
('seekable', 'flag'),
('pause', 'flag'),
('paused-for-cache', 'flag'),
('eof-reached', 'flag'),
('volume', 'double'),
('speed', 'double'),
('aid', 'string'),
('sid', 'string'),
];
/// Register [corePropertyObservations] plus `track-list` in the
/// backend's preferred format. Called from each subclass's initialize.
@protected
Future<void> observeCoreProperties({required String trackListFormat}) async {
for (final (name, format) in corePropertyObservations) {
await observeProperty(name, format);
}
await observeProperty('track-list', trackListFormat);
}
@protected
Future<void> observeProperty(String name, String format) async {
final propId = _nextPropId++;
@@ -543,7 +572,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {}
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
@@ -568,6 +597,34 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> clearVideoFrameRate() async {}
@override
// ignore: no-empty-block - base no-op, ExoPlayer styles subtitles natively
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
}) async {}
@override
// ignore: no-empty-block - base no-op, mpv scales via panscan/aspect-override
Future<void> setBoxFitMode(int mode) async {}
@override
// ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property
Future<void> setVideoZoom(double scale) async {}
@override
Future<Map<String, dynamic>> getStats() async => const {};
@override
Future<String> runtimePlayerType() async => playerType;
@override
Future<bool> requestAudioFocus() async {
// Default returns true, overridden by Android
@@ -585,6 +642,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@override
bool get supportsSecondarySubtitles => true;
@override
bool get attachesExternalSubtitlesAtOpen => false;
@override
bool get detectsFpsAfterRender => false;
@override
bool get needsDecoderRefreshAfterDisplaySwitch => false;
@override
bool get providesNativeStats => false;
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@@ -669,13 +738,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
@override
Future<void> dispose() async {
Future<void> dispose({bool preserveDisplayMode = false}) async {
if (_disposed) return;
_disposed = true;
await _eventSubscription?.cancel();
await _logSubscription?.cancel();
await methodChannel.invokeMethod('dispose'); // Direct call — already guarded by _disposed check above
await methodChannel.invokeMethod('dispose', {
'preserveDisplayMode': preserveDisplayMode,
}); // Direct call — already guarded by _disposed check above
await closeStreamControllers();
}
}
+17 -13
View File
@@ -112,17 +112,7 @@ class PlayerNative extends PlayerBase {
// Subscribe to MPV properties before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
// future would falsely treat as ready.
await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', _nodeFormat);
await observeProperty('eof-reached', 'flag');
await observeProperty('volume', 'double');
await observeProperty('speed', 'double');
await observeProperty('aid', 'string');
await observeProperty('sid', 'string');
await observeCoreProperties(trackListFormat: _nodeFormat);
await observeProperty('secondary-sid', 'string');
await observeProperty('demuxer-cache-state', _nodeFormat);
await observeProperty('audio-device-list', _nodeFormat);
@@ -195,6 +185,14 @@ class PlayerNative extends PlayerBase {
}
await command(['loadfile', uri, 'replace']);
// mpv's pause property survives loadfile; in-place reloads pause the old
// file before resolving, so explicitly unpause for the replacement. Set
// after loadfile so the paused old file never audibly unpauses
// pre-replace.
if (play) {
await setProperty('pause', 'no');
}
}
@override
@@ -295,10 +293,16 @@ class PlayerNative extends PlayerBase {
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {
bool get needsDecoderRefreshAfterDisplaySwitch => Platform.isAndroid;
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (disposed || !Platform.isIOS) return;
await _ensureInitialized();
await invoke('setDisplayCriteria', {'criteria': _effectiveDisplayCriteria(criteria)?.toJson()});
await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
'extraDelayMs': extraDelayMs,
});
}
@override
-12
View File
@@ -14,9 +14,6 @@ class PlayerState {
final double rate;
final Tracks tracks;
final TrackSelection track;
final double audioDelay;
final double subtitleDelay;
final bool audioPassthrough;
final AudioDevice audioDevice;
final List<AudioDevice> audioDevices;
final List<BufferRange> bufferRanges;
@@ -33,9 +30,6 @@ class PlayerState {
this.rate = 1.0,
this.tracks = const Tracks(),
this.track = const TrackSelection(),
this.audioDelay = 0.0,
this.subtitleDelay = 0.0,
this.audioPassthrough = false,
this.audioDevice = AudioDevice.auto,
this.audioDevices = const [],
this.bufferRanges = const [],
@@ -53,9 +47,6 @@ class PlayerState {
double? rate,
Tracks? tracks,
TrackSelection? track,
double? audioDelay,
double? subtitleDelay,
bool? audioPassthrough,
AudioDevice? audioDevice,
List<AudioDevice>? audioDevices,
List<BufferRange>? bufferRanges,
@@ -72,9 +63,6 @@ class PlayerState {
rate: rate ?? this.rate,
tracks: tracks ?? this.tracks,
track: track ?? this.track,
audioDelay: audioDelay ?? this.audioDelay,
subtitleDelay: subtitleDelay ?? this.subtitleDelay,
audioPassthrough: audioPassthrough ?? this.audioPassthrough,
audioDevice: audioDevice ?? this.audioDevice,
audioDevices: audioDevices ?? this.audioDevices,
bufferRanges: bufferRanges ?? this.bufferRanges,
@@ -284,6 +284,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
builder: (context) {
final svc = SettingsService.instance;
final shouldShow =
PlatformDetector.isAppleTV() ||
(Platform.isWindows &&
(svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) ||
(Platform.isAndroid && svc.read(SettingsService.matchContentFrameRate));
@@ -0,0 +1,77 @@
/// What a position tick means for the end-of-video prompt flow.
enum CompletionLatchSignal {
/// Nothing to do.
none,
/// Playback just entered the end-of-video window and the latch is clear —
/// the caller should run its completion handling (which latches on
/// success via [CompletionLatch.latch]).
completed,
/// Playback moved back out of the end region and the latch re-armed.
rearmed,
}
/// End-of-video latch with rearm hysteresis for the Play Next / completion
/// prompts.
///
/// The prompt fires when playback enters the last [triggerWindowMs] of the
/// item and must not re-fire on every subsequent position tick — the latch
/// stays set while playback is parked inside the end region. It re-arms only
/// once playback moves back out past [rearmWindowMs] (a larger window, so a
/// position oscillating at the boundary can't flap), and never while a
/// prompt is visible or an auto-play countdown owns the screen.
///
/// Latching is the *caller's* move ([latch]), not [classifyPosition]'s: the
/// completion handler has its own bail-outs (live TV, in-flight media swap)
/// and a tick that bails must stay un-latched so the next tick retries.
class CompletionLatch {
CompletionLatch({required this.triggerWindowMs, required this.rearmWindowMs})
: assert(rearmWindowMs > triggerWindowMs, 'rearm window must exceed trigger window for hysteresis');
/// Fire when within this many ms of the end.
final int triggerWindowMs;
/// Re-arm only after moving back out past this many ms from the end.
final int rearmWindowMs;
bool _triggered = false;
/// Whether the end-of-video handling already ran for this approach to
/// the end region.
bool get triggered => _triggered;
/// Mark the completion handling as done for this approach to the end.
void latch() => _triggered = true;
/// Clear unconditionally — new media was loaded.
void reset() => _triggered = false;
/// Re-arm so the prompt can fire again — but only when no prompt is
/// visible and no auto-play countdown is running, so an active dialog is
/// never clobbered. Callers decide *when* re-arming is safe (media
/// reloaded, or playback moved back out of the end region).
void rearmIfClear({required bool promptVisible, required bool countdownActive}) {
if (_triggered && !promptVisible && !countdownActive) _triggered = false;
}
/// Classify a position tick against the trigger/rearm windows.
CompletionLatchSignal classifyPosition({
required int positionMs,
required int durationMs,
required bool promptVisible,
required bool countdownActive,
}) {
if (durationMs <= 0) return CompletionLatchSignal.none;
if (positionMs >= durationMs - triggerWindowMs) {
if (!promptVisible && !_triggered) return CompletionLatchSignal.completed;
return CompletionLatchSignal.none;
}
if (positionMs < durationMs - rearmWindowMs) {
final wasLatched = _triggered;
rearmIfClear(promptVisible: promptVisible, countdownActive: countdownActive);
if (wasLatched && !_triggered) return CompletionLatchSignal.rearmed;
}
return CompletionLatchSignal.none;
}
}
@@ -0,0 +1,41 @@
/// Per-screen state for Android display frame-rate matching: the retry
/// counter for backends that detect fps only after rendering, whether a
/// switch was already applied for the current item, and the MediaSession
/// pause-suppression window armed around HDMI renegotiations.
///
/// One instance lives on the player screen; the open/reload pipelines call
/// [resetForNewItem] before each open and the display-matching paths flip
/// [applied]/[retries] as they negotiate.
class FrameRateMatcher {
/// Retries left for late fps detection (ExoPlayer reports container fps
/// only after ~8 rendered frames).
int retries = 0;
/// Whether a display switch was already applied for the current item —
/// the post-first-frame path bails instead of switching twice.
bool applied = false;
bool _suppressMediaPause = false;
/// Whether a MediaSession PauseEvent should be ignored right now because
/// the display is (or may still be) renegotiating HDMI. Fire Stick (and
/// similar Android TV devices) send onPause() through the MediaSession
/// callback when the display mode changes for frame rate matching.
bool get suppressesMediaPause => _suppressMediaPause;
/// Arm the pause-suppression window around an HDMI renegotiation. The
/// window outlasts the switch by a safety margin on top of the user's
/// configured extra delay.
void beginSuppressWindow(int delaySec) {
_suppressMediaPause = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPause = false;
});
}
/// Reset the per-item negotiation state before opening new media.
void resetForNewItem() {
retries = 0;
applied = false;
}
}
@@ -0,0 +1,41 @@
import '../../media/media_server_client.dart';
import '../../models/livetv_channel.dart';
/// Launch parameters for a live TV session. A [VideoPlayerScreen] plays live
/// TV iff it was constructed with one of these — the type encodes the
/// "these fields travel together" invariant the nine separate nullable
/// constructor parameters used to leave implicit.
class LiveTvSessionArgs {
final String? channelName;
/// Pre-resolved stream URL (Jellyfin always provides one; Plex tunes
/// in-player when null).
final String? streamUrl;
final List<LiveTvChannel>? channels;
final int? currentChannelIndex;
final String? dvrKey;
/// Backend-neutral client typing. The four in-player live ops branch on
/// `client is PlexClient` / `client is JellyfinClient` at their use sites:
/// Plex tunes a transcode session and gets capture-buffer updates;
/// Jellyfin uses its `/Sessions/Playing*` endpoints for progress reporting
/// and re-opens [streamUrl] for retry. Tune (Plex-only by protocol)
/// and seek (Plex-only — Jellyfin live channels aren't seekable) gate
/// explicitly on `client is PlexClient`.
final MediaServerClient? client;
final String? sessionIdentifier;
final String? sessionPath;
const LiveTvSessionArgs({
this.channelName,
this.streamUrl,
this.channels,
this.currentChannelIndex,
this.dvrKey,
this.client,
this.sessionIdentifier,
this.sessionPath,
});
}
@@ -0,0 +1,76 @@
import 'dart:async';
import '../../media/media_server_client.dart';
import '../../models/livetv_capture_buffer.dart';
import '../../services/jellyfin_client.dart';
import '../../services/live_session_tracker.dart';
import 'live_tv_session_args.dart';
/// Mutable state for one live TV playback session: tune/session identity,
/// the timeline heartbeat machinery, the capture buffer used for
/// time-shifting, and the retry/fallback ladder.
///
/// One instance lives on the player screen (inert when the screen plays
/// VOD); the live-TV part file owns all the logic and reads/writes through
/// this object so the session state has a single boundary and lifetime.
class LiveTvSessionState {
LiveTvSessionState(LiveTvSessionArgs? args, {required this.itemId})
: channelIndex = args?.currentChannelIndex ?? -1,
channelName = args?.channelName,
client = args?.client,
dvrKey = args?.dvrKey,
streamUrl = args?.streamUrl,
sessionIdentifier = args?.sessionIdentifier,
sessionPath = args?.sessionPath,
jellyfin = args?.client is JellyfinClient && args?.sessionIdentifier != null
? JellyfinLiveSessionTracker(playSessionId: args?.sessionIdentifier)
: JellyfinLiveSessionTracker();
int channelIndex;
String? channelName;
MediaServerClient? client;
String? dvrKey;
String? streamUrl;
/// The channel/program item progress reports are attributed to; updated
/// on channel switches.
String itemId;
String? sessionIdentifier;
String? sessionPath;
Timer? timelineTimer;
int timelineGeneration = 0;
DateTime? playbackStartTime;
String? programId;
int? durationMs;
/// Jellyfin live TV heartbeat state machine. The Plex live branch keeps
/// its bespoke capture-buffer flow inline; this tracker only collapses
/// the Jellyfin started/progress/stopped transition.
JellyfinLiveSessionTracker jellyfin;
CaptureBuffer? captureBuffer;
int? programBeginsAt;
double streamStartEpoch = 0;
bool atLiveEdge = true;
String? transcodeSessionId;
/// Fallback level for live TV stream errors (mirrors Plex web client
/// behavior). 0 = directStream+directStreamAudio, 1 = no directStream,
/// 2 = no DS + no DS audio.
int fallbackLevel = 0;
bool retrying = false;
/// Whether the timeline heartbeat should restart when the app resumes
/// from the background (it is suspended on hide).
bool resumeTimelineOnResume = false;
/// The stream just (re)started at the live edge — align the epoch
/// bookkeeping every restart flow shares (retry, channel zap).
void markStreamRestartedAtLiveEdge() {
final now = DateTime.now();
playbackStartTime = now;
streamStartEpoch = now.millisecondsSinceEpoch / 1000.0;
atLiveEdge = true;
}
}
+10 -10
View File
@@ -47,7 +47,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
});
}
int? _selectedSourceSubtitleStreamId(List<MediaSubtitleTrack> tracks) {
int? _selectedSourceSubtitleStreamIdForControls(List<MediaSubtitleTrack> tracks) {
if (tracks.isEmpty) return null;
for (final track in tracks) {
if (track.selected) return track.id;
@@ -248,7 +248,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onPrevious: onPrevious,
availableVersions: _availableVersions,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedQualityPreset: _selectedQualityPreset,
serverSupportsTranscoding: _serverSupportsTranscoding,
isTranscoding: _isTranscoding,
@@ -256,8 +255,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: _selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks),
selectedSubtitleStreamId: _selectedSourceSubtitleStreamIdForControls(sourceSubtitleTracks),
sourcePartId: _currentMediaInfo?.partId,
onPlaybackSourceChanged: _switchPlaybackSource,
onTogglePIPMode: _togglePIPMode,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0,
@@ -285,14 +285,14 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onShaderChanged: () => _setPlayerState(() {}),
thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null,
isLive: widget.isLive,
liveChannelName: _liveChannelName,
captureBuffer: _captureBuffer,
isAtLiveEdge: _isAtLiveEdge,
streamStartEpoch: _streamStartEpoch,
liveChannelName: _live.channelName,
captureBuffer: _live.captureBuffer,
isAtLiveEdge: _live.atLiveEdge,
streamStartEpoch: _live.streamStartEpoch,
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
onLiveSeek: _captureBuffer != null ? _seekLiveToEpoch : null,
onLiveSeekBy: _captureBuffer != null ? _liveSeek.seekBy : null,
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
onLiveSeek: _live.captureBuffer != null ? _seekLiveToEpoch : null,
onLiveSeekBy: _live.captureBuffer != null ? _liveSeek.seekBy : null,
onJumpToLive: _live.captureBuffer != null && !_live.atLiveEdge ? _jumpToLiveEdge : null,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
? _toggleAmbientLighting
@@ -16,7 +16,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (player == null) return;
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _captureBuffer != null) {
if (widget.isLive && _live.captureBuffer != null) {
_liveSeek.seekBy(seekSeconds);
return;
}
@@ -27,7 +27,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (player == null) return;
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _captureBuffer != null) {
if (widget.isLive && _live.captureBuffer != null) {
_liveSeek.seekBy(-seekSeconds);
return;
}
@@ -3,7 +3,7 @@ part of '../../video_player_screen.dart';
extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
Future<void> _applyFrameRateMatching() async {
if (player == null || !Platform.isAndroid) return;
if (_frameRateMatchingApplied) return;
if (_frameRate.applied) return;
try {
final fpsStr = await player!.getProperty('container-fps');
@@ -11,8 +11,8 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
if (fps == null || fps <= 0) {
// ExoPlayer detects FPS from frame timestamps after ~8 rendered frames.
// STATE_READY fires before frames render, so retry until detection completes.
if (player is PlayerAndroid && _frameRateRetries < 10) {
_frameRateRetries++;
if (player!.detectsFpsAfterRender && _frameRate.retries < 10) {
_frameRate.retries++;
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted && player != null) _applyFrameRateMatching();
});
@@ -22,19 +22,10 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
return;
}
_frameRateRetries = 0;
_frameRateMatchingApplied = true;
_frameRate.retries = 0;
_frameRate.applied = true;
final durationMs = player!.state.duration.inMilliseconds;
final settingsService = await SettingsService.getInstance();
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
// Suppress spurious PauseEvent from MediaSession during HDMI renegotiation.
// Fire Stick (and similar Android TV devices) send onPause() through the
// MediaSession callback when the display mode changes for frame rate matching.
_suppressMediaPauseDuringFrameRateSwitch = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPauseDuringFrameRateSwitch = false;
});
// Pause so the playback clock doesn't advance while the TV renegotiates
// HDMI. The native setVideoFrameRate call below awaits the real display
@@ -46,7 +37,12 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
appLogger.w('Failed to pause before frame rate switch', error: e);
}
final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000);
final didSwitch = await _switchDisplayFrameRateForOpen(
player: player!,
settingsService: settingsService,
fps: fps,
durationMs: durationMs,
);
if (didSwitch) {
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-first-frame display switch');
}
@@ -57,10 +53,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s',
category: 'player',
),
Breadcrumb(message: 'Frame rate matching: ${fps}fps, switched=$didSwitch', category: 'player'),
),
);
appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)');
@@ -71,7 +64,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
Future<void> _refreshAndroidMpvDecoderAfterFrameRateSwitch({required String reason}) async {
final p = player;
if (!mounted || !Platform.isAndroid || p == null || p is PlayerAndroid) return;
if (!mounted || p == null || !p.needsDecoderRefreshAfterDisplaySwitch) return;
final isLive = widget.isLive;
final targetPosition = p.state.position;
@@ -108,19 +101,6 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
}
}
/// Clear frame rate matching and restore default display mode
Future<void> _clearFrameRateMatching() async {
if (player == null || !Platform.isAndroid) return;
try {
await player!.clearVideoFrameRate();
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching cleared', category: 'player')));
appLogger.d('Frame rate matching: Cleared, restored default display mode');
} catch (e) {
appLogger.d('Failed to clear frame rate matching', error: e);
}
}
/// Apply Windows display mode matching (refresh rate, HDR).
Future<void> _applyWindowsDisplayMatching() async {
if (player == null || _displayModeService == null) return;
@@ -1,6 +1,26 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
void _clearEpisodeLoadingFlags() {
if (!_isLoadingNext && !_isLoadingPrevious) return;
_setPlayerState(() {
_isLoadingNext = false;
_isLoadingPrevious = false;
});
}
/// Old screen-swap parity: after an in-place item change (or its failed
/// rollback), surface the chrome and re-anchor focus on play/pause. The
/// control that drove the swap (next button, queue item, play-next prompt)
/// may have unmounted or unfocused by now — without a fresh route's
/// autofocus, dpad navigation would be stranded until the chrome is hidden
/// and re-shown. Focusing play/pause is invisible in pointer mode (focus
/// visuals are keyboard/dpad-gated).
void _showChromeForSwappedItem() {
if (!mounted) return;
_chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause);
}
Future<void> _playNext() async {
if (!mounted) return;
if (_nextEpisode == null || _isLoadingNext) return;
@@ -46,7 +66,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_setPlayerState(() {
_showPlayNextDialog = false;
_completionTriggered = false;
_completionLatch.reset();
});
final target = clampSeekPosition(currentPlayer, Duration.zero);
@@ -57,92 +77,171 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_updateMediaControlsPlaybackState();
}
/// Navigates to a new episode, preserving playback state and track selections.
/// When PiP is active, swaps the media source in-place to keep the PiP window alive.
/// Replace this screen with a fresh player route — the fallback for flows
/// the in-place reload cannot serve. Marks the screen as being replaced so
/// dispose skips the app-level player-exit side effects the replacement
/// route takes over (WT host-exit notify, sleep timer, system UI restore,
/// display mode).
Future<void> _replaceScreenWithPlayer(MediaItem metadata) async {
_isReplacingWithVideo = true; // before any await — dispose can run mid-helper
try {
await navigateToVideoPlayer(
context,
metadata: metadata,
usePushReplacement: true,
isOffline: _offlineLibraryMode,
);
} finally {
// Still mounted ⇒ no push happened (external-player branch or a
// throw): this screen stays, so restore normal-exit semantics.
if (mounted) {
_isReplacingWithVideo = false;
_clearEpisodeLoadingFlags();
}
}
}
/// Navigates to a new episode by reusing the current player whenever possible.
Future<void> _navigateToEpisode(MediaItem episodeMetadata) async {
// PiP active: swap media in-place to keep the PiP window alive. The
// swap path threads the neutral [MediaServerClient] through
// [PlaybackInitializationService] and the lifecycle services, so it
// works for both Plex and Jellyfin sessions.
if (PipService().isPipActive.value && player != null) {
await _swapEpisodeInPip(episodeMetadata);
return;
}
// Set flag to skip orientation restoration in dispose()
_isReplacingWithVideo = true;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (player == null) {
if (mounted) {
unawaited(
navigateToVideoPlayer(
context,
metadata: episodeMetadata,
usePushReplacement: true,
isOffline: widget.isOffline,
),
);
}
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
return;
}
// Capture current state atomically to avoid race conditions
await _reloadMediaInPlace(
metadata: episodeMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: null,
qualityPreset: _selectedQualityPreset,
// Stream ids are per-part: the previous episode's audio id is
// meaningless on the new item, so let preferences pick the track.
useCurrentAudioStreamSelection: false,
preserveCurrentTrackSelection: true,
reason: 'episode navigation',
);
}
Future<void> _switchPlaybackSource({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
}) async {
final currentPlayer = player;
if (currentPlayer == null) {
if (mounted) {
unawaited(
navigateToVideoPlayer(
context,
metadata: episodeMetadata,
usePushReplacement: true,
isOffline: widget.isOffline,
),
if (!mounted || currentPlayer == null || _playbackTransition != _PlaybackTransition.idle) return;
final effectiveMediaIndex = newMediaIndex ?? _effectiveSelectedMediaIndex;
final effectivePreset = newPreset ?? _selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
final currentSubtitleStreamId = _selectedSourceSubtitleStreamIdForControls(_sourceSubtitleTracksForControls());
final effectiveSubtitleStreamId = newSubtitleStreamId ?? currentSubtitleStreamId;
final effectiveMediaSourceId = newMediaIndex != null
? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _selectedMediaSourceId
: _selectedMediaSourceId;
final isVersionChange =
effectiveMediaIndex != _effectiveSelectedMediaIndex ||
(_selectedMediaSourceId != null && effectiveMediaSourceId != _selectedMediaSourceId);
final isPresetChange = effectivePreset != _selectedQualityPreset;
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
final isSubtitleChange = newSubtitleStreamId != null && effectiveSubtitleStreamId != currentSubtitleStreamId;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) return;
// Read the client before any await — context across an async gap. A
// missing client leaves this null and the guard below reports it.
final serverId = _currentMetadata.serverId;
PlexClient? subtitleClient;
if (isSubtitleChange && serverId != null) {
try {
subtitleClient = context.getPlexClientForServer(ServerId(serverId));
} catch (_) {}
}
try {
if (isVersionChange) {
await saveMediaVersionIndexFor(_currentMetadata, effectiveMediaIndex);
}
if (isSubtitleChange) {
final partId = _currentMediaInfo?.partId;
if (subtitleClient == null || partId == null || effectiveSubtitleStreamId == null) {
throw StateError('No Plex part available for subtitle stream selection');
}
final saved = await subtitleClient.selectStreams(
partId,
subtitleStreamID: effectiveSubtitleStreamId,
allParts: true,
);
if (!saved) {
throw StateError('Failed to select subtitle stream');
}
return;
}
final currentAudioTrack = currentPlayer.state.track.audio;
final currentSubtitleTrack = currentPlayer.state.track.subtitle;
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
unawaited(currentPlayer.pause());
await _sendStoppedProgressOnce();
_progressTracker?.stopTracking();
await disposePlayerForNavigation();
if (mounted) {
unawaited(
navigateToVideoPlayer(
context,
metadata: episodeMetadata,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
usePushReplacement: true,
isOffline: widget.isOffline,
),
await _reloadMediaInPlace(
metadata: _currentMetadata.copyWith(viewOffsetMs: currentPlayer.state.position.inMilliseconds),
selectedMediaIndex: effectiveMediaIndex,
selectedMediaSourceId: effectiveMediaSourceId,
qualityPreset: effectivePreset,
// A version change selects a different part, and stream ids are
// per-part — only same-part switches may carry the current id.
selectedAudioStreamId: isVersionChange ? newAudioStreamId : effectiveAudioStreamId,
useCurrentAudioStreamSelection: !isVersionChange,
resumePosition: currentPlayer.state.position,
preserveCurrentTrackSelection: false,
reason: 'source switch',
);
} catch (e) {
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
/// Swap to a new episode while keeping the player alive for PiP continuity.
/// Reuses the existing mpv instance (and its Metal layer in PiP) and only
/// reloads the media source + resets Dart-side services.
Future<void> _swapEpisodeInPip(MediaItem episodeMetadata) async {
_isSwappingEpisode = true;
/// Reload a VOD item/source while keeping the route, player instance, and
/// native renderer alive. This is the common path for episode navigation,
/// queue item jumps, Watch Together media switches, and source changes.
Future<bool> _reloadMediaInPlace({
required MediaItem metadata,
int? selectedMediaIndex,
String? selectedMediaSourceId,
TranscodeQualityPreset? qualityPreset,
int? selectedAudioStreamId,
Duration? resumePosition,
bool preserveCurrentTrackSelection = false,
bool useCurrentAudioStreamSelection = true,
String reason = 'media reload',
}) async {
if (widget.isLive) {
_clearEpisodeLoadingFlags();
return false;
}
final existingPlayer = player;
if (!mounted || existingPlayer == null || _playbackTransition != _PlaybackTransition.idle) {
if (mounted) _clearEpisodeLoadingFlags();
return false;
}
_playbackTransition = _PlaybackTransition.reloadingMedia;
final currentPlayer = player!;
final playbackGeneration = _beginPlaybackGeneration(isEpisodeSwap: true);
final previousMetadata = _currentMetadata;
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true);
bool isCurrentReload() => attempt.isCurrent;
final currentAudioTrack = currentPlayer.state.track.audio;
final currentSubtitleTrack = currentPlayer.state.track.subtitle;
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
// The session itself swaps atomically at the open boundary, so the only
// rollback state is the eagerly-set identity (shown by the loading UI)
// and the first-frame flag.
final previousMetadata = _currentMetadata;
final previousMediaIndex = _effectiveSelectedMediaIndex;
final previousPartId = _currentMediaInfo?.partId;
final previousHasFirstFrame = _hasFirstFrame.value;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
final currentAudioTrack = preserveCurrentTrackSelection ? currentPlayer.state.track.audio : null;
final currentSubtitleTrack = preserveCurrentTrackSelection ? currentPlayer.state.track.subtitle : null;
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? currentPlayer.state.track.secondarySubtitle
: null;
final wasPlayingBeforeReload = currentPlayer.state.playing;
var didOpenReplacement = false;
// Capture context-dependent values before async gaps. The neutral
// [PlaybackInitializationService] consumes [mediaClient] regardless of
@@ -154,39 +253,61 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final playbackState = context.read<PlaybackStateProvider>();
final database = context.read<AppDatabase>();
final serverManager = context.read<MultiServerProvider>().serverManager;
// Sync readiness (playerReady/deferredPlay/firstPlay handshake) is
// per-item: cycle the Watch Together attachment across item changes, the
// same reset the old screen-swap flow got from dispose + re-attach.
// Same-item source switches keep the attachment (and readiness) intact.
final watchTogether = _activeWatchTogetherSession();
final watchTogetherWasAttached = watchTogether?.syncManager?.hasPlayer ?? false;
final cycleWatchTogetherAttachment = watchTogetherWasAttached && isItemChange;
await _sendStoppedProgressOnce();
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return true;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
final requestedMediaIndex = _effectiveSelectedMediaIndex;
_currentMetadata = episodeMetadata;
VideoPlayerScreenState._activeId = episodeMetadata.id;
VideoPlayerScreenState._activeMediaIndex = requestedMediaIndex;
final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex;
final targetQualityPreset = qualityPreset ?? _selectedQualityPreset;
final targetAudioStreamId = useCurrentAudioStreamSelection
? selectedAudioStreamId ?? _selectedAudioStreamId
: selectedAudioStreamId;
// Eager identity-only: the loading UI shows the new title immediately,
// while the selection/source state flips with the session commit at the
// open boundary.
_currentMetadata = metadata;
VideoPlayerScreenState._activeId = metadata.id;
VideoPlayerScreenState._activeMediaIndex = targetMediaIndex;
_unfocusPlayNextPrompt();
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
_hasFirstFrame.value = false;
try {
// Detach before pausing so the reload's internal pause can't broadcast
// a party-wide pause; the finally below restores the attachment.
if (cycleWatchTogetherAttachment) {
watchTogether!.detachPlayer();
}
try {
await currentPlayer.pause();
} catch (e) {
appLogger.w('Failed to pause before $reason', error: e);
}
if (!isCurrentReload()) return true;
// Overlap the old item's stop report with the resolve round-trip; it
// is awaited again right before the open below.
final stoppedProgressFuture = _sendStoppedProgressOnce();
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
final playbackContext = await playbackResolver.resolve(
metadata: episodeMetadata,
selectedMediaIndex: requestedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
offlineLibraryMode: widget.isOffline,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
metadata: metadata,
selectedMediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
offlineLibraryMode: _offlineLibraryMode,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!isCurrentReload()) return true;
final result = playbackContext.result;
final mediaClient = playbackContext.reportingClient;
final plexClient = mediaClient is PlexClient ? mediaClient : null;
@@ -196,106 +317,145 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
throw PlaybackException('No video URL available');
}
Duration? resumePosition;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackContext = playbackContext;
_streamHeaders = streamHeaders;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
// Build the replacement session now, commit it only once open()
// succeeds — until then every session-derived getter still describes
// the item that is actually playing.
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: targetQualityPreset,
requestedMediaSourceId: selectedMediaSourceId,
);
if (result.fallbackReason != null && !targetQualityPreset.isOriginal && mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
_selectedQualityPreset = TranscodeQualityPreset.original;
}
if (_isOfflinePlayback) {
final localOffset = await offlineWatchService.getLocalViewOffset(episodeMetadata.globalKey);
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (localOffset != null && localOffset > 0) {
resumePosition = Duration(milliseconds: localOffset);
}
}
resumePosition ??= episodeMetadata.viewOffsetMs != null
? Duration(milliseconds: episodeMetadata.viewOffsetMs!)
: null;
final openResumePosition = await _resolveOpenResumePosition(
metadata: metadata,
isOffline: _offlineLibraryMode || result.isOffline,
offlineWatchService: offlineWatchService,
requested: resumePosition,
);
if (!isCurrentReload()) return true;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
final isExoPlayer = player is PlayerAndroid;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final displayCriteria = result.mediaInfo?.displayCriteria;
await currentPlayer.setDisplayCriteria(
!result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return true;
// Same pre-open frame-rate orchestration as the initial start flow —
// including the Android MPV startup decoder refresh, whose gate is
// armed before open and released after track setup below.
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
hasVideoUrl: true,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
);
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
final openTiming = _playbackOpenTiming(
backend: episodeMetadata.backend,
if (frameRatePlan == null || !isCurrentReload()) return true;
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) _frameRate.applied = true;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: episodeMetadata.durationMs,
);
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
await currentPlayer.open(
Media(result.videoUrl!, start: openTiming.mediaStart, headers: result.usesLocalMedia ? null : streamHeaders),
play: isExoPlayer || !hasExternalSubs,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: openTiming.timelineOffset,
timelineDuration: openTiming.timelineDuration,
if (!isCurrentReload()) return true;
final openTiming = _playbackOpenTiming(
backend: metadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
);
await stoppedProgressFuture;
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return true;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
_completionTriggered = false;
_isSwappingEpisode = false;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
play: !frameRatePlan.holdPlaybackStart && (attachesSubsAtOpen || !hasExternalSubs),
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
shouldContinue: isCurrentReload,
onOpened: () {
// The player now owns the new file — publish the session at the
// same boundary so identity and source state flip together.
didOpenReplacement = true;
_commitPlaybackSession(session);
},
);
if (!didOpen || !isCurrentReload()) return true;
_completionLatch.reset();
_scrubPreviewSource?.dispose();
_setPlayerState(() {
_availableVersions = result.availableVersions;
_currentMediaInfo = result.mediaInfo;
_scrubPreviewSource = null;
_isLoadingNext = false;
});
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up. Same-part switches (quality/audio/subtitle)
// keep the scrub-preview source — BIF/trickplay is per part, so a
// reset would re-download identical bytes.
final reusesScrubPreview =
previousMetadata.globalKey == metadata.globalKey &&
previousPartId != null &&
previousPartId == result.mediaInfo?.partId;
if (reusesScrubPreview) {
_setPlayerState(() {});
} else {
_resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
}
_clearEpisodeLoadingFlags();
if (isItemChange) _showChromeForSwappedItem();
_trackManager?.dispose();
final trackManager = TrackManager(
player: currentPlayer,
isActive: () => mounted && player != null,
// Plex writes track changes immediately. Jellyfin persists selected
// indexes through playback progress reports.
persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null,
final trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: metadata,
plexClient: plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
metadata: episodeMetadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
);
_trackManager = trackManager;
trackManager.cacheExternalSubtitles(result.externalSubtitles);
if (player is! PlayerAndroid && hasExternalSubs) {
trackManager.waitingForExternalSubsTrackSelection = true;
try {
await trackManager.addExternalSubtitles(result.externalSubtitles);
} finally {
await trackManager.resumeAfterSubtitleLoad();
}
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
} else {
trackManager.applyTrackSelectionWhenReady();
}
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: trackManager,
externalSubtitles: result.externalSubtitles,
// Same guard as the start path: don't resume a player a newer flow
// owns, and let a pending startup gate own the resume instead.
shouldResumeAfterSubtitleLoad: () => !frameRatePlan.holdPlaybackStart && mounted && player == currentPlayer,
);
if (!isCurrentReload()) return true;
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
reason: reason,
),
);
if (!isCurrentReload()) return true;
// Same helper as the initial start flow, so any future change lands in
// both paths together.
_wirePerItemPlaybackServices(
metadata: episodeMetadata,
metadata: metadata,
mediaClient: mediaClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
@@ -304,24 +464,80 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
);
try {
playbackState.setCurrentItem(episodeMetadata);
playbackState.setCurrentItem(metadata);
} catch (e) {
appLogger.d('playbackState.setCurrentItem failed', error: e);
}
await _loadAdjacentEpisodes();
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt));
if (!isCurrentReload()) return true;
if (_autoPipEnabled) {
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing));
}
return true;
} catch (e) {
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
_isSwappingEpisode = false;
_completionTriggered = false;
if (!isCurrentReload()) return true;
_completionLatch.reset();
if (!didOpenReplacement) {
// Nothing was opened: the previous session is still committed, so
// only the eagerly-set identity needs restoring before resuming.
_currentMetadata = previousMetadata;
VideoPlayerScreenState._activeId = previousMetadata.id;
appLogger.e('Failed to swap episode in PiP', error: e);
VideoPlayerScreenState._activeMediaIndex = previousMediaIndex;
_hasFirstFrame.value = previousHasFirstFrame;
// If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends).
_progressTracker?.resumeAfterStoppedReport();
if (wasPlayingBeforeReload && mounted && player == currentPlayer) {
unawaited(currentPlayer.play());
}
} else if (_progressTracker == null && player == currentPlayer) {
// The new file is playing and its session is committed — keep the
// new identity and make sure progress reporting is wired to the
// item actually on screen (the failure may have hit before
// _wirePerItemPlaybackServices ran).
_wirePerItemPlaybackServices(
metadata: metadata,
mediaClient: _playbackSession?.reportingClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
playMethod: _playbackPlayMethod,
mediaInfo: _currentMediaInfo,
);
}
// Unconditional setState — beyond the flags this also publishes the
// rolled-back identity (_clearEpisodeLoadingFlags skips the rebuild
// when no loading flags are set).
_setPlayerState(() {
_isLoadingNext = false;
_isLoadingPrevious = false;
});
if (isItemChange) _showChromeForSwappedItem();
appLogger.e('Failed to reload media in-place during $reason', error: e);
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
return true;
} finally {
// Release the reload transition unless a newer flow already took
// ownership (a non-reload attempt force-idles it; a newer reload can
// then re-acquire it).
if (attempt.isCurrent && _playbackTransition == _PlaybackTransition.reloadingMedia) {
_playbackTransition = _PlaybackTransition.idle;
}
// Restore Watch Together sync on every exit: after a successful item
// change (readiness re-handshakes for the new item), after a failed
// reload (the still-playing old item must stay synced), and when the
// manager auto-detached itself on a mid-reload remote-action failure.
if (watchTogetherWasAttached &&
watchTogether != null &&
watchTogether.isInSession &&
mounted &&
player == currentPlayer &&
!(watchTogether.syncManager?.hasPlayer ?? true)) {
watchTogether.attachPlayer(currentPlayer);
}
}
}
}
@@ -6,7 +6,7 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
if (!mounted) return;
// Download/offline library mode uses the local downloaded queue instead.
if (widget.isOffline) return;
if (_offlineLibraryMode) return;
// Skip play queue for live TV (would interfere with tuner session)
if (widget.isLive) return;
@@ -77,10 +77,12 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
}
}
Future<void> _loadAdjacentEpisodes() async {
Future<void> _loadAdjacentEpisodes({MediaItem? metadata, _PlaybackAttempt? attempt}) async {
if (!mounted || widget.isLive) return;
if (widget.isOffline) {
final targetMetadata = metadata ?? _currentMetadata;
if (_offlineLibraryMode) {
// Offline mode: find next/previous from downloaded episodes
_loadAdjacentEpisodesOffline();
return;
@@ -89,10 +91,10 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
try {
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
context: context,
metadata: _currentMetadata,
metadata: targetMetadata,
);
if (mounted) {
if (mounted && _currentMetadata.globalKey == targetMetadata.globalKey && (attempt == null || attempt.isCurrent)) {
_setPlayerState(() {
_nextEpisode = adjacentEpisodes.next;
_previousEpisode = adjacentEpisodes.previous;
+5 -5
View File
@@ -22,11 +22,11 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
// Live TV: retry with progressively degraded stream settings
// (mirrors Plex web client fallback chain).
if (widget.isLive && _liveStreamFallbackLevel < 2 && !_isRetryingLiveStream) {
_liveStreamFallbackLevel++;
_isRetryingLiveStream = true;
appLogger.w('Live stream failed, retrying with fallback level $_liveStreamFallbackLevel');
_retryLiveStream().whenComplete(() => _isRetryingLiveStream = false);
if (widget.isLive && _live.fallbackLevel < 2 && !_live.retrying) {
_live.fallbackLevel++;
_live.retrying = true;
appLogger.w('Live stream failed, retrying with fallback level $_live.fallbackLevel');
_retryLiveStream().whenComplete(() => _live.retrying = false);
return;
}
@@ -59,14 +59,14 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
}
void _suspendLiveTimelineForBackground() {
_resumeLiveTimelineOnResume = _liveTimelineTimer != null;
_live.resumeTimelineOnResume = _live.timelineTimer != null;
_stopLiveTimelineUpdates();
}
void _resumeLiveTimelineAfterBackgroundIfNeeded() {
final shouldResume = _resumeLiveTimelineOnResume;
_resumeLiveTimelineOnResume = false;
if (shouldResume && _liveSessionIdentifier != null) {
final shouldResume = _live.resumeTimelineOnResume;
_live.resumeTimelineOnResume = false;
if (shouldResume && _live.sessionIdentifier != null) {
_startLiveTimelineUpdates();
}
}
+132 -122
View File
@@ -3,10 +3,10 @@ part of '../../video_player_screen.dart';
extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Start periodic timeline heartbeats for live TV transcode session.
void _startLiveTimelineUpdates() {
final generation = ++_liveTimelineGeneration;
_liveTimelineTimer?.cancel();
_liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
if (generation != _liveTimelineGeneration) return;
final generation = ++_live.timelineGeneration;
_live.timelineTimer?.cancel();
_live.timelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
if (generation != _live.timelineGeneration) return;
final state = player?.state.playing == true ? 'playing' : 'paused';
_sendLiveTimeline(state);
});
@@ -14,7 +14,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
// Sending time=0 immediately after player.open() causes the server
// to spawn a duplicate transcode job with offset=-1 that 404s.
Future.delayed(const Duration(seconds: 3), () {
if (_liveTimelineTimer != null && generation == _liveTimelineGeneration) {
if (_live.timelineTimer != null && generation == _live.timelineGeneration) {
final state = player?.state.playing == true ? 'playing' : 'paused';
_sendLiveTimeline(state);
}
@@ -22,30 +22,30 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
}
void _stopLiveTimelineUpdates() {
_liveTimelineGeneration++;
_liveTimelineTimer?.cancel();
_liveTimelineTimer = null;
_live.timelineGeneration++;
_live.timelineTimer?.cancel();
_live.timelineTimer = null;
}
Future<void> _sendLiveTimeline(String state) async {
final client = _liveClient;
final playbackTime = _livePlaybackStartTime != null
? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds
final client = _live.client;
final playbackTime = _live.playbackStartTime != null
? DateTime.now().difference(_live.playbackStartTime!).inMilliseconds
: 0;
if (client is PlexClient) {
final sessionId = _liveSessionIdentifier;
final sessionPath = _liveSessionPath;
final sessionId = _live.sessionIdentifier;
final sessionPath = _live.sessionPath;
if (sessionId == null || sessionPath == null) return;
try {
// Use the program ratingKey from tune metadata, not the channel key
final ratingKey = _liveProgramId ?? _liveItemId ?? widget.metadata.id;
final ratingKey = _live.programId ?? _live.itemId;
// For live TV, player position/duration are unreliable (often 0).
// Use playbackTime as time, and program duration from tune metadata.
// Plex rejects timeline pings where time > duration; grow duration to
// match — otherwise Tunarr-style short synthetic programs 400 mid-stream.
final time = playbackTime;
final duration = max(_liveDurationMs ?? 0, time);
final duration = max(_live.durationMs ?? 0, time);
final updatedBuffer = await client.updateLiveTimeline(
ratingKey: ratingKey,
sessionPath: sessionPath,
@@ -57,8 +57,8 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
);
if (updatedBuffer != null && mounted) {
_setPlayerState(() {
_captureBuffer = updatedBuffer;
_isAtLiveEdge =
_live.captureBuffer = updatedBuffer;
_live.atLiveEdge =
(_currentPositionEpoch >=
updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
});
@@ -70,12 +70,12 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
}
if (client is JellyfinClient) {
await _jellyfinLiveSession.report(
await _live.jellyfin.report(
client: client,
itemId: _liveItemId ?? widget.metadata.id,
itemId: _live.itemId,
state: state,
position: Duration(milliseconds: playbackTime),
duration: Duration(milliseconds: _liveDurationMs ?? 0),
duration: Duration(milliseconds: _live.durationMs ?? 0),
);
return;
}
@@ -89,14 +89,16 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// that URL — degradation knobs apply only to the Plex transcoder branch.
Future<void> _retryLiveStream() async {
_liveSeek.cancel();
final client = _liveClient;
final ds = _liveStreamFallbackLevel < 1;
final dsa = _liveStreamFallbackLevel < 2;
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final client = _live.client;
final ds = _live.fallbackLevel < 1;
final dsa = _live.fallbackLevel < 2;
if (client is PlexClient) {
final channels = widget.liveChannels;
final channelIndex = _liveChannelIndex;
final dvrKey = _liveDvrKey;
final channels = widget.live?.channels;
final channelIndex = _live.channelIndex;
final dvrKey = _live.dvrKey;
if (channels == null || channelIndex < 0 || channelIndex >= channels.length || dvrKey == null) {
appLogger.w('Cannot retry live stream — missing session info');
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
@@ -108,48 +110,50 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
// Re-tune to get a fresh capture session — the previous one is dead.
final tuneResult = await client.tuneChannel(dvrKey, channel.key);
if (tuneResult == null || !mounted) {
if (!mounted || player != currentPlayer) return;
if (tuneResult == null) {
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton());
return;
}
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
_transcodeSessionId = generateSessionIdentifier();
_live.sessionIdentifier = tuneResult.sessionIdentifier;
_live.sessionPath = tuneResult.sessionPath;
_live.transcodeSessionId = generateSessionIdentifier();
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
transcodeSessionId: _live.transcodeSessionId!,
directStream: ds,
directStreamAudio: dsa,
);
if (streamPath == null || !mounted) {
if (!mounted || player != currentPlayer) return;
if (streamPath == null) {
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton());
return;
}
final streamUrl = client.buildLiveStreamUrl(streamPath);
_liveStreamUrl = streamUrl;
_livePlaybackStartTime = DateTime.now();
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
_live.streamUrl = streamUrl;
_live.markStreamRestartedAtLiveEdge();
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
await _setLiveStreamOptions(currentPlayer);
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
return;
}
final liveStreamUrl = _liveStreamUrl;
final liveStreamUrl = _live.streamUrl;
if (client is JellyfinClient && liveStreamUrl != null) {
appLogger.i('Retrying Jellyfin live stream by re-opening URL');
_livePlaybackStartTime = DateTime.now();
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
await _setLiveStreamOptions();
await player!.open(Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_live.markStreamRestartedAtLiveEdge();
await _setLiveStreamOptions(currentPlayer);
await currentPlayer.open(
Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}),
play: true,
isLive: true,
);
return;
}
@@ -161,18 +165,16 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Configure MPV options for live streaming.
/// The official Plex Media Player does not set client-side reconnect options —
/// reconnection is handled by the server's transcoder on the input side.
Future<void> _setLiveStreamOptions() async {
await player!.setProperty('force-seekable', 'no');
}
Future<void> _setLiveStreamOptions(Player player) => player.setProperty('force-seekable', 'no');
/// The raw live playback position as an absolute epoch second
/// (`_streamStartEpoch + player position`).
int get _rawPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// (`_live.streamStartEpoch + player position`).
int get _rawPositionEpoch => (_live.streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// The current playback position as an absolute epoch second (for live TV time-shift).
///
/// While a relative skip is pending/settling, this returns the accumulator's
/// target rather than the raw sum. During a live re-open `_streamStartEpoch`
/// target rather than the raw sum. During a live re-open `_live.streamStartEpoch`
/// is advanced to the target before the new stream's position resets to ~0,
/// so the raw sum transiently overshoots; pinning to the pending target keeps
/// seek accumulation and the live-edge heartbeat ([_sendLiveTimeline]) correct
@@ -196,47 +198,53 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Seek the live TV stream to an absolute epoch second.
/// Creates a new transcode session at the target offset.
Future<void> _seekLivePosition(int targetEpochSeconds) async {
if (_captureBuffer == null ||
_liveSessionPath == null ||
_liveSessionIdentifier == null ||
_transcodeSessionId == null) {
final currentPlayer = player;
if (currentPlayer == null) return;
if (_live.captureBuffer == null ||
_live.sessionPath == null ||
_live.sessionIdentifier == null ||
_live.transcodeSessionId == null) {
return;
}
final clamped = targetEpochSeconds.clamp(_captureBuffer!.seekableStartEpoch, _captureBuffer!.seekableEndEpoch);
final clamped = targetEpochSeconds.clamp(
_live.captureBuffer!.seekableStartEpoch,
_live.captureBuffer!.seekableEndEpoch,
);
final offsetSeconds = clamped - _captureBuffer!.startedAt.round();
final offsetSeconds = clamped - _live.captureBuffer!.startedAt.round();
// Live seek requires a transcode session — Plex-only by protocol. The
// Plex path populates _captureBuffer; the Jellyfin path never does, so
// Plex path populates _live.captureBuffer; the Jellyfin path never does, so
// the early-return above already covers Jellyfin in practice. This
// explicit guard keeps the contract obvious.
final client = _liveClient;
final client = _live.client;
if (client is! PlexClient) return;
final streamPath = await client.buildLiveStreamPath(
sessionPath: _liveSessionPath!,
sessionIdentifier: _liveSessionIdentifier!,
transcodeSessionId: _transcodeSessionId!,
sessionPath: _live.sessionPath!,
sessionIdentifier: _live.sessionIdentifier!,
transcodeSessionId: _live.transcodeSessionId!,
offsetSeconds: offsetSeconds,
);
if (streamPath == null || !mounted) return;
if (streamPath == null || !mounted || player != currentPlayer) return;
final streamUrl = client.buildLiveStreamUrl(streamPath);
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
_isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
_livePlaybackStartTime = DateTime.now();
_live.streamStartEpoch = _live.captureBuffer!.startedAt + offsetSeconds;
_live.atLiveEdge =
(clamped >= _live.captureBuffer!.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
_live.playbackStartTime = DateTime.now();
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
await _setLiveStreamOptions(currentPlayer);
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (mounted) _setPlayerState(() {});
}
/// Current seekable epoch window for [_liveSeek], or null when there is no
/// live capture buffer.
LiveSeekBounds? _liveSeekBounds() {
final buffer = _captureBuffer;
final buffer = _live.captureBuffer;
if (buffer == null) return null;
return (start: buffer.seekableStartEpoch, end: buffer.seekableEndEpoch);
}
@@ -246,10 +254,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
void _onLiveSeekTargetChanged() {
if (!mounted) return;
final pending = _liveSeek.pendingEpoch;
final buffer = _captureBuffer;
final buffer = _live.captureBuffer;
_setPlayerState(() {
if (pending != null && buffer != null) {
_isAtLiveEdge = pending >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds;
_live.atLiveEdge = pending >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds;
}
});
}
@@ -279,19 +287,21 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Jump to the live edge of the capture buffer.
Future<void> _jumpToLiveEdge() async {
if (_captureBuffer == null) return;
await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch);
if (_live.captureBuffer == null) return;
await _seekLiveToEpoch(_live.captureBuffer!.seekableEndEpoch);
}
Future<void> _switchLiveChannel(int delta) async {
final channels = widget.liveChannels;
final channels = widget.live?.channels;
if (channels == null || channels.isEmpty) return;
if (_isSwitchingChannel) return; // debounce concurrent switches
if (_playbackTransition != _PlaybackTransition.idle) return; // debounce concurrent switches
final newIndex = _liveChannelIndex + delta;
final newIndex = _live.channelIndex + delta;
if (newIndex < 0 || newIndex >= channels.length) return;
final currentPlayer = player;
if (currentPlayer == null) return;
_isSwitchingChannel = true;
_playbackTransition = _PlaybackTransition.switchingChannel;
_liveSeek.cancel();
// Stop old session heartbeats and notify server
@@ -313,27 +323,30 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey);
if (!mounted || player != currentPlayer) return;
if (resolution != null) {
// Jellyfin: pre-resolved negotiated URL.
await _setLiveStreamOptions();
await player!.open(Media(resolution.url, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_liveClient = genericClient;
_liveDvrKey = serverInfo.dvrKey;
_liveStreamUrl = resolution.url;
_liveItemId = channel.key;
_liveSessionIdentifier = resolution.playSessionId;
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId);
_livePlaybackStartTime = DateTime.now();
_captureBuffer = null;
_programBeginsAt = null;
_liveProgramId = null;
_liveDurationMs = null;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
await _setLiveStreamOptions(currentPlayer);
await currentPlayer.open(
Media(resolution.url, headers: const {'Accept-Language': 'en'}),
play: true,
isLive: true,
);
_live.client = genericClient;
_live.dvrKey = serverInfo.dvrKey;
_live.streamUrl = resolution.url;
_live.itemId = channel.key;
_live.sessionIdentifier = resolution.playSessionId;
_live.jellyfin = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId);
_live.captureBuffer = null;
_live.programBeginsAt = null;
_live.programId = null;
_live.durationMs = null;
_live.markStreamRestartedAtLiveEdge();
if (!mounted) return;
_setPlayerState(() {
_liveChannelIndex = newIndex;
_liveChannelName = channel.displayName;
_live.channelIndex = newIndex;
_live.channelName = channel.displayName;
});
_startLiveTimelineUpdates();
return;
@@ -344,43 +357,41 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (client == null) return;
final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key);
if (tuneResult == null || !mounted) return;
if (tuneResult == null || !mounted || player != currentPlayer) return;
_transcodeSessionId = generateSessionIdentifier();
_liveStreamFallbackLevel = 0;
_live.transcodeSessionId = generateSessionIdentifier();
_live.fallbackLevel = 0;
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
transcodeSessionId: _live.transcodeSessionId!,
);
if (streamPath == null || !mounted) return;
if (streamPath == null || !mounted || player != currentPlayer) return;
final streamUrl = client.buildLiveStreamUrl(streamPath);
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
await _setLiveStreamOptions(currentPlayer);
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_liveClient = client;
_liveDvrKey = serverInfo.dvrKey;
_liveStreamUrl = streamUrl;
_liveItemId = channel.key;
_livePlaybackStartTime = DateTime.now();
_liveProgramId = tuneResult.metadata.ratingKey;
_liveDurationMs = tuneResult.metadata.duration;
_live.client = client;
_live.dvrKey = serverInfo.dvrKey;
_live.streamUrl = streamUrl;
_live.itemId = channel.key;
_live.programId = tuneResult.metadata.ratingKey;
_live.durationMs = tuneResult.metadata.duration;
// Reset time-shift state for new channel
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
_live.captureBuffer = tuneResult.captureBuffer;
_live.programBeginsAt = tuneResult.beginsAt;
_live.markStreamRestartedAtLiveEdge();
if (!mounted) return;
_setPlayerState(() {
_liveChannelIndex = newIndex;
_liveChannelName = channel.displayName;
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
_live.channelIndex = newIndex;
_live.channelName = channel.displayName;
_live.sessionIdentifier = tuneResult.sessionIdentifier;
_live.sessionPath = tuneResult.sessionPath;
});
// Restart timeline heartbeats for the new session
@@ -389,15 +400,14 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
appLogger.e('Failed to switch channel', error: e);
if (mounted) showErrorSnackBar(context, e.toString());
} finally {
_isSwitchingChannel = false;
_playbackTransition = _PlaybackTransition.idle;
}
}
bool get _hasNextChannel =>
widget.isLive &&
widget.liveChannels != null &&
_liveChannelIndex >= 0 &&
_liveChannelIndex < (widget.liveChannels!.length - 1);
bool get _hasNextChannel {
final channels = widget.live?.channels;
return channels != null && _live.channelIndex >= 0 && _live.channelIndex < channels.length - 1;
}
bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0;
bool get _hasPreviousChannel => widget.live?.channels != null && _live.channelIndex > 0;
}
+2 -6
View File
@@ -37,18 +37,14 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
if (needsVideoFilter && _videoFilterManager == null && settings != null) {
_videoFilterManager = VideoFilterManager(
player: currentPlayer,
availableVersions: _availableVersions,
selectedMediaIndex: _effectiveSelectedMediaIndex,
initialBoxFitMode: settings.read(SettingsService.defaultBoxFitMode),
initialPlayerSize: initialPlayerSize,
onBoxFitModeChanged: (mode) => settings.write(SettingsService.defaultBoxFitMode, mode),
);
_videoFilterManager!.updateVideoFilter();
unawaited(_videoFilterManager!.updateVideoFilter());
}
if (_videoPIPManager == null) {
_videoPIPManager = VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize);
}
_videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize);
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
_attachPipStateListener();
}
@@ -0,0 +1,417 @@
part of '../../video_player_screen.dart';
/// Outcome of the Android pre-open frame-rate negotiation for the initial
/// start flow: which pre-switch ran, whether playback must open paused
/// behind a startup gate, and which post-open follow-up (fallback switch
/// or mpv decoder refresh) releases it.
class _FrameRateStartupPlan {
_FrameRateStartupPlan({required this.fps});
final double? fps;
bool attemptedMpvPreLoad = false;
bool didPreLoadSwitch = false;
bool preOpenExoHandled = false;
bool needsPostOpenSwitch = false;
bool needsStartupRefresh = false;
Future<bool>? _startupFrameReady;
/// Whether playback must open paused behind a startup gate that
/// [_releaseFrameRateStartupGate] resumes.
bool get holdPlaybackStart => needsPostOpenSwitch || needsStartupRefresh;
/// Whether the pre-open negotiation already counts as the per-item
/// switch — keeps the post-first-frame fallback from double-switching
/// while a planned follow-up is still pending.
bool get countsAsApplied => didPreLoadSwitch || attemptedMpvPreLoad || preOpenExoHandled;
/// Subscribe to the first rendered frame *before* open() so the startup
/// decoder refresh can't miss a synchronously-fast restart event.
void armStartupRefreshGate(Player player) {
if (!needsStartupRefresh) return;
appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh');
_startupFrameReady = player.streams.playbackRestart.first
.then((_) => true)
.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh');
return false;
},
);
}
}
/// Shared building blocks for opening media on the live player.
///
/// The initial start flow ([_startPlayback]), the in-place reload flow
/// ([_reloadMediaInPlace]), and the transcode-restart seek
/// ([_restartPlexTranscodeAt]) all route through these helpers so per-open
/// behavior (display priming, frame-rate suppression windows, native
/// subtitle styling, the open sequence itself) cannot drift between paths.
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
/// Prime native display matching (tvOS HDMI mode) from server metadata
/// before the decoder emits stream properties. The native side resolves
/// only after any resulting display-mode switch has settled, plus the
/// user-configured extra delay on Apple TV.
Future<void> _primeDisplayCriteria({
required Player player,
required SettingsService settingsService,
required MediaDisplayCriteria? displayCriteria,
required bool isTranscoding,
}) {
return player.setDisplayCriteria(
!isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
extraDelayMs: PlatformDetector.isAppleTV() ? settingsService.read(SettingsService.displaySwitchDelay) * 1000 : 0,
);
}
/// Ask the platform to renegotiate the display refresh rate for [fps],
/// arming the MediaSession pause-suppression window first. The native call
/// returns only after the real display-change event (+ settle + the
/// user-configured delay). Returns whether a switch was initiated.
Future<bool> _switchDisplayFrameRateForOpen({
required Player player,
required SettingsService settingsService,
required double fps,
required int durationMs,
}) {
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
_frameRate.beginSuppressWindow(delaySec);
return player.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000);
}
/// Whether the Android pre-open frame-rate negotiation applies: the user
/// opted into per-content refresh-rate matching and metadata already told
/// us the target fps. Shared by the start and reload flows so the
/// eligibility rule cannot drift between them.
bool _shouldAutoSwitchFrameRateForOpen(SettingsService settingsService, double? fps) {
return Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate) && fps != null && fps > 0;
}
/// Resolve where a fresh open should start: explicit request → locally
/// tracked offline progress → server view offset.
Future<Duration?> _resolveOpenResumePosition({
required MediaItem metadata,
required bool isOffline,
required OfflineWatchSyncService offlineWatchService,
Duration? requested,
}) async {
if (requested != null) return requested;
// In offline mode, prefer locally tracked progress over the cached server
// value since the user may have watched further since downloading.
if (isOffline) {
final localOffset = await offlineWatchService.getLocalViewOffset(metadata.globalKey);
if (localOffset != null && localOffset > 0) {
appLogger.d('Resuming offline playback from local progress: ${localOffset}ms');
return Duration(milliseconds: localOffset);
}
}
return metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : null;
}
/// Run the Android pre-open frame-rate strategy for the initial start:
/// mpv switches before load (its decoder must start after the mode change,
/// then gets a startup refresh); ExoPlayer switches before open (after
/// audio focus, so AudioTrack passthrough survives the renegotiation);
/// anything that could not switch up front falls back to a post-open
/// switch that holds playback start. Returns null when the screen/player
/// went stale mid-switch and the caller must bail.
Future<_FrameRateStartupPlan?> _prepareFrameRateForOpen({
required Player currentPlayer,
required SettingsService settingsService,
required double? preKnownFps,
required bool hasVideoUrl,
required Future<void> Function() ensureAudioFocus,
}) async {
final plan = _FrameRateStartupPlan(fps: preKnownFps);
final willAutoSwitch = _shouldAutoSwitchFrameRateForOpen(settingsService, preKnownFps);
// willAutoSwitch is Android-only, so the strategy fork below is between
// the two Android backends: mpv needs its decoder refreshed after a
// display switch (pre-load path), ExoPlayer switches pre-open instead.
final isAndroidMpv = currentPlayer.needsDecoderRefreshAfterDisplaySwitch;
final needsMpvPreLoad = willAutoSwitch && isAndroidMpv && hasVideoUrl;
final needsExoPreOpen = willAutoSwitch && !isAndroidMpv && hasVideoUrl;
plan.needsPostOpenSwitch = willAutoSwitch && !needsMpvPreLoad && !needsExoPreOpen;
plan.attemptedMpvPreLoad = needsMpvPreLoad;
// MPV on Android can decode and present its first paused frame before a
// post-open display switch settles. Switch first when metadata already
// gives us the FPS so MediaCodec starts after the display mode change.
if (needsMpvPreLoad) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
appLogger.d('Frame rate matching: pre-load MPV switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
plan.didPreLoadSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
);
if (!mounted || player != currentPlayer) return null;
if (plan.didPreLoadSwitch) {
_frameRate.applied = true;
plan.needsStartupRefresh = true;
}
appLogger.d(
'Frame rate matching: pre-load MPV switch complete '
'(switched=${plan.didPreLoadSwitch}, startupRefresh=${plan.needsStartupRefresh})',
);
} catch (e) {
appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.needsStartupRefresh = false;
}
}
// ExoPlayer prepares AudioTrack during open() even when opened paused.
// On Shield/AVR chains, switching HDMI refresh rate after that can break
// direct passthrough, so switch before ExoPlayer creates renderers.
if (needsExoPreOpen) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
await ensureAudioFocus();
if (!mounted || player != currentPlayer) return null;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
final didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
);
if (!mounted || player != currentPlayer) return null;
plan.preOpenExoHandled = true;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch complete (switched=$didSwitch)');
} catch (e) {
appLogger.w('Failed to apply pre-open ExoPlayer frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.preOpenExoHandled = false;
}
}
return plan;
}
/// Release the startup gate a [_FrameRateStartupPlan] held playback
/// behind: run the post-open fallback switch, or wait for the first
/// rendered frame and refresh the mpv decoder, then resume via
/// [resumeAfterStartupGate].
Future<void> _releaseFrameRateStartupGate({
required Player currentPlayer,
required SettingsService settingsService,
required _FrameRateStartupPlan plan,
required Future<void> Function(String reason) resumeAfterStartupGate,
}) async {
// Fallback refresh-rate path. The player was opened paused;
// setVideoFrameRate awaits the real display-change event (+ settle +
// user delay) before returning, then we start playback.
if (plan.needsPostOpenSwitch && mounted && player == currentPlayer) {
_frameRate.applied = true;
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
bool didSwitch = false;
try {
didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: plan.fps!,
durationMs: durationMs,
);
if (!mounted || player != currentPlayer) return;
if (didSwitch) {
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch');
}
} catch (e) {
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
}
// Always resume — either the switch completed and we want to play,
// or no switch was needed and we need to start playback now that the
// preparation gate has been cleared.
await resumeAfterStartupGate('post-open frame rate switch');
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(message: 'Pre-playback frame rate: ${plan.fps}fps, switched=$didSwitch', category: 'player'),
),
);
} else if (plan.needsStartupRefresh && mounted && player == currentPlayer) {
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh');
final startupFrameReady = plan._startupFrameReady;
final startupReady = startupFrameReady == null ? false : await startupFrameReady;
if (mounted && player == currentPlayer) {
if (startupReady) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
await resumeAfterStartupGate('startup decoder refresh');
} else {
appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out');
await resumeAfterStartupGate('startup frame timeout');
}
}
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Android MPV startup decoder refresh after pre-load frame-rate switch',
category: 'player',
),
),
);
}
}
/// Resume playback once a frame-rate startup gate releases: a pending
/// post-open external-subtitle load resumes through the track manager
/// (which also arms selection), everything else plays directly. Shared by
/// the start and reload flows.
Future<void> _resumeAfterFrameRateStartupGate({
required Player currentPlayer,
required bool attachesSubsAtOpen,
required bool hasExternalSubs,
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) {
await trackManager.resumeAfterSubtitleLoad();
} else {
await currentPlayer.play();
}
}
/// Push the user's subtitle style to the native rendering layer (no-op on
/// mpv backends, which style via `sub-*` properties). Must run after
/// open() since that's when ExoPlayer initializes its subtitle views.
Future<void> _applyNativeSubtitleStyle(Player player, SettingsService settingsService) {
return player.setSubtitleStyle(
fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(),
textColor: settingsService.read(SettingsService.subtitleTextColor),
borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(),
borderColor: settingsService.read(SettingsService.subtitleBorderColor),
bgColor: settingsService.read(SettingsService.subtitleBackgroundColor),
bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity),
subtitlePosition: settingsService.read(SettingsService.subtitlePosition),
bold: settingsService.read(SettingsService.subtitleBold),
italic: settingsService.read(SettingsService.subtitleItalic),
);
}
/// 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.
TrackManager _buildTrackManager({
required Player forPlayer,
required MediaItem metadata,
required PlexClient? plexClient,
required MediaServerUserProfile? Function() getProfileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) {
return TrackManager(
player: forPlayer,
isActive: () => mounted && player == forPlayer,
// Plex writes track changes immediately. Jellyfin persists selected
// indexes through playback progress reports.
persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null,
getProfileSettings: getProfileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
metadata: metadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
);
}
/// Apply track selection for a freshly opened source: mpv backends get
/// external subtitles via the post-open sub-add dance (opened paused to
/// avoid the issue #226 race), others arm selection directly.
/// [shouldResumeAfterSubtitleLoad] lets a startup gate own the resume.
/// [applySelectionWhenResumeSkipped] is for flows that legitimately stay
/// 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 bool Function() shouldResumeAfterSubtitleLoad,
bool applySelectionWhenResumeSkipped = false,
}) async {
if (!forPlayer.attachesExternalSubtitlesAtOpen && externalSubtitles.isNotEmpty) {
trackManager.waitingForExternalSubsTrackSelection = true;
try {
await trackManager.addExternalSubtitles(externalSubtitles);
} finally {
if (shouldResumeAfterSubtitleLoad()) {
await trackManager.resumeAfterSubtitleLoad();
} else if (applySelectionWhenResumeSkipped) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
}
} else {
// Subs attached at open time (ExoPlayer) or none: apply once tracks
// are available.
trackManager.applyTrackSelectionWhenReady();
}
}
/// Drop the previous item's scrub-preview source and kick off the async
/// thumbnail load for the new one.
void _resetScrubPreviewForNewItem({
required MediaItem metadata,
required MediaSourceInfo? mediaInfo,
required MediaServerClient? mediaClient,
}) {
_scrubPreviewSource?.dispose();
_setPlayerState(() => _scrubPreviewSource = null);
_queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient);
}
/// Open [videoUrl] on [player]: force-seekable hint → open → native
/// subtitle style.
///
/// [shouldContinue] is re-checked between the awaits so stale generations
/// stop without touching the player further. [onOpened] fires immediately
/// after open() returns (before styling) so callers can flip rollback
/// bookkeeping at the exact ownership boundary.
///
/// Returns false if [shouldContinue] stopped the sequence before open;
/// true once open() has been issued (even if styling was skipped).
Future<bool> _openMediaOnPlayer({
required Player player,
required SettingsService settingsService,
required String videoUrl,
required bool isTranscoding,
required _PlaybackOpenTiming timing,
Map<String, String>? headers,
required bool play,
List<SubtitleTrack>? externalSubtitlesAtOpen,
bool Function()? shouldContinue,
void Function()? onOpened,
}) async {
// Transcode streams can be seekable even when MPV cannot prove it
// from response headers. Reset non-transcodes so live/direct/offline
// streams keep native seekability detection.
await player.setProperty('force-seekable', isTranscoding ? 'yes' : 'no');
if (shouldContinue != null && !shouldContinue()) return false;
await player.open(
Media(videoUrl, start: timing.mediaStart, headers: headers),
play: play,
externalSubtitles: externalSubtitlesAtOpen,
timelineOffset: timing.timelineOffset,
timelineDuration: timing.timelineDuration,
);
onOpened?.call();
if (shouldContinue != null && !shouldContinue()) return true;
await _applyNativeSubtitleStyle(player, settingsService);
return true;
}
}
@@ -6,8 +6,9 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
// inter-segment gaps in the chunked MKV transcode stream.
if (widget.isLive) return;
if (!completed) return;
// Ignore spurious EOF from the old file during in-place episode swap
if (_isSwappingEpisode) return;
// Ignore spurious EOF from the old file during an in-place media-source
// transition (episode swap, transcode restart, channel switch).
if (_playbackTransition != _PlaybackTransition.idle) return;
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
// never fires false. Normalize all playback-dependent state.
@@ -28,14 +29,14 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
// End-of-video sleep timer takes precedence over autoplay / next-episode
// dialogs: the user explicitly asked to stop after this item.
final sleepTimerService = SleepTimerService();
if (sleepTimerService.isEndOfVideoMode && !_completionTriggered) {
_completionTriggered = true;
if (sleepTimerService.isEndOfVideoMode && !_completionLatch.triggered) {
_completionLatch.latch();
sleepTimerService.notifyVideoCompleted();
return;
}
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) {
_completionTriggered = true;
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionLatch.triggered) {
_completionLatch.latch();
// PiP: skip dialog (user can't interact), auto-play immediately
if (PipService().isPipActive.value) {
@@ -72,8 +73,8 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
if (autoPlayEnabled) {
_startAutoPlayTimer();
}
} else if (_nextEpisode == null && !_completionTriggered) {
_completionTriggered = true;
} else if (_nextEpisode == null && !_completionLatch.triggered) {
_completionLatch.latch();
unawaited(_handleBackButton());
}
}
@@ -108,14 +109,15 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
});
}
/// Re-arm the end-of-video latch so Play Next can fire again — but only when no
/// prompt is visible and no auto-play countdown is running, so we never clobber
/// an active dialog. Callers decide *when* it is safe to re-arm (media reloaded,
/// or playback moved back out of the end region).
/// Re-arm the end-of-video latch so Play Next can fire again. Callers
/// decide *when* it is safe to re-arm (media reloaded, or playback moved
/// back out of the end region); the latch itself refuses while a prompt
/// or countdown is active.
void _rearmCompletionLatch() {
if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) {
_completionTriggered = false;
}
_completionLatch.rearmIfClear(
promptVisible: _showPlayNextDialog,
countdownActive: _autoPlayTimer?.isActive == true,
);
}
void _showStillWatchingDialog() {
@@ -1,11 +1,42 @@
part of '../../video_player_screen.dart';
extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
void _queueScrubPreviewLoad({
required MediaItem metadata,
required MediaSourceInfo? mediaInfo,
required MediaServerClient? mediaClient,
}) {
if (mediaInfo == null || _isOfflinePlayback || mediaClient == null) return;
final mediaInfoAtStart = mediaInfo;
final metadataAtStart = metadata;
unawaited(
mediaClient
.createScrubPreviewSource(item: metadataAtStart, mediaSource: mediaInfoAtStart)
.then((service) {
if (service == null) return;
// Keyed on item + part rather than session identity: the preview
// is per part, so a load that outlives a same-part source switch
// (quality/audio) still applies.
if (mounted &&
_currentMetadata.globalKey == metadataAtStart.globalKey &&
_currentMediaInfo?.partId == mediaInfoAtStart.partId) {
_setPlayerState(() => _scrubPreviewSource = service);
} else {
service.dispose();
}
})
.catchError((e, st) {
appLogger.w('Scrub preview load failed', error: e, stackTrace: st);
}),
);
}
/// Wire the per-item playback services that need to (re)bind whenever
/// the active media item changes: [PlaybackProgressTracker],
/// [MediaControlsManager.updateMetadata], and the
/// Discord/Trakt/Tracker scrobblers. Both [_initializeServices] and
/// [_swapEpisodeInPip] call this so the two flows can't drift.
/// [_reloadMediaInPlace] call this so the two flows can't drift.
///
/// The caller is responsible for ensuring `player != null` and (if the
/// media-controls metadata refresh should run) for having created
@@ -21,8 +52,53 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
final currentPlayer = player;
if (currentPlayer == null) return;
// Progress tracker — local media still reports live when its server is
// online; only queue locally when no reporting client is reachable.
_rebindProgressTracker(
metadata: metadata,
mediaClient: mediaClient,
offlineWatchService: offlineWatchService,
playSessionId: playSessionId,
playMethod: playMethod,
mediaInfo: mediaInfo,
);
// Media controls metadata. Fire-and-forget — the OS plugin downloads
// the poster synchronously inside `setMetadata` (~270 ms); the
// controls populate a beat after first frame which is fine.
if (_mediaControlsManager != null) {
unawaited(
_mediaControlsManager!.updateMetadata(
metadata: metadata,
client: mediaClient,
duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
),
);
}
// Scrobblers — Discord RPC, Trakt, unified tracker. All accept the
// neutral [MediaServerClient]; null short-circuits cleanly.
if (mediaClient != null) {
unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient));
unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive));
unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive));
}
}
/// (Re)create the [PlaybackProgressTracker] for the current play session.
/// Session-keyed only — a transcode restart rebinds just this, while item
/// changes go through [_wirePerItemPlaybackServices] for the full set.
void _rebindProgressTracker({
required MediaItem metadata,
required MediaServerClient? mediaClient,
required OfflineWatchSyncService? offlineWatchService,
String? playSessionId,
String? playMethod,
MediaSourceInfo? mediaInfo,
}) {
final currentPlayer = player;
if (currentPlayer == null) return;
// Local media still reports live when its server is online; only queue
// locally when no reporting client is reachable.
if (mediaClient != null) {
_progressTracker = PlaybackProgressTracker(
client: mediaClient,
@@ -45,27 +121,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
);
_progressTracker!.startTracking();
}
// Media controls metadata. Fire-and-forget — the OS plugin downloads
// the poster synchronously inside `setMetadata` (~270 ms); the
// controls populate a beat after first frame which is fine.
if (_mediaControlsManager != null) {
unawaited(
_mediaControlsManager!.updateMetadata(
metadata: metadata,
client: mediaClient,
duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
),
);
}
// Scrobblers — Discord RPC, Trakt, unified tracker. All accept the
// neutral [MediaServerClient]; null short-circuits cleanly.
if (mediaClient != null) {
unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient));
unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive));
unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive));
}
}
/// Initialize the service layer
@@ -122,7 +177,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
_wasPlayingBeforeInactive = false;
_updateMediaControlsPlaybackState();
} else if (event is PauseEvent) {
if (_suppressMediaPauseDuringFrameRateSwitch) {
if (_frameRate.suppressesMediaPause) {
appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)');
return;
}
@@ -152,7 +207,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
});
// Wire progress tracker, media-controls metadata, and the
// Discord/Trakt/Tracker scrobblers. Shared with [_swapEpisodeInPip]
// Discord/Trakt/Tracker scrobblers. Shared with [_reloadMediaInPlace]
// so the two flows can't drift.
_wirePerItemPlaybackServices(
metadata: _currentMetadata,
+118 -349
View File
@@ -4,71 +4,71 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _startPlayback() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final playbackGeneration = _beginPlaybackGeneration();
final attempt = _beginPlaybackAttempt(currentPlayer);
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
try {
_hasFirstFrame.value = false;
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions();
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
await _setLiveStreamOptions(currentPlayer);
if (!attempt.isCurrent) return;
String streamUrl;
if (_liveStreamUrl != null) {
streamUrl = _liveStreamUrl!;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
if (_live.streamUrl != null) {
streamUrl = _live.streamUrl!;
_live.streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_live.atLiveEdge = true;
} else {
// Tune channel inside the player (shows loading spinner while tuning)
final channels = widget.liveChannels;
final channelIndex = _liveChannelIndex;
final channels = widget.live?.channels;
final channelIndex = _live.channelIndex;
if (channels == null || channelIndex < 0 || channelIndex >= channels.length) {
throw Exception('No channel to tune');
}
final channel = channels[channelIndex];
appLogger.d('Tune: dvrKey=$_liveDvrKey channelKey=${channel.key}');
final client = _liveClient;
appLogger.d('Tune: dvrKey=$_live.dvrKey channelKey=${channel.key}');
final client = _live.client;
if (client is! PlexClient) {
throw StateError(
'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. '
'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.',
);
}
final dvrKey = _liveDvrKey;
final dvrKey = _live.dvrKey;
if (dvrKey == null) throw Exception('No DVR to tune');
final tuneResult = await client.tuneChannel(dvrKey, channel.key);
if (tuneResult == null) throw Exception('Failed to tune channel');
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
_liveProgramId = tuneResult.metadata.ratingKey;
_liveDurationMs = tuneResult.metadata.duration;
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_transcodeSessionId = generateSessionIdentifier();
_live.sessionIdentifier = tuneResult.sessionIdentifier;
_live.sessionPath = tuneResult.sessionPath;
_live.programId = tuneResult.metadata.ratingKey;
_live.durationMs = tuneResult.metadata.duration;
_live.captureBuffer = tuneResult.captureBuffer;
_live.programBeginsAt = tuneResult.beginsAt;
_live.transcodeSessionId = generateSessionIdentifier();
// Show "Watch from Start" dialog when an existing capture session has >60s of history.
// On a fresh tune (no active recording), the buffer is empty so this won't trigger.
int? offsetSeconds;
if (_captureBuffer != null && _programBeginsAt != null) {
if (_live.captureBuffer != null && _live.programBeginsAt != null) {
final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final offsetProgramStart = _programBeginsAt! - _captureBuffer!.startedAt.round();
final offsetProgramStart = _live.programBeginsAt! - _live.captureBuffer!.startedAt.round();
// If a session recording started after current program start, offset of program start at will be negative.
// If a session recording started before current program start, offset of program start will be positive.
// If guide data is not available, program start will be equal to current time.
final useProgramStart = offsetProgramStart > 0 && nowEpoch - _programBeginsAt! > 60;
final effectiveStart = useProgramStart ? _programBeginsAt! : _captureBuffer!.seekableStartEpoch;
final useProgramStart = offsetProgramStart > 0 && nowEpoch - _live.programBeginsAt! > 60;
final effectiveStart = useProgramStart ? _live.programBeginsAt! : _live.captureBuffer!.seekableStartEpoch;
final elapsed = nowEpoch - effectiveStart;
appLogger.d(
'Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, '
'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)',
'Time-shift: buffer=${_live.captureBuffer!.seekableDurationSeconds}s, '
'beginsAt=$_live.programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)',
);
if (elapsed > 60) {
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
if (!mounted) return;
if (watchFromStart == true) {
offsetSeconds = useProgramStart ? offsetProgramStart : _captureBuffer!.seekStartSeconds.round();
offsetSeconds = useProgramStart ? offsetProgramStart : _live.captureBuffer!.seekStartSeconds.round();
}
}
}
@@ -77,28 +77,28 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
transcodeSessionId: _live.transcodeSessionId!,
offsetSeconds: offsetSeconds,
);
if (streamPath == null || !mounted) throw Exception('Failed to build stream path');
streamUrl = client.buildLiveStreamUrl(streamPath);
_liveStreamUrl = streamUrl;
_live.streamUrl = streamUrl;
// Track stream start epoch for position calculations
if (offsetSeconds != null) {
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
_isAtLiveEdge = false;
_live.streamStartEpoch = _live.captureBuffer!.startedAt + offsetSeconds;
_live.atLiveEdge = false;
} else {
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
_live.streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_live.atLiveEdge = true;
}
}
_livePlaybackStartTime = DateTime.now();
_live.playbackStartTime = DateTime.now();
await currentPlayer.setProperty('force-seekable', 'no');
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!attempt.isCurrent) return;
_trackManager?.cacheExternalSubtitles(const []);
@@ -106,9 +106,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (!mounted || player != currentPlayer) return;
if (mounted) {
// Live TV never commits a PlaybackSession, so the session-derived
// versions/mediaInfo getters already read empty here.
_setPlayerState(() {
_availableVersions = [];
_currentMediaInfo = null;
_isPlayerInitialized = true;
});
_trackManager?.mediaInfo = null;
@@ -128,91 +128,64 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final offlineWatchService = context.read<OfflineWatchSyncService>();
try {
PlaybackInitializationResult result;
PlaybackContext playbackContext;
Map<String, String>? streamHeaders;
if (widget.isOffline) {
if (_offlineLibraryMode) {
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
playbackContext = await playbackResolver.resolve(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _selectedMediaSourceId,
offlineLibraryMode: true,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
result = playbackContext.result;
if (result.videoUrl == null) {
if (playbackContext.result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
streamHeaders = playbackContext.streamHeaders;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
} else {
// Online path: `_playbackDataFuture` was kicked off in `_initializePlayer`
// in parallel with MPV setup. Quality preset + server capabilities +
// headers were resolved there too. Just await the result.
streamHeaders = _streamHeaders;
final playbackDataFuture = _playbackDataFuture;
if (playbackDataFuture == null) {
throw StateError('Playback data was not prepared before playback start');
}
playbackContext = await playbackDataFuture;
result = playbackContext.result;
if (!mounted || player != currentPlayer) return;
streamHeaders = playbackContext.streamHeaders;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (playbackContext.result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
// Reset the preset so the UI reflects what's actually playing.
_selectedQualityPreset = TranscodeQualityPreset.original;
}
}
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
_playbackContext = playbackContext;
_streamHeaders = streamHeaders;
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
// Initial start has no previous session to protect, so commit as soon
// as the resolve lands (reload-style flows commit at the open
// boundary instead).
_commitPlaybackSession(
PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _selectedMediaSourceId,
),
);
// Primary refresh-rate path: when metadata provides FPS, Android players
// can switch before creating decoders. MPV still needs a startup refresh
// when MediaCodec has already produced its first paused frame.
final settingsService = await SettingsService.getInstance();
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!attempt.isCurrent) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
final preKnownFps = displayCriteria?.fps;
final willAutoSwitch =
Platform.isAndroid &&
settingsService.read(SettingsService.matchContentFrameRate) &&
preKnownFps != null &&
preKnownFps > 0;
final isExoPlayer = currentPlayer is PlayerAndroid;
final isAndroidMpv = Platform.isAndroid && !isExoPlayer;
final needsExoPlayerFrameRateStartup = willAutoSwitch && isExoPlayer && result.videoUrl != null;
final needsAndroidMpvFrameRateStartup = willAutoSwitch && isAndroidMpv && result.videoUrl != null;
var didPreLoadFrameRateSwitch = false;
var didPreOpenExoFrameRateSwitch = false;
var preOpenExoFrameRateHandled = false;
var needsPostOpenFrameRateSwitch =
willAutoSwitch && !needsAndroidMpvFrameRateStartup && !needsExoPlayerFrameRateStartup;
var needsAndroidMpvStartupRefresh = false;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
Future<bool>? androidMpvStartupReady;
var audioFocusReady = false;
Future<void> ensureAudioFocus() async {
@@ -227,89 +200,23 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
audioFocusReady = true;
}
// MPV on Android can decode and present its first paused frame before a
// post-open display switch settles. Switch first when metadata already
// gives us the FPS so MediaCodec starts after the display mode change.
if (needsAndroidMpvFrameRateStartup) {
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
_suppressMediaPauseDuringFrameRateSwitch = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPauseDuringFrameRateSwitch = false;
});
try {
appLogger.d(
'Frame rate matching: pre-load MPV switch to ${preKnownFps}fps '
'(duration: ${durationMs}ms, delay=${delaySec}s)',
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
hasVideoUrl: result.videoUrl != null,
ensureAudioFocus: ensureAudioFocus,
);
didPreLoadFrameRateSwitch = await currentPlayer.setVideoFrameRate(
preKnownFps,
durationMs,
extraDelayMs: delaySec * 1000,
);
if (!mounted || player != currentPlayer) return;
if (didPreLoadFrameRateSwitch) {
_frameRateMatchingApplied = true;
needsAndroidMpvStartupRefresh = true;
}
appLogger.d(
'Frame rate matching: pre-load MPV switch complete '
'(switched=$didPreLoadFrameRateSwitch, delay=${delaySec}s, '
'startupRefresh=$needsAndroidMpvStartupRefresh)',
);
} catch (e) {
appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e);
needsPostOpenFrameRateSwitch = true;
needsAndroidMpvStartupRefresh = false;
}
}
// ExoPlayer prepares AudioTrack during open() even when opened paused.
// On Shield/AVR chains, switching HDMI refresh rate after that can break
// direct passthrough, so switch before ExoPlayer creates renderers.
if (needsExoPlayerFrameRateStartup) {
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
_suppressMediaPauseDuringFrameRateSwitch = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPauseDuringFrameRateSwitch = false;
});
try {
await ensureAudioFocus();
if (!mounted || player != currentPlayer) return;
appLogger.d(
'Frame rate matching: pre-open ExoPlayer switch to ${preKnownFps}fps '
'(duration: ${durationMs}ms, delay=${delaySec}s)',
);
didPreOpenExoFrameRateSwitch = await currentPlayer.setVideoFrameRate(
preKnownFps,
durationMs,
extraDelayMs: delaySec * 1000,
);
if (!mounted || player != currentPlayer) return;
preOpenExoFrameRateHandled = true;
appLogger.d(
'Frame rate matching: pre-open ExoPlayer switch complete '
'(switched=$didPreOpenExoFrameRateSwitch, delay=${delaySec}s)',
);
} catch (e) {
appLogger.w('Failed to apply pre-open ExoPlayer frame rate matching', error: e);
needsPostOpenFrameRateSwitch = true;
preOpenExoFrameRateHandled = false;
}
}
final shouldHoldPlaybackStart = needsPostOpenFrameRateSwitch || needsAndroidMpvStartupRefresh;
Duration? resumePosition;
if (frameRatePlan == null) return;
final shouldHoldPlaybackStart = frameRatePlan.holdPlaybackStart;
// Open video through Player
if (result.videoUrl != null) {
// Reset first frame flag and frame rate retry counter for new video
_hasFirstFrame.value = false;
_frameRateRetries = 0;
_frameRateMatchingApplied = false;
if (didPreLoadFrameRateSwitch || needsAndroidMpvFrameRateStartup || preOpenExoFrameRateHandled) {
_frameRateMatchingApplied = true;
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) {
_frameRate.applied = true;
}
// Request audio focus before starting playback (Android)
@@ -317,23 +224,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Fired in parallel with MPV setup in `_initializePlayer`; we await
// the in-flight future here (usually already resolved).
await ensureAudioFocus();
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!attempt.isCurrent) return;
// Pass resume position if available.
// In offline mode, prefer locally tracked progress over the cached server value
// since the user may have watched further since downloading.
if (_isOfflinePlayback) {
final globalKey = _currentMetadata.globalKey;
final localOffset = await offlineWatchService.getLocalViewOffset(globalKey);
final resumePosition = await _resolveOpenResumePosition(
metadata: _currentMetadata,
isOffline: _isOfflinePlayback,
offlineWatchService: offlineWatchService,
);
if (!mounted || player != currentPlayer) return;
if (localOffset != null && localOffset > 0) {
resumePosition = Duration(milliseconds: localOffset);
appLogger.d('Resuming offline playback from local progress: ${localOffset}ms');
}
}
resumePosition ??= _currentMetadata.viewOffsetMs != null
? Duration(milliseconds: _currentMetadata.viewOffsetMs!)
: null;
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops
// up to 10 min). Forwarded to the Kotlin layer on Android so MPV
@@ -346,23 +244,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
);
}
await currentPlayer.setDisplayCriteria(
!result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs);
if (needsAndroidMpvStartupRefresh) {
appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh');
androidMpvStartupReady = currentPlayer.streams.playbackRestart.first
.then((_) => true)
.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh');
return false;
},
);
}
final shouldAutoPlay = !shouldHoldPlaybackStart && (attachesSubsAtOpen || !hasExternalSubs);
frameRatePlan.armStartupRefreshGate(currentPlayer);
// ExoPlayer: attach external subs at open time so it discovers
// them in a single prepare() — no media reload needed for selection.
@@ -373,34 +263,18 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
// Transcode streams can be seekable even when MPV cannot prove it
// from response headers. Reset non-transcodes so live/direct/offline
// streams keep native seekability detection.
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
await currentPlayer.open(
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
timing: openTiming,
headers: streamHeaders,
play: shouldAutoPlay,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: openTiming.timelineOffset,
timelineDuration: openTiming.timelineDuration,
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
shouldContinue: () => attempt.isCurrent,
);
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
// Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale)
// Must be called after open() since that's when ExoPlayer initializes
if (currentPlayer is PlayerAndroid) {
await currentPlayer.setSubtitleStyle(
fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(),
textColor: settingsService.read(SettingsService.subtitleTextColor),
borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(),
borderColor: settingsService.read(SettingsService.subtitleBorderColor),
bgColor: settingsService.read(SettingsService.subtitleBackgroundColor),
bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity),
subtitlePosition: settingsService.read(SettingsService.subtitlePosition),
bold: settingsService.read(SettingsService.subtitleBold),
italic: settingsService.read(SettingsService.subtitleItalic),
);
}
if (!didOpen || !attempt.isCurrent) return;
// Attach player to Watch Together session for sync (if in session)
if (mounted && !_isOfflinePlayback) {
@@ -409,41 +283,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
}
}
// Update available versions from the playback data
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up.
if (mounted) {
_setPlayerState(() {
_availableVersions = result.availableVersions;
_currentMediaInfo = result.mediaInfo;
_scrubPreviewSource?.dispose();
_scrubPreviewSource = null;
});
// Backend-neutral scrub-thumbnail load. The factory dispatches to
// BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null
// when the inputs aren't sufficient. Guard against media-change
// races during the async load.
final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId));
final mediaInfoAtStart = _currentMediaInfo;
if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) {
unawaited(
mediaClient
.createScrubPreviewSource(item: _currentMetadata, mediaSource: mediaInfoAtStart)
.then((service) {
if (service == null) return;
if (mounted && identical(_currentMediaInfo, mediaInfoAtStart)) {
_setPlayerState(() => _scrubPreviewSource = service);
} else {
service.dispose();
}
})
.catchError((e, st) {
appLogger.w('Scrub preview load failed', error: e, stackTrace: st);
}),
);
}
_resetScrubPreviewForNewItem(metadata: _currentMetadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
await _initVideoFilterAndPip();
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!attempt.isCurrent) return;
if (player == currentPlayer) {
// Auto-PiP: set up callback for API 26-30 path and initial state
@@ -474,122 +321,44 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
await _restoreAmbientLighting();
}
}
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (!attempt.isCurrent) return;
// Track manager: owns track selection, external subtitle loading, and Plex
// immediate stream writes. Jellyfin persists selected stream indexes through
// playback progress reports instead.
final plexTrackClient = mediaClient is PlexClient ? mediaClient : null;
_trackManager = TrackManager(
player: currentPlayer,
isActive: () => mounted && player == currentPlayer,
persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
_trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: _currentMetadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
preferredSecondarySubtitleTrack: widget.preferredSecondarySubtitleTrack,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
);
// Store external subtitles for re-use after backend fallback
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
Future<void> resumeAfterStartupGate(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 (currentPlayer is! PlayerAndroid && hasExternalSubs) {
await trackManager.resumeAfterSubtitleLoad();
} else {
await currentPlayer.play();
}
}
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: _trackManager!,
externalSubtitles: result.externalSubtitles,
// When a startup gate below owns the resume, skip this one to
// avoid a double-play.
shouldResumeAfterSubtitleLoad: () => !shouldHoldPlaybackStart && mounted && player == currentPlayer,
);
// MPV with external subs: add after open via sub-add,
// opened paused to avoid race condition (issue #226)
if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
_hasFirstFrame.value = false;
_trackManager!.waitingForExternalSubsTrackSelection = true;
try {
await _trackManager!.addExternalSubtitles(result.externalSubtitles);
} finally {
// When a startup gate below owns the resume,
// skip this one to avoid a double-play.
if (!shouldHoldPlaybackStart && mounted && player == currentPlayer) {
await _trackManager!.resumeAfterSubtitleLoad();
}
}
} else {
// Android (subs attached at open time) or no external subs:
// apply once tracks are available
_trackManager!.applyTrackSelectionWhenReady();
}
// Fallback refresh-rate path. The player was opened paused;
// setVideoFrameRate awaits the real display-change event (+ settle +
// user delay) before returning, then we start playback.
if (needsPostOpenFrameRateSwitch && mounted && player == currentPlayer) {
_frameRateMatchingApplied = true;
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
_suppressMediaPauseDuringFrameRateSwitch = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPauseDuringFrameRateSwitch = false;
});
bool didSwitch = false;
try {
didSwitch = await currentPlayer.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000);
if (!mounted || player != currentPlayer) return;
if (didSwitch) {
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch');
}
} catch (e) {
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
}
// Always resume — either the switch completed and we want to play,
// or no switch was needed and we need to start playback now that the
// preparation gate has been cleared.
await resumeAfterStartupGate('post-open frame rate switch');
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s',
category: 'player',
),
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
attachesSubsAtOpen: attachesSubsAtOpen,
hasExternalSubs: hasExternalSubs,
reason: reason,
),
);
} else if (needsAndroidMpvStartupRefresh && mounted && player == currentPlayer) {
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh');
final startupReady = androidMpvStartupReady == null ? false : await androidMpvStartupReady;
if (mounted && player == currentPlayer) {
if (startupReady) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
await resumeAfterStartupGate('startup decoder refresh');
} else {
appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out');
await resumeAfterStartupGate('startup frame timeout');
}
}
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Android MPV startup decoder refresh after pre-load frame-rate switch',
category: 'player',
),
),
);
}
}
} on PlaybackException catch (e, st) {
appLogger.w('Playback initialization failed', error: e, stackTrace: st);
+74 -50
View File
@@ -39,91 +39,115 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
}
Future<void> _restartPlexTranscodeAt(Duration target) async {
if (_isRestartingTranscodeSeek) return;
if (_playbackTransition != _PlaybackTransition.idle) return;
appLogger.d('Restarting Plex transcode at ${target.inSeconds}s');
_isRestartingTranscodeSeek = true;
_playbackTransition = _PlaybackTransition.restartingTranscode;
_chromeController.show();
final currentPlayer = player;
if (currentPlayer == null) {
_isRestartingTranscodeSeek = false;
_playbackTransition = _PlaybackTransition.idle;
return;
}
final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds);
final wasPlaying = currentPlayer.state.playing;
final nextTranscodeSessionId = generateSessionIdentifier();
final offlineWatchService = context.read<OfflineWatchSyncService>();
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
try {
final mediaClient = _getMediaServerClient(context);
if (mediaClient == null) {
throw StateError('No client registered for ${replacementMetadata.serverId}');
}
_playbackTranscodeSessionId = nextTranscodeSessionId;
final playbackService = PlaybackInitializationService(client: mediaClient, database: context.read<AppDatabase>());
final result = await playbackService.getPlaybackData(
final playbackContext = await playbackResolver.resolve(
metadata: replacementMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: false,
selectedMediaSourceId: _selectedMediaSourceId,
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
// A transcode restart must stay on the server stream even when the
// preset would normally prefer a downloaded copy.
preferOffline: false,
);
if (!mounted || player != currentPlayer) return;
final result = playbackContext.result;
if (result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
_currentMetadata = replacementMetadata;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
_effectiveSelectedMediaIndex = result.selectedMediaIndex;
_availableVersions = result.availableVersions;
_currentMediaInfo = result.mediaInfo;
final isExoPlayer = currentPlayer is PlayerAndroid;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
final shouldAutoPlay = wasPlaying && (isExoPlayer || !hasExternalSubs);
final timelineDuration = _currentMetadata.durationMs != null
? Duration(milliseconds: _currentMetadata.durationMs!)
: null;
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
await currentPlayer.open(
Media(result.videoUrl!, start: result.isTranscoding ? null : target, headers: _streamHeaders),
play: shouldAutoPlay,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: result.isTranscoding ? target : Duration.zero,
timelineDuration: result.isTranscoding ? timelineDuration : null,
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _selectedMediaSourceId,
);
if (!mounted || player != currentPlayer) return;
final attachesSubsAtOpen = currentPlayer.attachesExternalSubtitlesAtOpen;
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
final shouldAutoPlay = wasPlaying && (attachesSubsAtOpen || !hasExternalSubs);
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: SettingsService.instance,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
timing: _playbackOpenTiming(
backend: replacementMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: target,
durationMs: replacementMetadata.durationMs,
),
headers: playbackContext.streamHeaders,
play: shouldAutoPlay,
externalSubtitlesAtOpen: attachesSubsAtOpen && hasExternalSubs ? result.externalSubtitles : null,
shouldContinue: () => mounted && player == currentPlayer,
onOpened: () {
// A pre-open failure leaves the previous session (and ids)
// committed; the swap happens only once the player owns the
// restarted stream.
_currentMetadata = replacementMetadata;
_commitPlaybackSession(session);
},
);
if (!didOpen || !mounted || player != currentPlayer) return;
_setPlayerState(() {});
// The play session changed with the restarted transcode — rebind the
// progress tracker so reports don't keep flowing against the dead
// session ids. The item itself is unchanged, so the item-keyed
// services (media-controls metadata, scrobblers) stay as they are.
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
_rebindProgressTracker(
metadata: _currentMetadata,
mediaClient: session.reportingClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
playMethod: _playbackPlayMethod,
mediaInfo: _currentMediaInfo,
);
final trackManager = _trackManager;
if (trackManager != null) {
trackManager.metadata = _currentMetadata;
trackManager.mediaInfo = _currentMediaInfo;
trackManager.cacheExternalSubtitles(result.externalSubtitles);
if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) {
trackManager.waitingForExternalSubsTrackSelection = true;
await trackManager.addExternalSubtitles(result.externalSubtitles);
if (wasPlaying && mounted && player == currentPlayer) {
await trackManager.resumeAfterSubtitleLoad();
} else {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
} else {
trackManager.applyTrackSelectionWhenReady();
}
await _applyTracksAfterOpen(
forPlayer: currentPlayer,
trackManager: trackManager,
externalSubtitles: result.externalSubtitles,
// A restart while paused must stay paused — selection is still
// applied through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () => wasPlaying && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: true,
);
}
_updateMediaControlsPlaybackState();
@@ -133,7 +157,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
} finally {
_isRestartingTranscodeSeek = false;
_playbackTransition = _PlaybackTransition.idle;
}
}
}
@@ -36,6 +36,17 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
}
}
/// The active Watch Together session, or null when not in one (or the
/// provider is unavailable).
WatchTogetherProvider? _activeWatchTogetherSession() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
return watchTogether.isInSession ? watchTogether : null;
} catch (_) {
return null;
}
}
/// Check if episode navigation controls should be enabled
/// Returns true if not in Watch Together session, or if user is the host
bool _canNavigateEpisodes() {
@@ -75,8 +86,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
}
}
/// Handle media switch from host (guest only)
/// Uses VideoPlayerScreen's context for proper navigation (pushReplacement)
/// Handle media switch from host (guest only) using the in-place reload path.
Future<void> _handlePlayerMediaSwitch(String ratingKey, ServerId serverId, String title) async {
if (!mounted) return;
@@ -102,12 +112,22 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
return;
}
// Detach and dispose current player before switching to avoid sync calls on a disposed instance
_isReplacingWithVideo = true;
await disposePlayerForNavigation();
if (!mounted) return;
if (player == null || widget.isLive) {
unawaited(_replaceScreenWithPlayer(metadata));
return;
}
// Use same navigation as local episode change (pushReplacement from player context)
unawaited(navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true));
final handled = await _reloadMediaInPlace(
metadata: metadata,
selectedMediaIndex: await savedMediaVersionIndexFor(metadata) ?? 0,
selectedMediaSourceId: null,
qualityPreset: _selectedQualityPreset,
preserveCurrentTrackSelection: false,
useCurrentAudioStreamSelection: false,
reason: 'watch together media switch',
);
if (!handled && mounted && player == null) {
unawaited(_replaceScreenWithPlayer(metadata));
}
}
}
+145 -193
View File
@@ -17,6 +17,8 @@ import '../mpv/player/platform/player_android.dart';
import '../services/scrub_preview_source.dart';
import '../media/media_backend.dart';
import '../media/media_display_criteria.dart';
import '../media/media_server_user_profile.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_server_client.dart';
@@ -27,8 +29,6 @@ import '../services/plex_client.dart';
import '../utils/session_identifier.dart';
import '../database/app_database.dart';
import '../media/media_version.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/transcode_quality_preset.dart';
import '../media/media_source_info.dart';
import '../mixins/mounted_set_state_mixin.dart';
@@ -48,6 +48,7 @@ import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart';
import '../services/playback_session.dart';
import '../services/playback_progress_tracker.dart';
import '../services/playback_source_resolver.dart';
import '../services/offline_watch_sync_service.dart';
@@ -74,6 +75,10 @@ import '../utils/platform_detector.dart';
import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import 'video_player/completion_latch.dart';
import 'video_player/frame_rate_matcher.dart';
import 'video_player/live_tv_session_args.dart';
import 'video_player/live_tv_session_state.dart';
import 'video_player/widgets/player_prompt_overlays.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/video_controls/player_chrome_controller.dart';
@@ -96,6 +101,7 @@ part 'video_player/parts/live_tv.dart';
part 'video_player/parts/media_controls.dart';
part 'video_player/parts/pip.dart';
part 'video_player/parts/shader.dart';
part 'video_player/parts/playback_open.dart';
part 'video_player/parts/playback_prompts.dart';
part 'video_player/parts/playback_services.dart';
part 'video_player/parts/playback_start.dart';
@@ -120,6 +126,25 @@ Future<void> _setWakelock(bool enabled) async {
}
}
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
/// transition is in flight.
enum _PlaybackTransition { idle, reloadingMedia, restartingTranscode, switchingChannel }
/// Handle for one playback attempt (initial start, in-place reload,
/// transcode restart). Async continuations check [isCurrent] after every
/// await: it holds while the screen is mounted, the captured player is
/// still the active one, and no newer attempt has bumped the generation.
class _PlaybackAttempt {
_PlaybackAttempt._(this._owner, this.generation, this.player);
final VideoPlayerScreenState _owner;
final int generation;
final Player player;
bool get isCurrent => _owner._isCurrentPlaybackGeneration(generation, player);
}
class _PlaybackOpenTiming {
final Duration? mediaStart;
final Duration timelineOffset;
@@ -194,29 +219,11 @@ class VideoPlayerScreen extends StatefulWidget {
/// Plex audio track (fallback: first).
final int? selectedAudioStreamId;
/// Session identifiers forwarded across quality/version/audio switches so
/// the server-side transcode session is preserved.
final String? reusedSessionIdentifier;
final String? reusedTranscodeSessionId;
/// Present iff this screen plays live TV; carries the whole live launch
/// state (see [LiveTvSessionArgs]).
final LiveTvSessionArgs? live;
// Live TV fields
final bool isLive;
final String? liveChannelName;
final String? liveStreamUrl;
final List<LiveTvChannel>? liveChannels;
final int? liveCurrentChannelIndex;
final String? liveDvrKey;
/// Backend-neutral client typing. The four in-player live ops branch on
/// `client is PlexClient` / `client is JellyfinClient` at their use sites:
/// Plex tunes a transcode session and gets capture-buffer updates;
/// Jellyfin uses its `/Sessions/Playing*` endpoints for progress reporting
/// and re-opens [liveStreamUrl] for retry. Tune (Plex-only by protocol)
/// and seek (Plex-only — Jellyfin live channels aren't seekable) gate
/// explicitly on `client is PlexClient`.
final MediaServerClient? liveClient;
final String? liveSessionIdentifier;
final String? liveSessionPath;
bool get isLive => live != null;
const VideoPlayerScreen({
super.key,
@@ -229,17 +236,7 @@ class VideoPlayerScreen extends StatefulWidget {
this.isOffline = false,
this.selectedQualityPreset,
this.selectedAudioStreamId,
this.reusedSessionIdentifier,
this.reusedTranscodeSessionId,
this.isLive = false,
this.liveChannelName,
this.liveStreamUrl,
this.liveChannels,
this.liveCurrentChannelIndex,
this.liveDvrKey,
this.liveClient,
this.liveSessionIdentifier,
this.liveSessionPath,
this.live,
});
@override
@@ -264,31 +261,38 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
MediaItem? _previousEpisode;
bool _isLoadingNext = false;
bool _isLoadingPrevious = false;
bool _isSwappingEpisode = false;
// In-flight media-source transition. At most one can run at a time: the
// entry guards make reload / transcode-restart / channel-switch mutually
// exclusive instead of relying on three independent booleans.
_PlaybackTransition _playbackTransition = _PlaybackTransition.idle;
bool _showPlayNextDialog = false;
bool _isPhone = false;
List<MediaVersion> _availableVersions = [];
MediaSourceInfo? _currentMediaInfo;
late int _effectiveSelectedMediaIndex;
String? _selectedMediaSourceId;
bool get _offlineLibraryMode => widget.isOffline;
// Transcode / quality state
late TranscodeQualityPreset _selectedQualityPreset;
int? _selectedAudioStreamId;
bool _isTranscoding = false;
bool _effectiveIsOffline = false;
AudioTrack? _preferredAudioTrack;
SubtitleTrack? _preferredSubtitleTrack;
SubtitleTrack? _preferredSecondarySubtitleTrack;
bool _serverSupportsTranscoding = false;
// Kicked off early in `_initializePlayer` for online non-live playback so
// the metadata fetch (and transcode-decision HTTP, if non-original preset)
// overlaps with MPV property configuration. Awaited inside `_startPlayback`
// immediately before `player.open()` needs the video URL.
Future<PlaybackContext>? _playbackDataFuture;
PlaybackContext? _playbackContext;
// The item currently loaded in the player: resolver output + effective
// selections, swapped atomically by [_commitPlaybackSession]. Null until
// the first resolve lands and always null for live TV (which tunes
// through its own path). The getters below denormalize it for the many
// existing read sites.
PlaybackSession? _playbackSession;
int _playbackGeneration = 0;
// HTTP headers attached to the player's `Media` request — `X-Plex-Token`
// for Plex, empty for Jellyfin (token rides in the URL there). Sourced
// from `MediaServerClient.streamHeaders` so the player code path stays
// backend-neutral.
Map<String, String>? _streamHeaders;
// Fired in parallel with MPV setup so the OS audio-focus negotiation
// (~90ms on Android) doesn't sit on the critical path. Awaited before
// `player.open()` so the semantics are unchanged — we just eat the cost
@@ -296,8 +300,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void>? _audioFocusFuture;
late final String _playbackSessionIdentifier;
late String _playbackTranscodeSessionId;
String? _playbackPlaySessionId;
String? _playbackPlayMethod;
StreamSubscription<PlayerError>? _errorSubscription;
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _completedSubscription;
@@ -307,7 +309,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _playbackRestartSubscription;
StreamSubscription<void>? _backendSwitchedSubscription;
bool _isRestartingTranscodeSeek = false;
TrackManager? _trackManager;
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<void>? _sleepTimerSubscription;
@@ -316,35 +317,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<double>? _mediaControlsRateSubscription;
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
bool _isReplacingWithVideo = false;
bool _isDisposingForNavigation = false;
bool _isHandlingBack = false;
/// Set just before this screen replaces itself with another player route
/// (the fallback pushReplacement paths). Dispose then skips the app-level
/// player-exit side effects because the replacement continues the session.
bool _isReplacingWithVideo = false;
ScrubPreviewSource? _scrubPreviewSource;
int _liveChannelIndex = -1;
String? _liveChannelName;
MediaServerClient? _liveClient;
String? _liveDvrKey;
String? _liveStreamUrl;
String? _liveItemId;
String? _liveSessionIdentifier;
String? _liveSessionPath;
Timer? _liveTimelineTimer;
int _liveTimelineGeneration = 0;
DateTime? _livePlaybackStartTime;
String? _liveProgramId;
int? _liveDurationMs;
// Jellyfin live TV heartbeat state machine. The Plex live branch keeps
// its bespoke capture-buffer flow inline; this tracker only collapses
// the Jellyfin started/progress/stopped transition.
JellyfinLiveSessionTracker _jellyfinLiveSession = JellyfinLiveSessionTracker();
CaptureBuffer? _captureBuffer;
int? _programBeginsAt;
double _streamStartEpoch = 0;
bool _isAtLiveEdge = true;
String? _transcodeSessionId;
/// Live TV session state (tune identity, heartbeats, capture buffer,
/// retry ladder) — inert for VOD screens. See [LiveTvSessionState].
late final LiveTvSessionState _live = LiveTvSessionState(widget.live, itemId: widget.metadata.id);
/// Coalesces rapid relative live-TV skips into a single transcode re-open so
/// mashing skip-forward can't compound into an overshoot to live (#1253).
@@ -357,21 +340,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
onChanged: _onLiveSeekTargetChanged,
);
/// Fallback level for live TV stream errors (mirrors Plex web client behavior).
/// 0 = directStream+directStreamAudio, 1 = no directStream, 2 = no DS + no DS audio.
int _liveStreamFallbackLevel = 0;
bool _isRetryingLiveStream = false;
Timer? _autoPlayTimer;
int _autoPlayCountdown = 5;
bool _completionTriggered = false;
// End-of-video Play Next thresholds. Fire the prompt within _kPlayNextTriggerMs
// of the end; re-arm (allow it to fire again) only once playback is more than
// _kPlayNextRearmMs from the end. The gap is hysteresis so a position parked at
// the boundary can't oscillate between firing and re-arming.
static const int _kPlayNextTriggerMs = 1000;
static const int _kPlayNextRearmMs = 2000;
// End-of-video Play Next latch. Fires within 1s of the end; re-arms only
// once playback is more than 2s from the end — the gap is hysteresis so a
// position parked at the boundary can't oscillate (see CompletionLatch).
final CompletionLatch _completionLatch = CompletionLatch(triggerWindowMs: 1000, rearmWindowMs: 2000);
late final FocusNode _playNextCancelFocusNode;
late final FocusNode _playNextConfirmFocusNode;
@@ -405,7 +380,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _androidAutoPipTransitionInFlight = false;
bool _pipFiltersPrepared = false;
VoidCallback? _autoPipEnteringCallback;
bool _resumeLiveTimelineOnResume = false;
int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value();
String _playerBackendLabel = 'unknown';
@@ -456,17 +430,49 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return manager.getClient(ServerId(id));
}
// Denormalized views over the committed [PlaybackSession]. Read sites
// keep their historical names; live TV (no session) gets the defaults.
PlaybackContext? get _playbackContext => _playbackSession?.context;
bool get _isTranscoding => _playbackSession?.isTranscoding ?? false;
bool get _effectiveIsOffline => _playbackSession?.isOffline ?? false;
String? get _playbackPlaySessionId => _playbackSession?.playSessionId;
String? get _playbackPlayMethod => _playbackSession?.playMethod;
List<MediaVersion> get _availableVersions => _playbackSession?.availableVersions ?? const [];
MediaSourceInfo? get _currentMediaInfo => _playbackSession?.mediaInfo;
bool get _usesLocalPlaybackSource => _effectiveIsOffline;
bool get _isOfflinePlayback => widget.isOffline || _effectiveIsOffline;
bool get _isOfflinePlayback => _offlineLibraryMode || _effectiveIsOffline;
/// Atomically publish a freshly opened [PlaybackSession] and refine the
/// selection-intent fields from what the backend actually delivered
/// (clamped version index, active audio stream, post-fallback preset).
///
/// Reload-style flows call this from the open boundary: a failure before
/// the commit leaves the previous session — and everything derived from
/// it — untouched, so there is nothing to roll back.
void _commitPlaybackSession(PlaybackSession session) {
_playbackSession = session;
_effectiveSelectedMediaIndex = session.mediaIndex;
_selectedMediaSourceId = session.mediaSourceId;
_selectedQualityPreset = session.qualityPreset;
_selectedAudioStreamId = session.audioStreamId;
}
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
int _beginPlaybackGeneration({bool isEpisodeSwap = false}) {
if (!isEpisodeSwap) _isSwappingEpisode = false;
int _beginPlaybackGeneration({bool isMediaReload = false}) {
if (!isMediaReload) _playbackTransition = _PlaybackTransition.idle;
return ++_playbackGeneration;
}
/// Start a new playback attempt: bumps the generation and captures the
/// owning player so async continuations can check [_PlaybackAttempt.isCurrent]
/// uniformly instead of threading (generation, player) pairs around.
_PlaybackAttempt _beginPlaybackAttempt(Player currentPlayer, {bool isMediaReload = false}) {
return _PlaybackAttempt._(this, _beginPlaybackGeneration(isMediaReload: isMediaReload), currentPlayer);
}
bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) {
return mounted && player == currentPlayer && _playbackGeneration == generation;
}
@@ -484,27 +490,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_activeId = widget.metadata.id;
_activeMediaIndex = widget.selectedMediaIndex;
_effectiveSelectedMediaIndex = widget.selectedMediaIndex;
_selectedMediaSourceId = widget.selectedMediaSourceId;
// Reused across quality/version/audio switches so the server-side
// transcode session is preserved.
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier();
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier();
// Reused across in-place quality/version/audio switches so the
// server-side transcode session is preserved.
_playbackSessionIdentifier = generateSessionIdentifier();
_playbackTranscodeSessionId = generateSessionIdentifier();
_selectedAudioStreamId = widget.selectedAudioStreamId;
_effectiveIsOffline = false;
_preferredAudioTrack = widget.preferredAudioTrack;
_preferredSubtitleTrack = widget.preferredSubtitleTrack;
_preferredSecondarySubtitleTrack = widget.preferredSecondarySubtitleTrack;
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
_liveChannelName = widget.liveChannelName;
_liveClient = widget.liveClient;
_liveDvrKey = widget.liveDvrKey;
_liveStreamUrl = widget.liveStreamUrl;
_liveItemId = widget.metadata.id;
_liveSessionIdentifier = widget.liveSessionIdentifier;
_liveSessionPath = widget.liveSessionPath;
if (widget.liveClient is JellyfinClient && widget.liveSessionIdentifier != null) {
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: widget.liveSessionIdentifier);
}
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
@@ -516,16 +513,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
_screenFocusNode.addListener(_onScreenFocusChanged);
appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}');
if (widget.preferredAudioTrack != null) {
appLogger.d('VideoPlayerScreen initialized for: ${_currentMetadata.title}');
if (_preferredAudioTrack != null) {
appLogger.d(
'Preferred audio track: ${widget.preferredAudioTrack!.title ?? widget.preferredAudioTrack!.id} (${widget.preferredAudioTrack!.language ?? "unknown"})',
'Preferred audio track: ${_preferredAudioTrack!.title ?? _preferredAudioTrack!.id} (${_preferredAudioTrack!.language ?? "unknown"})',
);
}
if (widget.preferredSubtitleTrack != null) {
final subtitleDesc = widget.preferredSubtitleTrack!.id == "no"
if (_preferredSubtitleTrack != null) {
final subtitleDesc = _preferredSubtitleTrack!.id == "no"
? "OFF"
: "${widget.preferredSubtitleTrack!.title ?? widget.preferredSubtitleTrack!.id} (${widget.preferredSubtitleTrack!.language ?? "unknown"})";
: "${_preferredSubtitleTrack!.title ?? _preferredSubtitleTrack!.id} (${_preferredSubtitleTrack!.language ?? "unknown"})";
appLogger.d('Preferred subtitle track: $subtitleDesc');
}
@@ -541,7 +538,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// synthetic ids tracked in the provider). For genuine standalone
// playback (continue-watching, direct episode tap with no queue
// launcher) clear any stale queue so prev/next stays consistent.
final meta = widget.metadata;
final meta = _currentMetadata;
if (playbackState.isItemInActiveQueue(meta)) {
playbackState.setCurrentItem(meta);
} else {
@@ -659,7 +656,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// no async gaps invalidate it before the calls below read it.
// Skipped for live TV (has its own tune path) and offline (its own
// branch in _startPlayback).
if (!widget.isLive && !widget.isOffline && mounted) {
if (!widget.isLive && !_offlineLibraryMode && mounted) {
// Backend-neutral lookup so Jellyfin items also flow through here.
// Plex-specific transcoder caching is gated on capabilities below;
// Jellyfin's `streamHeaders` is empty because it embeds api_key in
@@ -668,7 +665,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (genericClient == null) {
throw StateError('No client registered for ${_currentMetadata.serverId}');
}
_streamHeaders = genericClient.streamHeaders;
// Single source of truth for showing quality controls and applying the
// saved startup quality. Backends that cannot transcode always start at
// Original even if the user picked a lower default quality.
@@ -686,8 +682,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
);
_playbackDataFuture = playbackResolver.resolve(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _selectedMediaSourceId,
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
@@ -912,7 +908,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!done) {
final durMs = currentPlayer.state.duration.inMilliseconds;
final posMs = currentPlayer.state.position.inMilliseconds;
if (durMs <= 0 || posMs < durMs - _kPlayNextRearmMs) {
if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) {
_rearmCompletionLatch();
}
}
@@ -938,7 +934,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// When server comes back online while buffering, force mpv to reconnect
// immediately instead of waiting for ffmpeg's exponential backoff
if (!_isOfflinePlayback && !widget.isLive) {
final serverId = widget.metadata.serverId;
final serverId = _currentMetadata.serverId;
if (serverId != null) {
if (!mounted) return;
final serverManager = context.read<MultiServerProvider>().serverManager;
@@ -959,7 +955,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!mounted || player != currentPlayer) return;
_lastLogError = null;
_sawServer500 = false;
_liveStreamFallbackLevel = 0;
_live.fallbackLevel = 0;
if (!_hasFirstFrame.value) {
_hasFirstFrame.value = true;
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
@@ -1000,17 +996,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
final duration = activePlayer.state.duration;
if (duration.inMilliseconds > 0) {
if (position.inMilliseconds >= duration.inMilliseconds - _kPlayNextTriggerMs &&
!_showPlayNextDialog &&
!_completionTriggered) {
final signal = _completionLatch.classifyPosition(
positionMs: position.inMilliseconds,
durationMs: duration.inMilliseconds,
promptVisible: _showPlayNextDialog,
countdownActive: _autoPlayTimer?.isActive == true,
);
if (signal == CompletionLatchSignal.completed) {
_onVideoCompleted(true);
} else if (position.inMilliseconds < duration.inMilliseconds - _kPlayNextRearmMs) {
// Seeked back out of the end region after dismissing Play Next — re-arm
// so the prompt can fire again if the user returns to the end.
_rearmCompletionLatch();
}
}
// CompletionLatchSignal.rearmed needs no action here: the latch
// re-armed itself once playback seeked back out of the end region.
});
// Services init must finish before first frame so Discord / Trakt /
@@ -1041,14 +1037,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Windows display mode matching service.
DisplayModeService? _displayModeService;
/// Apply frame rate matching on Android by setting the display refresh rate
/// to match the video content's frame rate.
int _frameRateRetries = 0;
bool _suppressMediaPauseDuringFrameRateSwitch = false;
// True once a frame-rate switch has been requested for the current playback
// session — either via the pre-playback primary path (Plex metadata fps) or
// via the post-`playbackRestart` fallback. Prevents double-switching.
bool _frameRateMatchingApplied = false;
/// Android display frame-rate matching state (retry counter, applied
/// latch, MediaSession pause-suppression window) — see [FrameRateMatcher].
final FrameRateMatcher _frameRate = FrameRateMatcher();
/// Handle back button press
/// For non-host participants in Watch Together, shows leave session confirmation
@@ -1130,10 +1121,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_cleanupCompanionRemoteCallbacks();
// Notify Watch Together guests that host is exiting the player
// Use stored reference since context.read() may fail in dispose
// Skip if replacing with another video (episode navigation)
if (!_isReplacingWithVideo &&
// Notify Watch Together guests that host is exiting the player.
// Use stored reference since context.read() may fail in dispose.
final isReplacingWithVideo = _isReplacingWithVideo;
if (!isReplacingWithVideo &&
_watchTogetherProvider != null &&
_watchTogetherProvider!.isHost &&
_watchTogetherProvider!.isInSession) {
@@ -1167,8 +1158,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_scrubPreviewSource?.dispose();
// Mark sleep timer for restart if truly exiting (not episode transition)
if (!_isReplacingWithVideo) {
if (!isReplacingWithVideo) {
SleepTimerService().markNeedsRestart();
}
@@ -1216,7 +1206,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (Platform.isWindows && _displayModeService != null) {
FullscreenStateManager().removeListener(_onFullscreenChanged);
}
if (!_isReplacingWithVideo &&
if (!isReplacingWithVideo &&
Platform.isWindows &&
_displayModeService != null &&
_displayModeService!.anyChangeApplied) {
@@ -1228,15 +1218,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Clear frame rate matching and abandon audio focus before disposing player (Android only)
if (Platform.isAndroid && player != null) {
// Native dispose deliberately leaves the display mode for Dart to clear
// (ExoPlayerCore.releasePending) — skip it during a player→player
// replacement, the Android analog of preserveDisplayMode below.
if (!isReplacingWithVideo) {
player!.clearVideoFrameRate();
}
player!.abandonAudioFocus();
}
unawaited(_setWakelock(false));
appLogger.d('Wakelock disabled');
// Restore system UI and orientation preferences (skip if navigating to another video)
if (!_isReplacingWithVideo) {
if (!isReplacingWithVideo) {
unawaited(_restoreSystemUiAndOrientation());
}
@@ -1244,7 +1238,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final playerToDispose = player;
player = null;
if (playerToDispose != null) {
unawaited(playerToDispose.dispose());
// Keep the native display mode (tvOS HDMI criteria) across a
// player→player handoff; the replacement screen primes its own.
unawaited(playerToDispose.dispose(preserveDisplayMode: isReplacingWithVideo));
}
if (_activeId == _currentMetadata.id) {
_activeId = null;
@@ -1332,8 +1328,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _setPlayerState(VoidCallback fn) => setStateIfMounted(fn);
bool _isSwitchingChannel = false;
/// Wait briefly for profile settings to load in offline mode.
/// This prevents default-track fallback when playback starts before
/// UserProfileProvider finishes initialization.
@@ -1365,17 +1359,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
/// Set flag to skip orientation restoration when replacing with another video
void setReplacingWithVideo() {
_isReplacingWithVideo = true;
}
/// Session identifiers owned by this screen, forwarded to a replacement
/// [VideoPlayerScreen] during quality/version/audio switches so the Plex
/// transcode session is continued rather than restarted.
String get playbackSessionIdentifier => _playbackSessionIdentifier;
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
final tracker = _progressTracker;
if (tracker == null) return Future<void>.value();
@@ -1385,37 +1368,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
});
}
/// Dispose the player before replacing the video to avoid race conditions
Future<void> disposePlayerForNavigation() async {
if (_isDisposingForNavigation) return;
_isDisposingForNavigation = true;
_isExiting.value = true; // Show black overlay during transition
try {
_detachFromWatchTogetherSession();
await _sendStoppedProgressOnce();
_progressTracker?.stopTracking();
_detachPipStateListener();
_videoPIPManager?.onBeforeEnterPip = null;
unawaited(_videoPIPManager?.disableAutoPip());
_clearAutoPipEnteringCallback();
// Clear frame rate matching before disposing (Android only)
await _clearFrameRateMatching();
// Restore Windows display mode before disposing
if (!_isReplacingWithVideo) {
await _restoreWindowsDisplayMode();
}
await _positionSubscription?.cancel();
_positionSubscription = null;
await player?.dispose();
} catch (e) {
appLogger.d('Error disposing player before navigation', error: e);
} finally {
player = null;
_isPlayerInitialized = false;
}
}
@override
Widget build(BuildContext context) {
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
@@ -7,12 +7,10 @@ import 'package:provider/provider.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/play_queue.dart';
import '../mpv/mpv.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/multi_server_manager.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
/// Result of loading adjacent episodes
class AdjacentEpisodes {
@@ -146,50 +144,6 @@ class EpisodeNavigationService {
appLogger.d('Local episode queue (${allEpisodes.length} episodes, anchor: $anchorIdx)');
}
/// Navigate to the next or previous episode
///
/// Preserves the current audio track, subtitle track, and playback rate
/// selections when transitioning between episodes.
Future<void> navigateToEpisode({
required BuildContext context,
required MediaItem episode,
required Player? player,
bool usePushReplacement = true,
}) async {
if (!context.mounted) return;
// Capture current player state before navigation
AudioTrack? currentAudioTrack;
SubtitleTrack? currentSubtitleTrack;
SubtitleTrack? currentSecondarySubtitleTrack;
double? currentPlaybackRate;
if (player != null) {
currentAudioTrack = player.state.track.audio;
currentSubtitleTrack = player.state.track.subtitle;
currentSecondarySubtitleTrack = player.state.track.secondarySubtitle;
currentPlaybackRate = player.state.rate;
appLogger.d(
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack?.id}, Subtitle: ${currentSubtitleTrack?.id}, Rate: ${currentPlaybackRate}x',
);
}
// Navigate to the new episode
if (context.mounted) {
unawaited(
navigateToVideoPlayer(
context,
metadata: episode,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
usePushReplacement: usePushReplacement,
),
);
}
}
/// LRU-touching read: re-inserts the entry so it becomes the most recent.
/// Returns null on miss.
List<MediaItem>? _readSeriesCache(String seriesId) {
+76
View File
@@ -0,0 +1,76 @@
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../media/media_source_info.dart';
import '../media/media_version.dart';
import '../models/transcode_quality_preset.dart';
import 'playback_context.dart';
import 'playback_initialization_types.dart';
/// Immutable snapshot of everything that describes the item currently loaded
/// in the player: the resolver output plus the effective (post-fallback,
/// post-clamping) source selections.
///
/// Built once per resolve and swapped atomically on the screen — failure
/// before the swap means the previous session (and the state derived from
/// it) stays untouched, which replaces the old field-by-field
/// snapshot/rollback bookkeeping.
class PlaybackSession {
final PlaybackContext context;
/// Effective quality preset — the requested preset, downgraded to
/// original when the backend reported a transcode fallback.
final TranscodeQualityPreset qualityPreset;
/// Effective media version id, refined from the resolver's clamped
/// version index when the version list provides one.
final String? mediaSourceId;
const PlaybackSession({required this.context, required this.qualityPreset, this.mediaSourceId});
/// Derives the effective selections from a resolved [context]:
/// quality falls back to original when the backend rejected the requested
/// preset, and the media source id follows the clamped version index.
factory PlaybackSession.fromContext(
PlaybackContext context, {
required TranscodeQualityPreset requestedQualityPreset,
String? requestedMediaSourceId,
}) {
final result = context.result;
final fellBackToOriginal = result.fallbackReason != null && !requestedQualityPreset.isOriginal;
return PlaybackSession(
context: context,
qualityPreset: fellBackToOriginal ? TranscodeQualityPreset.original : requestedQualityPreset,
mediaSourceId:
mediaSourceIdForIndex(result.availableVersions, result.selectedMediaIndex) ?? requestedMediaSourceId,
);
}
static String? mediaSourceIdForIndex(List<MediaVersion> versions, int index) {
if (index < 0 || index >= versions.length) return null;
return versions[index].id;
}
PlaybackInitializationResult get result => context.result;
MediaItem get metadata => context.metadata;
MediaServerClient? get reportingClient => context.reportingClient;
bool get isTranscoding => result.isTranscoding;
bool get isOffline => result.isOffline;
String? get playSessionId => result.playSessionId;
String? get playMethod => result.playMethod;
int? get audioStreamId => result.activeAudioStreamId;
int get mediaIndex => result.selectedMediaIndex;
List<MediaVersion> get availableVersions => result.availableVersions;
MediaSourceInfo? get mediaInfo => result.mediaInfo;
Map<String, String>? get streamHeaders => context.streamHeaders;
}
+5 -1
View File
@@ -14,6 +14,9 @@ class PlaybackSourceResolver {
const PlaybackSourceResolver({required this.serverManager, required this.database});
/// [preferOffline] overrides the default downloaded-copy preference
/// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for
/// flows that must stay on the server stream, e.g. a transcode restart.
Future<PlaybackContext> resolve({
required MediaItem metadata,
required int selectedMediaIndex,
@@ -23,6 +26,7 @@ class PlaybackSourceResolver {
int? selectedAudioStreamId,
String? sessionIdentifier,
String? transcodeSessionId,
bool? preferOffline,
}) async {
final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode);
final service = PlaybackInitializationService(client: reportingClient, database: database);
@@ -30,7 +34,7 @@ class PlaybackSourceResolver {
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferOffline: offlineLibraryMode || qualityPreset.isOriginal,
preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal),
qualityPreset: qualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
+5 -13
View File
@@ -4,9 +4,7 @@ import 'package:flutter/material.dart';
import 'package:rate_limiter/rate_limiter.dart';
import '../mpv/mpv.dart';
import '../mpv/player/platform/player_android.dart';
import '../media/media_version.dart';
import '../utils/app_logger.dart';
import 'ambient_lighting_service.dart';
@@ -24,8 +22,6 @@ class VideoFilterManager {
static const double zoomStep = 0.01;
final Player player;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
/// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch)
int _boxFitMode;
@@ -56,8 +52,6 @@ class VideoFilterManager {
VideoFilterManager({
required this.player,
required this.availableVersions,
required this.selectedMediaIndex,
int initialBoxFitMode = 0,
Size? initialPlayerSize,
this.onBoxFitModeChanged,
@@ -176,13 +170,11 @@ class VideoFilterManager {
/// When ambient lighting is active, video-aspect-override is managed by ambient lighting.
Future<void> updateVideoFilter() async {
try {
// ExoPlayer handles scaling via AspectRatioFrameLayout. The MPV properties
// below still run — on PlayerAndroid they forward to setMpvProperty, which
// queues them for any future fallback to MPV.
if (player is PlayerAndroid) {
await (player as PlayerAndroid).setBoxFitMode(_boxFitMode);
await (player as PlayerAndroid).setVideoZoom(_zoomScale);
}
// ExoPlayer handles scaling via AspectRatioFrameLayout (no-op on mpv
// backends). The MPV properties below still run — on ExoPlayer they
// forward to setMpvProperty, which queues them for any future fallback.
await player.setBoxFitMode(_boxFitMode);
await player.setVideoZoom(_zoomScale);
if (ambientLightingService?.isEnabled != true) {
await player.setProperty('video-aspect-override', 'no');
+9 -9
View File
@@ -9,6 +9,7 @@ import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../models/livetv_channel.dart';
import '../providers/multi_server_provider.dart';
import '../screens/video_player/live_tv_session_args.dart';
import '../screens/video_player_screen.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
@@ -63,16 +64,15 @@ Future<void> navigateToLiveTv(
settings: const RouteSettings(name: kVideoPlayerRouteName),
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: placeholder,
isLive: true,
liveChannelName: channel.displayName,
liveStreamUrl: liveStreamUrl,
liveChannels: channels,
liveCurrentChannelIndex: channels?.indexWhere(
(ch) => liveTvChannelScopeKey(ch) == liveTvChannelScopeKey(channel),
live: LiveTvSessionArgs(
channelName: channel.displayName,
streamUrl: liveStreamUrl,
channels: channels,
currentChannelIndex: channels?.indexWhere((ch) => liveTvChannelScopeKey(ch) == liveTvChannelScopeKey(channel)),
dvrKey: dvrKey,
client: liveClient,
sessionIdentifier: liveSessionIdentifier,
),
liveDvrKey: dvrKey,
liveClient: liveClient,
liveSessionIdentifier: liveSessionIdentifier,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
+26 -11
View File
@@ -85,6 +85,31 @@ class WatchTogetherPlaybackNavigationException implements Exception {
String toString() => message;
}
/// Series (keyed by grandparent) or standalone-item key under
/// [SettingsService.mediaVersionPreferences].
String _mediaVersionPreferenceKey(MediaItem metadata) => metadata.grandparentId ?? metadata.id;
/// Saved media-version preference for [metadata], or null when none is
/// stored. Shared by launch navigation and in-player version switching so
/// reads and writes can't drift onto different keys.
Future<int?> savedMediaVersionIndexFor(MediaItem metadata) async {
try {
final settingsService = await SettingsService.getInstance();
return settingsService.read(SettingsService.mediaVersionPreferences)[_mediaVersionPreferenceKey(metadata)];
} catch (_) {
return null;
}
}
/// Persist [index] as the preferred media version for [metadata]'s series/movie.
Future<void> saveMediaVersionIndexFor(MediaItem metadata, int index) async {
final settingsService = await SettingsService.getInstance();
await settingsService.write(SettingsService.mediaVersionPreferences, {
...settingsService.read(SettingsService.mediaVersionPreferences),
_mediaVersionPreferenceKey(metadata): index,
});
}
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
///
/// This utility function provides a consistent way to navigate to the video player
@@ -128,17 +153,7 @@ Future<bool?> navigateToVideoPlayer(
? manager.getClient(serverId)
: null;
int mediaIndex = selectedMediaIndex ?? 0;
if (selectedMediaIndex == null) {
try {
final settingsService = await SettingsService.getInstance();
final seriesKey = metadata.grandparentId ?? metadata.id;
final savedPreference = settingsService.read(SettingsService.mediaVersionPreferences)[seriesKey];
if (savedPreference != null) {
mediaIndex = savedPreference;
}
} catch (_) {}
}
final mediaIndex = selectedMediaIndex ?? await savedMediaVersionIndexFor(metadata) ?? 0;
var markedInFlight = false;
if (!usePushReplacement) {
@@ -135,93 +135,24 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
}
}
/// Switch version, quality preset, or audio stream ID. Any combination may
/// change in one invocation; unspecified values retain their current value.
/// Always routes through pushReplacement, preserving playback position and
/// the transcode session identifiers.
/// Request a version, quality preset, audio stream, or source subtitle reload.
/// The owning player screen decides how to apply it so controls do not own
/// player lifecycle/navigation policy.
Future<void> _switchVersionAndQuality({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
}) async {
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
final effectiveSubtitleStreamId = newSubtitleStreamId ?? widget.selectedSubtitleStreamId;
final effectiveMediaSourceId = effectiveMediaIndex >= 0 && effectiveMediaIndex < widget.availableVersions.length
? widget.availableVersions[effectiveMediaIndex].id
: widget.selectedMediaSourceId;
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId;
final isSubtitleChange =
newSubtitleStreamId != null && effectiveSubtitleStreamId != widget.selectedSubtitleStreamId;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
return;
}
final onPlaybackSourceChanged = widget.onPlaybackSourceChanged;
if (onPlaybackSourceChanged == null) return;
try {
final currentPosition = widget.player.state.position;
// Get state reference before async operations
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
if (isVersionChange) {
final settingsService = await SettingsService.getInstance();
final seriesKey = widget.metadata.grandparentId ?? widget.metadata.id;
await settingsService.write(SettingsService.mediaVersionPreferences, {
...settingsService.read(SettingsService.mediaVersionPreferences),
seriesKey: effectiveMediaIndex,
});
}
if (isSubtitleChange) {
final serverId = widget.metadata.serverId;
final partId = widget.sourcePartId;
if (serverId == null || partId == null || effectiveSubtitleStreamId == null) {
throw StateError('No Plex part available for subtitle stream selection');
}
final client = context.getPlexClientForServer(ServerId(serverId));
final saved = await client.selectStreams(partId, subtitleStreamID: effectiveSubtitleStreamId, allParts: true);
if (!saved) {
throw StateError('Failed to select subtitle stream');
}
}
// Preserve session identifiers across the reload so Plex reuses the
// transcode session rather than spinning up a new one.
final sessionId = videoPlayerState?.playbackSessionIdentifier;
final transcodeSessionId = videoPlayerState?.playbackTranscodeSessionId;
// Set flag on parent VideoPlayerScreen to skip orientation restoration
videoPlayerState?.setReplacingWithVideo();
// Dispose the existing player before spinning up the replacement to avoid race conditions
await videoPlayerState?.disposePlayerForNavigation();
// Navigate to new player screen with the updated selection
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
if (mounted) {
unawaited(
Navigator.pushReplacement(
context,
PageRouteBuilder<bool>(
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds),
selectedMediaIndex: effectiveMediaIndex,
selectedMediaSourceId: effectiveMediaSourceId,
selectedQualityPreset: effectivePreset,
selectedAudioStreamId: effectiveAudioStreamId,
reusedSessionIdentifier: sessionId,
reusedTranscodeSessionId: transcodeSessionId,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
),
await onPlaybackSourceChanged(
newMediaIndex: newMediaIndex,
newPreset: newPreset,
newAudioStreamId: newAudioStreamId,
newSubtitleStreamId: newSubtitleStreamId,
);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
@@ -4,10 +4,15 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState {
Future<void> _loadPlaybackExtras({bool forceRefresh = false}) async {
// Live TV metadata uses EPG rating keys, not library items
if (widget.isLive) return;
if (_isLoadingExtras) return;
final loadKey = widget.metadata.globalKey;
// Re-entrancy guard is per item: an in-place episode swap may start the
// new item's load while the old item's is still in flight.
if (_isLoadingExtras && _extrasLoadKey == loadKey) return;
_isLoadingExtras = true;
_extrasLoadKey = loadKey;
final serverId = widget.metadata.serverId;
final metadata = widget.metadata;
final serverId = metadata.serverId;
// Read providers before any await — `context` after an async gap is
// a lint trigger and can crash if the widget unmounts mid-load.
final client = serverId != null ? context.tryGetMediaClientForServer(ServerId(serverId)) : null;
@@ -15,13 +20,16 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState {
try {
final extras = await VideoControlsPlaybackExtrasLoader(
metadata: widget.metadata,
metadata: metadata,
database: database,
client: client,
).load(forceRefresh: forceRefresh);
if (extras != null) _applyPlaybackExtras(extras);
// Discard stale responses — the item may have swapped mid-flight.
if (extras != null && mounted && widget.metadata.globalKey == loadKey) {
_applyPlaybackExtras(extras);
}
} finally {
_isLoadingExtras = false;
if (_extrasLoadKey == loadKey) _isLoadingExtras = false;
}
}
@@ -297,6 +297,9 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
void _requestFocusTarget(PlayerChromeFocusTarget target) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.chromeController.controlsVisible) return;
// Never steal focus from an open sheet (same rule as
// _reclaimFocusAfterControlsHide).
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) return;
switch (target) {
case PlayerChromeFocusTarget.playPause:
_desktopControlsKey.currentState?.requestPlayPauseFocus();
+27 -2
View File
@@ -156,6 +156,14 @@ bool shouldShowSkipMarkerButton({
return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible);
}
typedef PlaybackSourceChangeCallback =
Future<void> Function({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
});
class PlexVideoControls extends StatefulWidget {
final Player player;
final MediaItem metadata;
@@ -163,7 +171,6 @@ class PlexVideoControls extends StatefulWidget {
final VoidCallback? onPrevious;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
@@ -173,6 +180,7 @@ class PlexVideoControls extends StatefulWidget {
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final int? sourcePartId;
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
final int boxFitMode;
final double videoZoomScale;
final VoidCallback? onTogglePIPMode;
@@ -270,7 +278,6 @@ class PlexVideoControls extends StatefulWidget {
this.onPrevious,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
@@ -280,6 +287,7 @@ class PlexVideoControls extends StatefulWidget {
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.sourcePartId,
this.onPlaybackSourceChanged,
this.boxFitMode = 0,
this.videoZoomScale = 1.0,
this.onTogglePIPMode,
@@ -328,6 +336,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
late bool _lastControlsVisible;
bool _isLoadingExtras = false;
// Item key the in-flight extras load belongs to, so a load for a swapped
// item can start while a stale one is still in flight (and the stale
// response is discarded).
String? _extrasLoadKey;
List<MediaChapter> _chapters = [];
bool _chaptersLoaded = false;
bool _isFullscreen = false;
@@ -502,6 +514,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_lastControlsVisible = widget.chromeController.controlsVisible;
widget.chromeController.addListener(_onChromeChanged);
}
// The same controls instance survives in-place episode swaps — re-key
// the per-item chapters/markers/skip state when the item changes.
// (Quality/version switches keep the same item, so no refetch churn.)
if (oldWidget.metadata.globalKey != widget.metadata.globalKey) {
_setControlsState(() {
_chapters = [];
_chaptersLoaded = false;
_markers = [];
_markersLoaded = false;
});
_clearCurrentMarker();
_loadPlaybackExtras();
}
_configureChromeController();
}
@@ -4,7 +4,6 @@ import 'dart:io' show Platform, ProcessInfo;
import 'package:flutter/scheduler.dart';
import '../../../../mpv/mpv.dart';
import '../../../../mpv/player/platform/player_android.dart';
import '../../../../utils/app_logger.dart';
import 'performance_stats.dart';
@@ -50,13 +49,12 @@ class PerformanceStatsService {
void startPolling() {
_pollingTimer?.cancel();
// Listen for backend switches on Android (ExoPlayer -> MPV fallback)
if (player is PlayerAndroid) {
// Listen for backend switches (only the Android ExoPlayer -> MPV
// fallback ever emits; the stream is silent elsewhere).
_backendSwitchedSubscription?.cancel();
_backendSwitchedSubscription = player.streams.backendSwitched.listen((_) {
_updateRuntimePlayerType();
});
}
// Start FPS tracking
_startFpsTracking();
@@ -67,12 +65,8 @@ class PerformanceStatsService {
/// Update the runtime player type by querying the native layer.
Future<void> _updateRuntimePlayerType() async {
if (player is PlayerAndroid) {
_runtimePlayerType = await (player as PlayerAndroid).getPlayerType();
_runtimePlayerType = await player.runtimePlayerType();
appLogger.d('Performance stats: runtime player type updated to $_runtimePlayerType');
} else {
_runtimePlayerType = 'mpv'; // Non-Android always uses MPV
}
}
/// Start tracking UI frame rate.
@@ -115,12 +109,12 @@ class PerformanceStatsService {
await _updateRuntimePlayerType();
}
if (player is PlayerAndroid) {
// For Android (ExoPlayer or MPV fallback), always use getStats()
// The native side returns appropriate stats based on which backend is active
if (player.providesNativeStats) {
// Android (ExoPlayer or MPV fallback): the native side returns
// appropriate stats based on which backend is active.
await _fetchAndroidStats();
} else {
// For non-Android platforms, use MPV property queries
// For mpv-channel players, use MPV property queries
await _fetchMpvStats();
}
} catch (e) {
@@ -131,8 +125,7 @@ class PerformanceStatsService {
/// Fetch stats from Android player (ExoPlayer or MPV fallback).
/// The native side returns appropriate stats based on the active backend.
Future<void> _fetchAndroidStats() async {
final androidPlayer = player as PlayerAndroid;
final statsMap = await androidPlayer.getStats();
final statsMap = await player.getStats();
final playerType = statsMap['playerType'] as String? ?? 'unknown';
// Get app memory usage
+3 -24
View File
@@ -284,31 +284,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String
else {
result(
FlutterError(
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
details: nil))
return
}
guard let core = playerCore else {
result(nil)
return
}
core.setPropertyAsync(name, value: value) { [weak self] _ in
if name == "pause" {
func didSetPauseProperty(value: String) {
let isPlaying = value == "no"
self?.pipController?.setPlaying(isPlaying)
core.setPaused(!isPlaying)
}
result(nil)
}
pipController?.setPlaying(isPlaying)
playerCore?.setPaused(!isPlaying)
}
// MARK: - Helpers
+43 -2
View File
@@ -72,6 +72,12 @@ struct ServerDisplayCriteria {
let gamma: String?
let primaries: String?
let colorMatrix: String?
/// Whether the server metadata carried actual color/DoVi information
/// only then may the prime lock out mpv-derived color updates.
var hasColorInfo: Bool {
doviProfile > 0 || gamma != nil || primaries != nil || colorMatrix != nil
}
}
class MpvPlayerCoreBase: NSObject {
@@ -97,6 +103,7 @@ class MpvPlayerCoreBase: NSObject {
private var cachedVideoPrimaries: String?
private var cachedVideoColorMatrix: String?
private var serverDisplayCriteriaActive = false
private var serverCriteriaLocksColor = false
private var lastServerCriteria: ServerDisplayCriteria?
private var cachedDvConversionMode = "auto"
private var cachedDvConversionLogEnabled = false
@@ -196,12 +203,42 @@ class MpvPlayerCoreBase: NSObject {
colorMatrix: String?
) -> Bool { false }
/// Whether the mpv-derived caches indicate an HDR/DV source mirrors the
/// Dart-side MediaDisplayCriteria.isHdr tag check. Call under cacheLock.
private static func looksHdr(
doviProfile: Int64, sigPeak: Double, gamma: String?, primaries: String?, colorMatrix: String?
) -> Bool {
if doviProfile > 0 || sigPeak > 1 { return true }
let tags = [gamma, primaries, colorMatrix]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
.replacingOccurrences(of: "[^a-z0-9]", with: "", options: .regularExpression)
return ["hlg", "arib", "pq", "smpte2084", "st2084", "bt2020"].contains { tags.contains($0) }
}
func scheduleDisplayCriteriaUpdate() {
cacheLock.lock()
if serverDisplayCriteriaActive {
// A color-bearing server prime owns the display mode for the item. An
// fps-only prime is just an early hint: demote it once the decoded
// stream proves HDR/DV so the real color tags reach the display,
// otherwise keep suppressing redundant SDR re-applies.
if serverCriteriaLocksColor
|| !Self.looksHdr(
doviProfile: cachedDoviProfile,
sigPeak: cachedLastSigPeak,
gamma: cachedVideoGamma,
primaries: cachedVideoPrimaries,
colorMatrix: cachedVideoColorMatrix
)
{
cacheLock.unlock()
return
}
serverDisplayCriteriaActive = false
serverCriteriaLocksColor = false
lastServerCriteria = nil
}
let profile = cachedDoviProfile
let level = cachedDoviLevel
let fps = cachedContainerFps
@@ -229,16 +266,17 @@ class MpvPlayerCoreBase: NSObject {
}
}
func setServerDisplayCriteria(_ criteria: ServerDisplayCriteria?) {
func setServerDisplayCriteria(_ criteria: ServerDisplayCriteria?, completion: ((Bool) -> Void)? = nil) {
cacheLock.lock()
serverDisplayCriteriaActive = criteria != nil
serverCriteriaLocksColor = criteria?.hasColorInfo ?? false
lastServerCriteria = criteria
cacheLock.unlock()
let apply = { [weak self] in
guard let self else { return }
guard let criteria else {
_ = self.updateDisplayCriteria(
let applied = self.updateDisplayCriteria(
doviProfile: 0,
doviLevel: 0,
doviCompatibilityId: nil,
@@ -250,6 +288,7 @@ class MpvPlayerCoreBase: NSObject {
primaries: nil,
colorMatrix: nil
)
completion?(applied)
return
}
@@ -268,9 +307,11 @@ class MpvPlayerCoreBase: NSObject {
if !applied {
self.cacheLock.lock()
self.serverDisplayCriteriaActive = false
self.serverCriteriaLocksColor = false
self.cacheLock.unlock()
self.scheduleDisplayCriteriaUpdate()
}
completion?(applied)
}
if Thread.isMainThread {
@@ -14,10 +14,40 @@ protocol MpvPluginShared: AnyObject, MpvPlayerDelegate {
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool)
func updatePlayerFrame()
/// Invoked after the `pause` property is applied via setProperty so each
/// platform can sync its PiP/idle bookkeeping (iOS: invalidate the PiP
/// playback state + timebase; macOS: setPlaying/setPaused).
func didSetPauseProperty(value: String)
}
extension MpvPluginShared {
func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String
else {
result(
FlutterError(
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
details: nil))
return
}
guard let core = coreBase else {
result(nil)
return
}
core.setPropertyAsync(name, value: value) { [weak self] _ in
if name == "pause" {
self?.didSetPauseProperty(value: value)
}
result(nil)
}
}
func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_display_criteria.dart';
void main() {
group('MediaDisplayCriteria', () {
test('can prime native display criteria from frame rate and dimensions', () {
const criteria = MediaDisplayCriteria(fps: 23.976, width: 1920, height: 1080);
expect(criteria.canPrimeNativeDisplayCriteria, isTrue);
});
test('cannot prime native display criteria without dimensions', () {
const criteria = MediaDisplayCriteria(fps: 23.976);
expect(criteria.canPrimeNativeDisplayCriteria, isFalse);
});
});
}
+96
View File
@@ -245,6 +245,74 @@ void main() {
);
});
test('MPV open(play: true) unpauses after loadfile even when previously paused', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
// Simulate the in-place reload: the old file is paused before the
// replacement opens. mpv's pause property survives loadfile.
await player.pause();
await player.open(Media('https://example.test/next.mkv'));
final loadIndex = _loadfileCallIndex(calls);
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(loadIndex, greaterThanOrEqualTo(0));
expect(unpauseIndex, greaterThan(loadIndex), reason: 'open(play: true) must clear pause after loadfile');
} finally {
await player.dispose();
}
},
);
});
test('MPV open(play: false) opens paused and never unpauses', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
},
testBody: () async {
final player = PlayerNative();
try {
await player.open(Media('https://example.test/next.mkv'), play: false);
final loadIndex = _loadfileCallIndex(calls);
final pauseIndex = _setPropertyCallIndex(calls, 'pause');
final unpauseIndex = _setPropertyValueIndex(calls, 'pause', 'no');
expect(pauseIndex, greaterThanOrEqualTo(0));
expect(pauseIndex, lessThan(loadIndex));
expect(_setPropertyValue(calls[pauseIndex]), 'yes');
expect(unpauseIndex, -1, reason: 'a paused open must stay paused');
} finally {
await player.dispose();
}
},
);
});
test('MPV maps server-offset streams to absolute timeline positions', () async {
final calls = <MethodCall>[];
@@ -327,6 +395,28 @@ void main() {
},
);
});
test('MPV forwards preserve display mode flag on dispose', () async {
final calls = <MethodCall>[];
await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
await player.dispose(preserveDisplayMode: true);
final disposeCall = calls.singleWhere((call) => call.method == 'dispose');
final args = Map<Object?, Object?>.from(disposeCall.arguments as Map);
expect(args['preserveDisplayMode'], isTrue);
},
);
});
});
}
@@ -380,6 +470,12 @@ int _setPropertyCallIndex(List<MethodCall> calls, String name) {
return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name);
}
int _setPropertyValueIndex(List<MethodCall> calls, String name, String value) {
return calls.indexWhere(
(call) => call.method == 'setProperty' && _setPropertyName(call) == name && _setPropertyValue(call) == value,
);
}
String? _setPropertyName(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['name'] as String?;
String? _setPropertyValue(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['value'] as String?;
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/mpv/player/player_base.dart';
import 'package:plezy/mpv/player/player_native.dart';
/// Guards the channel contract: every property [PlayerBase.handlePropertyChange]
/// depends on for core state must be registered by each backend at init.
/// The Android ExoPlayer plugin replays exactly these registrations into a
/// fallback MPV core, so a missing registration here silently breaks the
/// event stream after a backend switch.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const coreNames = {
'time-pos',
'duration',
'seekable',
'pause',
'paused-for-cache',
'eof-reached',
'volume',
'speed',
'aid',
'sid',
'track-list',
};
Future<List<MethodCall>> capturedObservations({
required String channelName,
required Future<void> Function() initialize,
required Future<void> Function() dispose,
}) async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final methodChannel = MethodChannel(channelName);
final observations = <MethodCall>[];
messenger.setMockMethodCallHandler(methodChannel, (call) async {
if (call.method == 'observeProperty') observations.add(call);
if (call.method == 'initialize') return true;
return null;
});
try {
await initialize();
} finally {
await dispose();
messenger.setMockMethodCallHandler(methodChannel, null);
}
return observations;
}
Set<String> names(List<MethodCall> calls) => calls.map((c) => (c.arguments as Map)['name'] as String).toSet();
test('the shared core table covers every state-critical property', () {
final tableNames = PlayerBase.corePropertyObservations.map((e) => e.$1).toSet()..add('track-list');
expect(tableNames, coreNames);
});
test('ExoPlayer registers the core properties (plus its cache extra)', () async {
final player = PlayerAndroid();
final observations = await capturedObservations(
channelName: 'com.plezy/exo_player',
initialize: () => player.requestAudioFocus(), // forces _ensureInitialized
dispose: () => player.dispose(),
);
final registered = names(observations);
expect(registered, containsAll(coreNames));
expect(registered, contains('demuxer-cache-time'));
for (final call in observations) {
final args = call.arguments as Map;
expect(args['format'], isNotNull);
expect(args['id'], isA<int>());
}
});
test('mpv registers the core properties (plus its track/device extras)', () async {
final player = PlayerNative();
final observations = await capturedObservations(
channelName: 'com.plezy/mpv_player',
initialize: () => player.setLogLevel('warn'), // forces _ensureInitialized
dispose: () => player.dispose(),
);
final registered = names(observations);
expect(registered, containsAll(coreNames));
expect(registered, containsAll({'secondary-sid', 'demuxer-cache-state', 'audio-device-list', 'audio-device'}));
});
}
@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
void main() {
CompletionLatch latch() => CompletionLatch(triggerWindowMs: 1000, rearmWindowMs: 2000);
CompletionLatchSignal tick(
CompletionLatch l,
int positionMs, {
int durationMs = 60000,
bool promptVisible = false,
bool countdownActive = false,
}) {
return l.classifyPosition(
positionMs: positionMs,
durationMs: durationMs,
promptVisible: promptVisible,
countdownActive: countdownActive,
);
}
test('signals completed once inside the trigger window, then stays quiet while latched', () {
final l = latch();
expect(tick(l, 58000), CompletionLatchSignal.none);
expect(tick(l, 59200), CompletionLatchSignal.completed);
// The handler latches on success; until then ticks keep retrying.
expect(tick(l, 59300), CompletionLatchSignal.completed);
l.latch();
expect(tick(l, 59400), CompletionLatchSignal.none);
});
test('does not fire while a prompt is visible', () {
final l = latch();
expect(tick(l, 59500, promptVisible: true), CompletionLatchSignal.none);
});
test('ignores ticks with no known duration', () {
final l = latch();
expect(tick(l, 59500, durationMs: 0), CompletionLatchSignal.none);
});
test('re-arms only after moving back past the rearm window', () {
final l = latch();
l.latch();
// Inside the hysteresis gap (between trigger and rearm windows): no flap.
expect(tick(l, 58500), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
// Clearly out of the end region: re-armed.
expect(tick(l, 50000), CompletionLatchSignal.rearmed);
expect(l.triggered, isFalse);
// Returning to the end can fire again.
expect(tick(l, 59500), CompletionLatchSignal.completed);
});
test('refuses to re-arm while a prompt or countdown is active', () {
final l = latch();
l.latch();
expect(tick(l, 50000, promptVisible: true), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
expect(tick(l, 50000, countdownActive: true), CompletionLatchSignal.none);
expect(l.triggered, isTrue);
expect(tick(l, 50000), CompletionLatchSignal.rearmed);
});
test('reset clears unconditionally', () {
final l = latch();
l.latch();
l.reset();
expect(l.triggered, isFalse);
});
test('rearmIfClear honors prompt/countdown directly', () {
final l = latch();
l.latch();
l.rearmIfClear(promptVisible: true, countdownActive: false);
expect(l.triggered, isTrue);
l.rearmIfClear(promptVisible: false, countdownActive: false);
expect(l.triggered, isFalse);
});
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_context.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_session.dart';
PlaybackContext _context(PlaybackInitializationResult result) {
return PlaybackContext(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
result: result,
sourceKind: result.usesLocalMedia ? PlaybackSourceKind.localFile : PlaybackSourceKind.remoteDirect,
reportingMode: PlaybackReportingMode.online,
streamHeaders: const {'X-Test': 'token'},
);
}
void main() {
group('PlaybackSession.fromContext', () {
test('keeps the requested preset when no fallback occurred', () {
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: const [], videoUrl: 'u')),
requestedQualityPreset: TranscodeQualityPreset.p1080_8mbps,
);
expect(session.qualityPreset, TranscodeQualityPreset.p1080_8mbps);
});
test('falls back to original when the backend rejected the preset', () {
final session = PlaybackSession.fromContext(
_context(
PlaybackInitializationResult(
availableVersions: const [],
videoUrl: 'u',
fallbackReason: TranscodeFallbackReason.values.first,
),
),
requestedQualityPreset: TranscodeQualityPreset.p1080_8mbps,
);
expect(session.qualityPreset, TranscodeQualityPreset.original);
});
test('an original-quality request ignores the fallback reason', () {
final session = PlaybackSession.fromContext(
_context(
PlaybackInitializationResult(
availableVersions: const [],
videoUrl: 'u',
fallbackReason: TranscodeFallbackReason.values.first,
),
),
requestedQualityPreset: TranscodeQualityPreset.original,
);
expect(session.qualityPreset, TranscodeQualityPreset.original);
});
test('refines the media source id from the clamped version index', () {
final versions = [MediaVersion(id: 'v0'), MediaVersion(id: 'v1')];
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: versions, videoUrl: 'u', selectedMediaIndex: 1)),
requestedQualityPreset: TranscodeQualityPreset.original,
requestedMediaSourceId: 'requested',
);
expect(session.mediaSourceId, 'v1');
expect(session.mediaIndex, 1);
});
test('keeps the requested source id when the index is out of range', () {
final session = PlaybackSession.fromContext(
_context(PlaybackInitializationResult(availableVersions: const [], videoUrl: 'u', selectedMediaIndex: 2)),
requestedQualityPreset: TranscodeQualityPreset.original,
requestedMediaSourceId: 'requested',
);
expect(session.mediaSourceId, 'requested');
});
});
test('forwarding getters mirror the resolver output', () {
final result = PlaybackInitializationResult(
availableVersions: [MediaVersion(id: 'v0')],
videoUrl: 'u',
isTranscoding: true,
playSessionId: 'psid',
playMethod: 'Transcode',
activeAudioStreamId: 7,
);
final session = PlaybackSession.fromContext(
_context(result),
requestedQualityPreset: TranscodeQualityPreset.original,
);
expect(session.isTranscoding, isTrue);
expect(session.isOffline, isFalse);
expect(session.playSessionId, 'psid');
expect(session.playMethod, 'Transcode');
expect(session.audioStreamId, 7);
expect(session.availableVersions, hasLength(1));
expect(session.streamHeaders, containsPair('X-Test', 'token'));
expect(session.metadata.id, 'item-1');
});
}
+12 -10
View File
@@ -6,7 +6,7 @@ import 'package:plezy/services/video_filter_manager.dart';
void main() {
test('zoom scale snaps to whole percentages', () {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
expect(manager.setZoomScale(1.234), 1.23);
@@ -18,7 +18,7 @@ void main() {
test('zoom scale snaps near 100 percent to exact default', () {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
manager.setZoomScale(1.5);
@@ -30,7 +30,7 @@ void main() {
test('video zoom property is exact zero at normalized default', () async {
final player = _RecordingPlayer();
final manager = VideoFilterManager(player: player, availableVersions: const [], selectedMediaIndex: 0);
final manager = VideoFilterManager(player: player);
addTearDown(manager.dispose);
expect(VideoFilterManager.videoZoomPropertyForScale(1.00008), 0.0);
@@ -48,13 +48,7 @@ void main() {
test('stretch mode applies the initial player size before a resize event', () async {
final player = _RecordingPlayer();
final manager = VideoFilterManager(
player: player,
availableVersions: const [],
selectedMediaIndex: 0,
initialBoxFitMode: 2,
initialPlayerSize: const Size(1920, 1080),
);
final manager = VideoFilterManager(player: player, initialBoxFitMode: 2, initialPlayerSize: const Size(1920, 1080));
addTearDown(manager.dispose);
await manager.updateVideoFilter();
@@ -73,6 +67,14 @@ class _RecordingPlayer implements Player {
writes.add(MapEntry(name, value));
}
@override
// ignore: no-empty-block - native-layer call, irrelevant to property recording
Future<void> setBoxFitMode(int mode) async {}
@override
// ignore: no-empty-block - native-layer call, irrelevant to property recording
Future<void> setVideoZoom(double scale) async {}
@override
PlayerState get state => const PlayerState();
@@ -144,6 +144,54 @@ void main() {
await player.dispose();
await peerService.close();
});
test('media-switch attachment cycle re-announces readiness and re-arms the initial-play gate', () async {
final peerService = _FakeWatchTogetherPeerService(peerId: 'host');
final player = _FakePlayer(playing: false, position: const Duration(minutes: 3));
final manager = _hostManager(peerService);
final deferredStates = <bool>[];
manager.onDeferredPlayChanged = deferredStates.add;
manager.initializeParticipants(['host', 'guest']);
manager.attachPlayer(player);
peerService.emit(SyncMessage.playerReady(peerId: 'guest', ready: true));
await _settle();
await player.emitPlaying(true);
expect(deferredStates, isNot(contains(true)));
await player.emitPlaying(false);
peerService.broadcasts.clear();
// In-place media switch: the reload cycles the attachment exactly like
// the provider does (re-initialize participants, then re-attach).
manager.detachPlayer();
expect(
peerService.broadcasts.where(
(m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == false,
),
isNotEmpty,
);
manager.initializeParticipants(['host', 'guest']);
manager.attachPlayer(player);
// The already-loaded (non-buffering) player re-announces ready for the
// new item on attach.
expect(
peerService.broadcasts.where(
(m) => m.type == SyncMessageType.playerReady && m.peerId == 'host' && m.bufferingState == true,
),
isNotEmpty,
);
// First play after the switch defers again until the guest is ready.
await player.emitPlaying(true);
expect(deferredStates, contains(true));
expect(player.state.playing, isFalse);
manager.dispose();
await player.dispose();
await peerService.close();
});
});
}
@@ -278,7 +326,7 @@ class _FakePlayer implements Player {
bool get disposed => _disposed;
@override
Future<void> dispose() async {
Future<void> dispose({bool preserveDisplayMode = false}) async {
_disposed = true;
await _playingController.close();
await _bufferingController.close();