diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 59ec90c6..fcd18bda 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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() + + // 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() + private val mainHandler = Handler(Looper.getMainLooper()) private var configuredBufferSizeBytes: Int? = null private var sessionGeneration = 0 private var debugLoggingEnabled: Boolean = false - private val pendingMpvProperties = mutableListOf>() + + // 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() // 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("name") val id = call.argument("id") + val format = call.argument("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?, + 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() + 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?, @@ -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() - 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 diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 8a3cf14f..d0dd968b 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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. - updateDisplayCriteria( - doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil, - fps: 0, width: 0, height: 0, sigPeak: 0, - gamma: nil, primaries: nil, colorMatrix: nil) + // 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 { diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index 9f7d135b..ac0f08d8 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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,10 +360,12 @@ 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) - result(nil) + core.setServerDisplayCriteriaForPlayback(nil, extraDelayMs: extraDelayMs) { + result(nil) + } } return } @@ -398,8 +382,9 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS colorMatrix: stringValue(raw["matrix"]) ) DispatchQueue.main.async { - core.setServerDisplayCriteria(criteria) - result(nil) + core.setServerDisplayCriteriaForPlayback(criteria, extraDelayMs: extraDelayMs) { + result(nil) + } } } diff --git a/lib/media/media_display_criteria.dart b/lib/media/media_display_criteria.dart index 4531e642..026bc436 100644 --- a/lib/media/media_display_criteria.dart +++ b/lib/media/media_display_criteria.dart @@ -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; diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 171b8342..963ed8d7 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -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> getStats() async { if (disposed) return {}; try { @@ -303,7 +319,8 @@ class PlayerAndroid extends PlayerBase { } } - Future getPlayerType() async { + @override + Future runtimePlayerType() async { if (disposed) return 'unknown'; try { final result = await invoke('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 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 setBoxFitMode(int mode) async { if (disposed || !initialized) return; await invoke('setBoxFitMode', {'mode': mode}); } /// Apply custom zoom to the native ExoPlayer layer. + @override Future setVideoZoom(double scale) async { if (disposed || !initialized) return; await invoke('setVideoZoom', {'scale': scale}); diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 1a19fcc2..0fa19c3b 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -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 setDisplayCriteria(MediaDisplayCriteria? criteria); + /// + /// [extraDelayMs] is added after a native display-switch completion event, + /// for TVs or AVRs that need extra HDMI settle time. + Future 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 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 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 setBoxFitMode(int mode); + + /// Apply custom zoom to the native video layer. No-op on mpv backends, + /// which zoom via the `video-zoom` property. + Future 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> 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 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 dispose(); + Future dispose({bool preserveDisplayMode = false}); /// Creates a new player instance. /// diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index e98083d3..8741e720 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -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 observeCoreProperties({required String trackListFormat}) async { + for (final (name, format) in corePropertyObservations) { + await observeProperty(name, format); + } + await observeProperty('track-list', trackListFormat); + } + @protected Future observeProperty(String name, String format) async { final propId = _nextPropId++; @@ -543,7 +572,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } @override - Future setDisplayCriteria(MediaDisplayCriteria? criteria) async {} + Future setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {} @override Future 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 clearVideoFrameRate() async {} + @override + // ignore: no-empty-block - base no-op, ExoPlayer styles subtitles natively + Future 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 setBoxFitMode(int mode) async {} + + @override + // ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property + Future setVideoZoom(double scale) async {} + + @override + Future> getStats() async => const {}; + + @override + Future runtimePlayerType() async => playerType; + @override Future 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 selectSecondarySubtitleTrack(SubtitleTrack track) async {} @@ -669,13 +738,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } @override - Future dispose() async { + Future 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(); } } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index ee1b5e7f..59ac780e 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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 setDisplayCriteria(MediaDisplayCriteria? criteria) async { + bool get needsDecoderRefreshAfterDisplaySwitch => Platform.isAndroid; + + @override + Future 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 diff --git a/lib/mpv/player/player_state.dart b/lib/mpv/player/player_state.dart index 1dfc14fb..51201a62 100644 --- a/lib/mpv/player/player_state.dart +++ b/lib/mpv/player/player_state.dart @@ -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 audioDevices; final List 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? audioDevices, List? 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, diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 28d3c15d..d32bbd30 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -284,6 +284,7 @@ class _PlaybackSettingsScreenState extends State { 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)); diff --git a/lib/screens/video_player/completion_latch.dart b/lib/screens/video_player/completion_latch.dart new file mode 100644 index 00000000..056f9404 --- /dev/null +++ b/lib/screens/video_player/completion_latch.dart @@ -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; + } +} diff --git a/lib/screens/video_player/frame_rate_matcher.dart b/lib/screens/video_player/frame_rate_matcher.dart new file mode 100644 index 00000000..2195929c --- /dev/null +++ b/lib/screens/video_player/frame_rate_matcher.dart @@ -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; + } +} diff --git a/lib/screens/video_player/live_tv_session_args.dart b/lib/screens/video_player/live_tv_session_args.dart new file mode 100644 index 00000000..e4018357 --- /dev/null +++ b/lib/screens/video_player/live_tv_session_args.dart @@ -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? 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, + }); +} diff --git a/lib/screens/video_player/live_tv_session_state.dart b/lib/screens/video_player/live_tv_session_state.dart new file mode 100644 index 00000000..6f9e6aff --- /dev/null +++ b/lib/screens/video_player/live_tv_session_state.dart @@ -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; + } +} diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 95011090..99848cce 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -47,7 +47,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { }); } - int? _selectedSourceSubtitleStreamId(List tracks) { + int? _selectedSourceSubtitleStreamIdForControls(List 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 diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index a804e24d..5651d9da 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -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; } diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index ab2eaa63..35d45795 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -3,7 +3,7 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { Future _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 _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 _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 _applyWindowsDisplayMatching() async { if (player == null || _displayModeService == null) return; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index d89f61cd..f9c8777a 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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 _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. - Future _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, - ), - ); - } - return; - } - - // Capture current state atomically to avoid race conditions - final currentPlayer = player; - if (currentPlayer == null) { - if (mounted) { - unawaited( - navigateToVideoPlayer( - context, - metadata: episodeMetadata, - usePushReplacement: true, - isOffline: widget.isOffline, - ), - ); - } - 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, - ), + /// 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 _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(); + } } } - /// 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 _swapEpisodeInPip(MediaItem episodeMetadata) async { - _isSwappingEpisode = true; - final currentPlayer = player!; - final playbackGeneration = _beginPlaybackGeneration(isEpisodeSwap: true); - final previousMetadata = _currentMetadata; + /// Navigates to a new episode by reusing the current player whenever possible. + Future _navigateToEpisode(MediaItem episodeMetadata) async { + if (player == null) { + if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata)); + return; + } - final currentAudioTrack = currentPlayer.state.track.audio; - final currentSubtitleTrack = currentPlayer.state.track.subtitle; - final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; + 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 _switchPlaybackSource({ + int? newMediaIndex, + TranscodeQualityPreset? newPreset, + int? newAudioStreamId, + int? newSubtitleStreamId, + }) async { + final currentPlayer = player; + 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'); + } + } + + 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())); + } + } + } + + /// 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 _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 attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true); + bool isCurrentReload() => attempt.isCurrent; + + // 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(); final database = context.read(); final serverManager = context.read().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) { - showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); - } - _selectedQualityPreset = TranscodeQualityPreset.original; + // 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); } - 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; - _currentMetadata = previousMetadata; - VideoPlayerScreenState._activeId = previousMetadata.id; - appLogger.e('Failed to swap episode in PiP', error: e); + 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; + 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); + } } } } diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index f254007f..c82535cf 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -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 _loadAdjacentEpisodes() async { + Future _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; diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index fcbc6a2b..7292ae68 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -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; } diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index f9ec1acc..e5c6fae1 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -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(); } } diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index eea5357b..91c60ad8 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -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 _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 _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 _setLiveStreamOptions() async { - await player!.setProperty('force-seekable', 'no'); - } + Future _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 _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 _jumpToLiveEdge() async { - if (_captureBuffer == null) return; - await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch); + if (_live.captureBuffer == null) return; + await _seekLiveToEpoch(_live.captureBuffer!.seekableEndEpoch); } Future _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; } diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index de58f248..9d3ec897 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -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(); } diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart new file mode 100644 index 00000000..42e9cc04 --- /dev/null +++ b/lib/screens/video_player/parts/playback_open.dart @@ -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? _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 _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 _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 _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 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 _releaseFrameRateStartupGate({ + required Player currentPlayer, + required SettingsService settingsService, + required _FrameRateStartupPlan plan, + required Future 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.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 _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 _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 _applyTracksAfterOpen({ + required Player forPlayer, + required TrackManager trackManager, + required List 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 _openMediaOnPlayer({ + required Player player, + required SettingsService settingsService, + required String videoUrl, + required bool isTranscoding, + required _PlaybackOpenTiming timing, + Map? headers, + required bool play, + List? 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; + } +} diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index b9858b00..04958c85 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -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() { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index acf6f26d..5162f664 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 34f01391..39e1abb0 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -4,71 +4,71 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { Future _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(); try { - PlaybackInitializationResult result; PlaybackContext playbackContext; - Map? streamHeaders; - if (widget.isOffline) { + if (_offlineLibraryMode) { final playbackResolver = PlaybackSourceResolver( serverManager: context.read().serverManager, database: context.read(), ); 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? androidMpvStartupReady; var audioFocusReady = false; Future 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)', - ); - 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; + final frameRatePlan = await _prepareFrameRateForOpen( + currentPlayer: currentPlayer, + settingsService: settingsService, + preKnownFps: displayCriteria?.fps, + hasVideoUrl: result.videoUrl != null, + ensureAudioFocus: ensureAudioFocus, + ); + 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); - 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; + final resumePosition = await _resolveOpenResumePosition( + metadata: _currentMetadata, + isOffline: _isOfflinePlayback, + offlineWatchService: offlineWatchService, + ); + if (!mounted || player != currentPlayer) return; // 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().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().profileSettings, + preferredAudioTrack: _preferredAudioTrack, + preferredSubtitleTrack: _preferredSubtitleTrack, + preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack, ); // Store external subtitles for re-use after backend fallback _trackManager!.cacheExternalSubtitles(result.externalSubtitles); - Future 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', - ), - ), - ); - } 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.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', - ), - ), - ); - } + await _releaseFrameRateStartupGate( + currentPlayer: currentPlayer, + settingsService: settingsService, + plan: frameRatePlan, + resumeAfterStartupGate: (reason) => _resumeAfterFrameRateStartupGate( + currentPlayer: currentPlayer, + attachesSubsAtOpen: attachesSubsAtOpen, + hasExternalSubs: hasExternalSubs, + reason: reason, + ), + ); } } on PlaybackException catch (e, st) { appLogger.w('Playback initialization failed', error: e, stackTrace: st); diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index d8b5f115..1adf6fde 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -39,91 +39,115 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { } Future _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(); + final playbackResolver = PlaybackSourceResolver( + serverManager: context.read().serverManager, + database: context.read(), + ); 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()); - 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; } } } diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index 5d5abead..519693c9 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -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(); + 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 _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)); + } } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a17b1b06..b88ed1f9 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 _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? 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 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 _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? _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? _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 with WidgetsBindin Future? _audioFocusFuture; late final String _playbackSessionIdentifier; late String _playbackTranscodeSessionId; - String? _playbackPlaySessionId; - String? _playbackPlayMethod; StreamSubscription? _errorSubscription; StreamSubscription? _playingSubscription; StreamSubscription? _completedSubscription; @@ -307,7 +309,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _positionSubscription; StreamSubscription? _playbackRestartSubscription; StreamSubscription? _backendSwitchedSubscription; - bool _isRestartingTranscodeSeek = false; TrackManager? _trackManager; StreamSubscription? _logSubscription; StreamSubscription? _sleepTimerSubscription; @@ -316,35 +317,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _mediaControlsRateSubscription; StreamSubscription? _mediaControlsSeekableSubscription; StreamSubscription>? _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 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 with WidgetsBindin bool _androidAutoPipTransitionInFlight = false; bool _pipFiltersPrepared = false; VoidCallback? _autoPipEnteringCallback; - bool _resumeLiveTimelineOnResume = false; int _rewindOnResume = 0; Future _lifecycleTransition = Future.value(); String _playerBackendLabel = 'unknown'; @@ -456,17 +430,49 @@ class VideoPlayerScreenState extends State 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 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 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 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 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 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 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 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 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 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().serverManager; @@ -959,7 +955,7 @@ class VideoPlayerScreenState extends State 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 with WidgetsBindin } final duration = activePlayer.state.duration; - if (duration.inMilliseconds > 0) { - if (position.inMilliseconds >= duration.inMilliseconds - _kPlayNextTriggerMs && - !_showPlayNextDialog && - !_completionTriggered) { - _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(); - } + final signal = _completionLatch.classifyPosition( + positionMs: position.inMilliseconds, + durationMs: duration.inMilliseconds, + promptVisible: _showPlayNextDialog, + countdownActive: _autoPlayTimer?.isActive == true, + ); + if (signal == CompletionLatchSignal.completed) { + _onVideoCompleted(true); } + // 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 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 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 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 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 with WidgetsBindin // Clear frame rate matching and abandon audio focus before disposing player (Android only) if (Platform.isAndroid && player != null) { - player!.clearVideoFrameRate(); + // 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 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 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 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 _sendStoppedProgressOnce({Duration? positionOverride}) { final tracker = _progressTracker; if (tracker == null) return Future.value(); @@ -1385,37 +1368,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); } - /// Dispose the player before replacing the video to avoid race conditions - Future 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; diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index 2e67c727..ca442057 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -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 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? _readSeriesCache(String seriesId) { diff --git a/lib/services/playback_session.dart b/lib/services/playback_session.dart new file mode 100644 index 00000000..2e42a8d7 --- /dev/null +++ b/lib/services/playback_session.dart @@ -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 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 get availableVersions => result.availableVersions; + + MediaSourceInfo? get mediaInfo => result.mediaInfo; + + Map? get streamHeaders => context.streamHeaders; +} diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index bca2dd3e..ac22a5e8 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -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 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, diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 82bda589..37f8bb93 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -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 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 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'); diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index 13db6fcc..2d9fc73a 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -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 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, diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 73877f3d..a6dc53db 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -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 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 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 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) { diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 3c0c6fe7..9fc37429 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -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 _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(); - - 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( - 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())); diff --git a/lib/widgets/video_controls/parts/playback_extras.dart b/lib/widgets/video_controls/parts/playback_extras.dart index 17bfbfb4..53d46ff1 100644 --- a/lib/widgets/video_controls/parts/playback_extras.dart +++ b/lib/widgets/video_controls/parts/playback_extras.dart @@ -4,10 +4,15 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState { Future _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; } } diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 28b0a62f..d58c968d 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -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(); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a2d85bc4..bdef0406 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -156,6 +156,14 @@ bool shouldShowSkipMarkerButton({ return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible); } +typedef PlaybackSourceChangeCallback = + Future 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 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 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 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 _chapters = []; bool _chaptersLoaded = false; bool _isFullscreen = false; @@ -502,6 +514,19 @@ class _PlexVideoControlsState extends State _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(); } diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index f9cc7686..9cd61abb 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -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) { - _backendSwitchedSubscription?.cancel(); - _backendSwitchedSubscription = player.streams.backendSwitched.listen((_) { - _updateRuntimePlayerType(); - }); - } + // 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 _updateRuntimePlayerType() async { - if (player is PlayerAndroid) { - _runtimePlayerType = await (player as PlayerAndroid).getPlayerType(); - appLogger.d('Performance stats: runtime player type updated to $_runtimePlayerType'); - } else { - _runtimePlayerType = 'mpv'; // Non-Android always uses MPV - } + _runtimePlayerType = await player.runtimePlayerType(); + appLogger.d('Performance stats: runtime player type updated to $_runtimePlayerType'); } /// 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 _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 diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index 69412ebd..cd469ddc 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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" { - let isPlaying = value == "no" - self?.pipController?.setPlaying(isPlaying) - core.setPaused(!isPlaying) - } - result(nil) - } + func didSetPauseProperty(value: String) { + let isPlaying = value == "no" + pipController?.setPlaying(isPlaying) + playerCore?.setPaused(!isPlaying) } // MARK: - Helpers diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index f7d2e541..736bc41e 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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,11 +203,41 @@ 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 { - cacheLock.unlock() - return + // 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 @@ -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 { diff --git a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift index 5e0ab9be..a60a5e63 100644 --- a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift +++ b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift @@ -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 diff --git a/test/media/media_display_criteria_test.dart b/test/media/media_display_criteria_test.dart new file mode 100644 index 00000000..d7784fe8 --- /dev/null +++ b/test/media/media_display_criteria_test.dart @@ -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); + }); + }); +} diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 880c3a5c..778b1e47 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -245,6 +245,74 @@ void main() { ); }); + test('MPV open(play: true) unpauses after loadfile even when previously paused', () async { + final calls = []; + + 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 = []; + + 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 = []; @@ -327,6 +395,28 @@ void main() { }, ); }); + + test('MPV forwards preserve display mode flag on dispose', () async { + final calls = []; + + 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.from(disposeCall.arguments as Map); + expect(args['preserveDisplayMode'], isTrue); + }, + ); + }); }); } @@ -380,6 +470,12 @@ int _setPropertyCallIndex(List calls, String name) { return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name); } +int _setPropertyValueIndex(List calls, String name, String value) { + return calls.indexWhere( + (call) => call.method == 'setProperty' && _setPropertyName(call) == name && _setPropertyValue(call) == value, + ); +} + String? _setPropertyName(MethodCall call) => Map.from(call.arguments as Map)['name'] as String?; String? _setPropertyValue(MethodCall call) => Map.from(call.arguments as Map)['value'] as String?; diff --git a/test/mpv/property_observation_test.dart b/test/mpv/property_observation_test.dart new file mode 100644 index 00000000..0ca73084 --- /dev/null +++ b/test/mpv/property_observation_test.dart @@ -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> capturedObservations({ + required String channelName, + required Future Function() initialize, + required Future Function() dispose, + }) async { + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final methodChannel = MethodChannel(channelName); + final observations = []; + + 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 names(List 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()); + } + }); + + 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'})); + }); +} diff --git a/test/screens/video_player/completion_latch_test.dart b/test/screens/video_player/completion_latch_test.dart new file mode 100644 index 00000000..37ceea9a --- /dev/null +++ b/test/screens/video_player/completion_latch_test.dart @@ -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); + }); +} diff --git a/test/services/playback_session_test.dart b/test/services/playback_session_test.dart new file mode 100644 index 00000000..7a6624ec --- /dev/null +++ b/test/services/playback_session_test.dart @@ -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'); + }); +} diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index 4c1ee62e..b392c55f 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -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 setBoxFitMode(int mode) async {} + + @override + // ignore: no-empty-block - native-layer call, irrelevant to property recording + Future setVideoZoom(double scale) async {} + @override PlayerState get state => const PlayerState(); diff --git a/test/watch_together/watch_together_sync_manager_test.dart b/test/watch_together/watch_together_sync_manager_test.dart index 2ee1f1a0..324f652b 100644 --- a/test/watch_together/watch_together_sync_manager_test.dart +++ b/test/watch_together/watch_together_sync_manager_test.dart @@ -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 = []; + 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 dispose() async { + Future dispose({bool preserveDisplayMode = false}) async { _disposed = true; await _playingController.close(); await _bufferingController.close();