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