Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ae3609f6 | ||
|
|
32f72c6dd2 | ||
|
|
8c5d0b4213 | ||
|
|
3dd5b5d48e | ||
|
|
b6aa83af74 | ||
|
|
4712414b3d | ||
|
|
89b326e3f3 | ||
|
|
cf0dd0607e | ||
|
|
9a44f062a6 | ||
|
|
178634e943 | ||
|
|
99f4fda55a | ||
|
|
908ccbb233 | ||
|
|
2b176861b7 | ||
|
|
647971a5d6 | ||
|
|
a18c54f5ce | ||
|
|
c5ed666aa9 | ||
|
|
6580c4cf97 | ||
|
|
3ca565baa9 | ||
|
|
bc1a715411 | ||
|
|
c20a6cf162 | ||
|
|
ab1c9b9afd | ||
|
|
9ef3de985f | ||
|
|
54438c8d7a | ||
|
|
1d5b175cc8 | ||
|
|
b80aa1916e | ||
|
|
5c40ba0505 | ||
|
|
cbc47790c3 | ||
|
|
baa31742cb | ||
|
|
01750ad92b | ||
|
|
5de2d28166 | ||
|
|
88e6d33221 | ||
|
|
ac7fb87ade | ||
|
|
ce3a3b177c | ||
|
|
b795ef1604 | ||
|
|
deeec6810d | ||
|
|
d27cb61a2b | ||
|
|
e660a06499 | ||
|
|
2fb04a14ad | ||
|
|
c228a12d1e | ||
|
|
d47bcdec12 | ||
|
|
bd9fb38413 | ||
|
|
dca8029a99 | ||
|
|
7aa1679036 | ||
|
|
9ae34473af | ||
|
|
3fb8794619 | ||
|
|
98c6a09b41 | ||
|
|
621b50141c | ||
|
|
02dc57bd55 | ||
|
|
38f00f51c4 | ||
|
|
77422f2e6e | ||
|
|
d2d73f35f8 | ||
|
|
d6f0aa8936 | ||
|
|
e35f6b3a64 | ||
|
|
80bc37eb34 | ||
|
|
0490de5b5b | ||
|
|
f738e74994 | ||
|
|
65af57dd93 | ||
|
|
bc483a8206 | ||
|
|
c0d090079b | ||
|
|
6147736bba | ||
|
|
7885bea016 | ||
|
|
05598f1708 | ||
|
|
dc06b2e181 | ||
|
|
dbf8bf54c5 | ||
|
|
36e898067a | ||
|
|
a475e92d7e | ||
|
|
55985b8f9c |
@@ -135,6 +135,11 @@ sudo moss it plezy
|
||||
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
|
||||
- Synchronized playback with friends
|
||||
- Real-time play / pause / seek sync
|
||||
- Host handoff when the relay and every connected peer support safe transfers
|
||||
- Automatic reconnect authenticates retained room membership; it never silently joins a reused room code.
|
||||
|
||||
Self-hosted relays must support `authenticatedResume` for recovery. Deploy the updated relay before updating clients.
|
||||
Older relays still accept explicit create/join, but failed recovery requires joining or creating a room again.
|
||||
|
||||
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
||||
- Discord Rich Presence[^desktop]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Exercises MPV's JNI reachability against the production-shrunk app with
|
||||
* `-Pplezy.testBuildType=minified`. Native initialization resolves all nine static
|
||||
* callback descriptors, so a stale or missing keep fails before initialization completes.
|
||||
*
|
||||
* Only calls the core APIs used by production. Referencing MpvPlayer's callbacks
|
||||
* directly here would let the harness mask missing retention in the app APK.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvPlayerReachabilityTest {
|
||||
@Test
|
||||
fun productionCoreInitializesDeliversNativeLogAndDisposes() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val initializationSucceeded = AtomicBoolean()
|
||||
val commandCompleted = CountDownLatch(1)
|
||||
val commandSucceeded = AtomicBoolean()
|
||||
val nativeLogReceived = CountDownLatch(1)
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
// Pass every constructor argument: production uses this constructor, not
|
||||
// the default-argument bridge that R8 may legitimately remove.
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, true, true, 1f, "v"))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message" && data?.get("level") == "info" && data["text"] == LOG_MARKER) {
|
||||
nativeLogReceived.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().initialize {
|
||||
initializationSucceeded.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertTrue("Native MPV initialization failed", initializationSucceeded.get())
|
||||
|
||||
// initialize completes after the production flow collectors subscribe.
|
||||
// This marker must cross the real native event thread and reach the delegate.
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().command(arrayOf("print-text", LOG_MARKER)) {
|
||||
commandSucceeded.set(it)
|
||||
commandCompleted.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(commandCompleted, "print-text")
|
||||
assertTrue("Native MPV print-text failed", commandSucceeded.get())
|
||||
assertCompletes(nativeLogReceived, "native log callback")
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "native teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
const val LOG_MARKER = "plezy-mpv-r8-native-log"
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Instrumentation
|
||||
import android.os.SystemClock
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvLogLevelDeviceTest {
|
||||
@Test
|
||||
fun normalLoggingPreservesErrorsAndCanToggleVerboseOffAgain() = withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "normal", expectInfo = false)
|
||||
setLogLevel(instrumentation, core, "v")
|
||||
assertLogPhase(instrumentation, core, logs, "verbose", expectInfo = true)
|
||||
setLogLevel(instrumentation, core, "warn")
|
||||
assertLogPhase(instrumentation, core, logs, "normal-again", expectInfo = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verboseLoggingCanBeSelectedBeforeNativeInitialization() = withCore(initialLogLevel = "v") { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "initial-verbose", expectInfo = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedInitialLogLevelDoesNotBlockTheNextPlayer() {
|
||||
withCore(initialLogLevel = "not-a-level", initializationSucceeds = false) { _, _, _ -> }
|
||||
withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "after-rejected-init", expectInfo = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun withCore(
|
||||
initialLogLevel: String = "warn",
|
||||
initializationSucceeds: Boolean = true,
|
||||
block: (Instrumentation, MpvPlayerCore, LinkedBlockingQueue<Pair<String, String>>) -> Unit
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val logs = LinkedBlockingQueue<Pair<String, String>>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, audioOnly = true, initialLogLevel = initialLogLevel))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message") {
|
||||
logs.add((data?.get("level") as? String ?: "") to (data?.get("text") as? String ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
core.get().initialize {
|
||||
success.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertEquals("Native MPV initialization result", initializationSucceeds, success.get())
|
||||
block(instrumentation, core.get(), logs)
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLogLevel(instrumentation: Instrumentation, core: MpvPlayerCore, level: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val result = AtomicReference<Result<Unit>>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.setLogLevel(level) {
|
||||
result.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, "setLogLevel($level)")
|
||||
result.get().getOrThrow()
|
||||
}
|
||||
|
||||
private fun command(instrumentation: Instrumentation, core: MpvPlayerCore, vararg args: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.command(arrayOf(*args)) {
|
||||
success.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, args.first())
|
||||
assertTrue("MPV command failed: ${args.first()}", success.get())
|
||||
}
|
||||
|
||||
private fun assertLogPhase(
|
||||
instrumentation: Instrumentation,
|
||||
core: MpvPlayerCore,
|
||||
logs: LinkedBlockingQueue<Pair<String, String>>,
|
||||
phase: String,
|
||||
expectInfo: Boolean
|
||||
) {
|
||||
val token = "mpv-log-$phase-${UUID.randomUUID()}"
|
||||
val infoMarker = "$token-info"
|
||||
val errorMarker = "$token-missing"
|
||||
val missingFile = File(instrumentation.targetContext.cacheDir, errorMarker)
|
||||
command(instrumentation, core, "print-text", infoMarker)
|
||||
command(instrumentation, core, "loadfile", missingFile.absolutePath, "replace")
|
||||
|
||||
// The failed open is an error-level barrier in the same ordered log stream.
|
||||
// Seeing it proves the preceding informational message was either delivered
|
||||
// or filtered; no sleep is needed to assert that a quiet log stayed quiet.
|
||||
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)
|
||||
var sawInfo = false
|
||||
while (true) {
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
assertTrue("Missing native error log in $phase", remaining > 0)
|
||||
val log = logs.poll(remaining, TimeUnit.MILLISECONDS)
|
||||
assertTrue("Missing native error log in $phase", log != null)
|
||||
if (log!!.second.contains(infoMarker)) {
|
||||
assertEquals("info", log.first)
|
||||
sawInfo = true
|
||||
}
|
||||
if (log.first == "error" && log.second.contains(errorMarker)) break
|
||||
}
|
||||
assertEquals("Informational log visibility in $phase", expectInfo, sawInfo)
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,29 @@ internal object GpuVoPolicy {
|
||||
*/
|
||||
fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true
|
||||
|
||||
/**
|
||||
* The video track the per-file policies ([needsDvReshaping],
|
||||
* [needsSoftwareDecode]) decide for, or null when no video track will be
|
||||
* selected. They run inside on_preloaded, where the track list is complete
|
||||
* but nothing is selected yet, so "the selected track" does not exist:
|
||||
* `vid` still reads the option value, and mpv's own selection
|
||||
* (default/forced flags, --vlang, attached pictures skipped) only runs
|
||||
* after the hook. [vid] is the `vid` property: `no` and an explicit id are
|
||||
* authoritative on their own; `auto` defers to [pendingVid], the fork's
|
||||
* `pending-vid` property, which runs that selection ahead of time. A null
|
||||
* [pendingVid] means the libmpv has no such property, and the first track
|
||||
* in [videoTrackIds] (track-list order) is the best guess left — wrong for
|
||||
* files whose first video track is not the one mpv picks.
|
||||
*/
|
||||
fun pendingVideoTrackId(vid: String?, pendingVid: String?, videoTrackIds: List<Long>): Long? {
|
||||
val requested = when {
|
||||
vid == null || vid == "auto" -> pendingVid ?: return videoTrackIds.firstOrNull()
|
||||
else -> vid
|
||||
}
|
||||
val id = requested.toLongOrNull() ?: return null
|
||||
return id.takeIf { it in videoTrackIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL vo session should drop to the cheap render tier: bilinear
|
||||
* scalers and no dither. Keyed on `GL_EXT_texture_norm16` being absent,
|
||||
|
||||
@@ -44,21 +44,40 @@ class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean,
|
||||
private val hardwareDecoding: Boolean,
|
||||
/** Subtitle "Render Resolution" as a fraction of the OSD plane's view size; see [OsdPlanePolicy]. */
|
||||
private val osdRenderScale: Float,
|
||||
private val initialLogLevel: String,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
/**
|
||||
* Test seam standing in for the native player's command path: returns what
|
||||
* [MpvPlayer.command] would (the playlist entry id of a `loadfile`, else
|
||||
* null) or throws to reject the command. Null keeps the production rule
|
||||
* that a command without a native player fails.
|
||||
*/
|
||||
private val commandRunnerOverride: (suspend (Array<String>) -> Long?)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback,
|
||||
SurfacePlayerCore {
|
||||
constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean = false,
|
||||
hardwareDecoding: Boolean = true
|
||||
) : this(context, audioOnly, hardwareDecoding, null, false)
|
||||
hardwareDecoding: Boolean = true,
|
||||
osdRenderScale: Float = 1f,
|
||||
initialLogLevel: String = "warn"
|
||||
) : this(context, audioOnly, hardwareDecoding, osdRenderScale, initialLogLevel, null, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, true, propertyWriter, true)
|
||||
) : this(context, audioOnly, true, 1f, "warn", propertyWriter, null, true)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?,
|
||||
commandRunner: suspend (Array<String>) -> Long?
|
||||
) : this(context, audioOnly, true, 1f, "warn", propertyWriter, commandRunner, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
@@ -134,6 +153,9 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
private val parkedHwdec = java.util.concurrent.atomic.AtomicReference<String?>()
|
||||
|
||||
/** Whether the missing `pending-vid` property was logged; hook-serial. */
|
||||
private var pendingVidUnavailableLogged = false
|
||||
|
||||
/** mpv option -> the default it carried before the cheap render tier
|
||||
* replaced it; empty while the tier is off. See [applyRenderTier]. */
|
||||
private val cheapRenderRestore = LinkedHashMap<String, String>()
|
||||
@@ -391,7 +413,7 @@ class MpvPlayerCore private constructor(
|
||||
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
|
||||
surfaceContainer!!.addView(surfaceView)
|
||||
if (usesMediaCodecVo) {
|
||||
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
|
||||
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback, osdRenderScale)
|
||||
surfaceContainer!!.addView(osdSurfaceView)
|
||||
}
|
||||
|
||||
@@ -422,6 +444,7 @@ class MpvPlayerCore private constructor(
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
|
||||
)
|
||||
val p = MpvPlayer.create(context.applicationContext) {
|
||||
setLogLevel(initialLogLevel)
|
||||
if (audioOnly) {
|
||||
// Pure audio core (all set before mpv_initialize, mirroring the
|
||||
// Windows/Linux audio instances): vid=no keeps embedded cover
|
||||
@@ -492,12 +515,15 @@ class MpvPlayerCore private constructor(
|
||||
// Per-file decode routing runs inside mpv's on_preloaded hook:
|
||||
// the demuxer has opened the file, no decoder exists yet, and
|
||||
// mpv waits for the answer. file-loaded would be too late — the
|
||||
// MediaCodec decoder is already created by then (#2065).
|
||||
// MediaCodec decoder is already created by then (#2065). Nothing
|
||||
// is selected yet either, so the track both policies decide for
|
||||
// is resolved once here, from mpv's pending selection.
|
||||
p.hookHandler = { name ->
|
||||
if (name == "on_preloaded" && !disposing) {
|
||||
withContext(mpvWriteDispatcher) {
|
||||
applyDvReshapePolicy(p)
|
||||
applySoftwareDecodePolicy(p)
|
||||
val track = pendingVideoTrack(p)
|
||||
applyDvReshapePolicy(p, track)
|
||||
applySoftwareDecodePolicy(p, track)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -841,12 +867,14 @@ class MpvPlayerCore private constructor(
|
||||
/**
|
||||
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
|
||||
* the DOVI configuration record's profile on the track list (never trust
|
||||
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
|
||||
* every file-loaded, so a following non-P5 file restores hardware decode
|
||||
* and returns to the video plane.
|
||||
* server metadata for this — it mis-tags DV routinely; mpv omits the
|
||||
* field when the bitstream carries no record). Re-evaluated on every
|
||||
* file, so a following non-P5 file restores hardware decode and returns
|
||||
* to the video plane. [track] is the pending video track, see
|
||||
* [pendingVideoTrack].
|
||||
*/
|
||||
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
|
||||
val profile = selectedVideoDvProfile(p)
|
||||
private suspend fun applyDvReshapePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
|
||||
val profile = track?.takeIf { it.has("dolby-vision-profile") }?.getLong("dolby-vision-profile")
|
||||
val needs = GpuVoPolicy.needsDvReshaping(
|
||||
dvProfile = profile,
|
||||
conversionMode = currentDvConversionMode,
|
||||
@@ -865,9 +893,9 @@ class MpvPlayerCore private constructor(
|
||||
* The profile comes from the container's avcC record (fork patch), so it
|
||||
* is known inside on_preloaded. Distinct from [GpuVoPolicy.REASON_SW_DECODE]:
|
||||
* that one follows `hwdec-current`, which is still blank at this point.
|
||||
* [track] is the pending video track, see [pendingVideoTrack].
|
||||
*/
|
||||
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) {
|
||||
val track = videoTracks(p).firstOrNull()
|
||||
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
|
||||
val codec = track?.optString("codec")
|
||||
val codecProfile = track?.optString("codec-profile")
|
||||
val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support()
|
||||
@@ -948,13 +976,29 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected video track's Dolby Vision profile, or null for non-DV content
|
||||
* (mpv omits the field when the bitstream carries no DOVI configuration
|
||||
* record).
|
||||
* The video track mpv is about to select, resolved inside on_preloaded
|
||||
* where nothing is selected yet. `vid=no` and an explicit `vid=N` answer
|
||||
* on their own (the hook never re-selects: an explicit choice stays the
|
||||
* user's); `auto` asks the fork's `pending-vid`, which runs mpv's own
|
||||
* default selection ahead of time. Without that property (a libmpv
|
||||
* predating the fork patch) the first track is the only guess left; the
|
||||
* gap is logged once so a wrong policy on a multi-video file is traceable.
|
||||
* Decision in [GpuVoPolicy.pendingVideoTrackId].
|
||||
*/
|
||||
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
|
||||
?.takeIf { it.has("dolby-vision-profile") }
|
||||
?.getLong("dolby-vision-profile")
|
||||
private suspend fun pendingVideoTrack(p: MpvPlayer): org.json.JSONObject? {
|
||||
val tracks = videoTracks(p)
|
||||
if (tracks.isEmpty()) return null
|
||||
val vid = p.getString("vid")
|
||||
val auto = vid == null || vid == "auto"
|
||||
val pendingVid = if (auto) p.getString("pending-vid") else null
|
||||
if (auto && pendingVid == null && !pendingVidUnavailableLogged) {
|
||||
pendingVidUnavailableLogged = true
|
||||
Log.w(TAG, "libmpv has no pending-vid property; decode routing assumes the first video track")
|
||||
}
|
||||
val id = GpuVoPolicy.pendingVideoTrackId(vid, pendingVid, tracks.map { it.optLong("id") })
|
||||
Log.d(TAG, "Pending video track: vid=$vid pending-vid=$pendingVid -> ${id ?: "none"} of ${tracks.size}")
|
||||
return tracks.firstOrNull { it.optLong("id") == id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Observed rather than derived from the hardware-decoding setting because
|
||||
@@ -1556,6 +1600,35 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setLogLevel(level: String, onComplete: (Result<Unit>) -> Unit) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
|
||||
// ATOMIC starts even if dispose cancels a queued write, so its channel
|
||||
// result is completed; ensureActive prevents that write reaching JNI.
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val outcome = try {
|
||||
ensureActive()
|
||||
val p = player ?: throw CancellationException("MPV player unavailable")
|
||||
p.setLogLevel(level)
|
||||
Result.success(Unit)
|
||||
} catch (error: Exception) {
|
||||
Result.failure(error)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete(
|
||||
if (disposing || !isInitialized) {
|
||||
Result.failure(CancellationException("MPV core unavailable"))
|
||||
} else {
|
||||
outcome
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
@@ -1823,21 +1896,34 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
|
||||
fun command(args: Array<String>, onComplete: ((Boolean) -> Unit)? = null) {
|
||||
commandForSource(args) { onComplete?.invoke(it.isSuccess) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an mpv command on the ordered writer. Completes on the main thread with the playlist
|
||||
* entry id a `loadfile` created (null for every other command), or with the failure mpv
|
||||
* reported — a rejected load never starts a source, so it must not be reported as one.
|
||||
*/
|
||||
fun commandForSource(args: Array<String>, onComplete: (Result<Long?>) -> Unit) {
|
||||
if (!isInitialized || disposing || args.isEmpty() || !scope.isActive) {
|
||||
onComplete?.invoke(false)
|
||||
onComplete(Result.failure(IllegalStateException("MPV player unavailable")))
|
||||
return
|
||||
}
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
var success = false
|
||||
try {
|
||||
player?.command(*args)
|
||||
success = true
|
||||
val outcome: Result<Long?> = try {
|
||||
val runner = commandRunnerOverride
|
||||
if (runner != null) {
|
||||
Result.success(runner(args))
|
||||
} else {
|
||||
val p = player ?: throw IllegalStateException("MPV player unavailable")
|
||||
Result.success(p.command(*args))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "command failed", e)
|
||||
} finally {
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(success)
|
||||
}
|
||||
Result.failure(e)
|
||||
}
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ open class MpvPlayerPlugin(
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
// Subtitle "Render Resolution" for the vo=mediacodec OSD plane (Full / ¾ / ½ /
|
||||
// ⅓ / ¼ of the surface); the same fraction the ExoPlayer overlay applies.
|
||||
// Absent from older callers and the audio-only core; full is the default.
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1f
|
||||
val logLevel = call.argument<String>("logLevel") ?: "warn"
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -264,7 +269,7 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
@@ -469,25 +474,42 @@ open class MpvPlayerPlugin(
|
||||
result.error("NOT_INITIALIZED", "Player not initialized", null)
|
||||
return
|
||||
}
|
||||
core.command(args.toTypedArray()) { success ->
|
||||
if (success) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("COMMAND_FAILED", "mpv command failed", args)
|
||||
}
|
||||
// `loadfile` answers with the playlist entry it created so Dart can tie
|
||||
// the load to that source's start-file/playback-restart/end-file events;
|
||||
// every other command answers null.
|
||||
core.commandForSource(args.toTypedArray()) { outcome ->
|
||||
outcome.fold(
|
||||
onSuccess = { playlistEntryId ->
|
||||
result.success(playlistEntryId?.let { mapOf("playlistEntryId" to it) })
|
||||
},
|
||||
onFailure = { error ->
|
||||
result.error("COMMAND_FAILED", error.message ?: "mpv command failed", args)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.argument<String>("level") == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'level'", null)
|
||||
val level = call.argument<Any>("level") as? String
|
||||
if (level == null) {
|
||||
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
|
||||
return
|
||||
}
|
||||
result.error(
|
||||
"UNSUPPORTED",
|
||||
"Runtime mpv log level changes are not supported on Android",
|
||||
null
|
||||
)
|
||||
val core = playerCore
|
||||
if (core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
core.setLogLevel(level) { outcome ->
|
||||
when (val failure = outcome.exceptionOrNull()) {
|
||||
null -> result.success(null)
|
||||
is CancellationException -> completeMpvPropertyNotInitialized(result)
|
||||
else -> {
|
||||
Log.w(tag, "MPV rejected log level change", failure)
|
||||
result.error("SET_LOG_LEVEL_FAILED", "MPV log level change was rejected", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure geometry for the `vo=mediacodec` subtitle/OSD plane.
|
||||
*
|
||||
* mpv rasterizes subtitles at whatever size the OSD Surface reports, so the
|
||||
* "Render Resolution" setting is applied by giving the OSD SurfaceView a
|
||||
* fixed buffer size below its view size; SurfaceFlinger scales the plane back
|
||||
* up. Every stage of the OSD pass (libass rasterization, the software
|
||||
* composite, the Surface copy) is proportional to that pixel count, which is
|
||||
* what makes the setting a throughput knob on render-bound TV hardware. The
|
||||
* ExoPlayer path applies the same fraction to its libass overlay.
|
||||
*/
|
||||
internal object OsdPlanePolicy {
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Fixed buffer size for an OSD plane whose view measures [viewWidth] x
|
||||
* [viewHeight], or null when the plane should track the view (full
|
||||
* resolution, or a view that has not been laid out yet).
|
||||
*
|
||||
* Dimensions are rounded to even values: the 4:2:0-derived alignment
|
||||
* assumptions in the compositor stack are cheap to honor here and an odd
|
||||
* OSD width buys nothing.
|
||||
*/
|
||||
fun fixedSizeFor(viewWidth: Int, viewHeight: Int, renderScale: Float): Size? {
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return null
|
||||
if (!(renderScale > 0f) || renderScale >= 1f) return null
|
||||
return Size(
|
||||
width = scaledEven(viewWidth, renderScale),
|
||||
height = scaledEven(viewHeight, renderScale)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scaledEven(value: Int, scale: Float): Int {
|
||||
val scaled = (value * scale).roundToInt().coerceAtLeast(2)
|
||||
return scaled and 1.inv()
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,18 @@ internal object GlCapabilities {
|
||||
if (!EGL14.eglChooseConfig(display, configAttributes, 0, configs, 0, 1, count, 0) || count[0] < 1) return null
|
||||
val config = configs[0] ?: return null
|
||||
surface = EGL14.eglCreatePbufferSurface(
|
||||
display, config, intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), 0
|
||||
display,
|
||||
config,
|
||||
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
|
||||
0
|
||||
)
|
||||
if (surface == EGL14.EGL_NO_SURFACE) return null
|
||||
context = EGL14.eglCreateContext(
|
||||
display, config, EGL14.EGL_NO_CONTEXT, intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), 0
|
||||
display,
|
||||
config,
|
||||
EGL14.EGL_NO_CONTEXT,
|
||||
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE),
|
||||
0
|
||||
)
|
||||
if (context == EGL14.EGL_NO_CONTEXT) return null
|
||||
if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.edde746.plezy.mpv.OsdPlanePolicy
|
||||
|
||||
/** Shared Android view scaffold beneath the ExoPlayer and mpv cores. */
|
||||
internal object PlayerSurfaceHost {
|
||||
@@ -63,8 +64,13 @@ internal object PlayerSurfaceHost {
|
||||
* Transparent plane directly above the video surface for the mpv
|
||||
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
|
||||
* the video SurfaceView but still beneath the Flutter window content.
|
||||
*
|
||||
* [renderScale] < 1 gives the plane a fixed buffer size below its view size
|
||||
* (see [OsdPlanePolicy]); mpv then rasterizes at that size and the
|
||||
* compositor scales the plane to the view. Re-applied on every layout so a
|
||||
* container resize keeps the ratio.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
@@ -74,6 +80,13 @@ internal object PlayerSurfaceHost {
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
if (renderScale < 1f) {
|
||||
addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (right - left == oldRight - oldLeft && bottom - top == oldBottom - oldTop) return@addOnLayoutChangeListener
|
||||
val size = OsdPlanePolicy.fixedSizeFor(right - left, bottom - top, renderScale) ?: return@addOnLayoutChangeListener
|
||||
(view as SurfaceView).holder.setFixedSize(size.width, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
|
||||
@@ -18,3 +18,12 @@ add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
|
||||
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
add_executable(mpv_lifecycle_test mpv_lifecycle_test.cpp)
|
||||
target_compile_features(mpv_lifecycle_test PRIVATE cxx_std_17)
|
||||
target_include_directories(mpv_lifecycle_test PRIVATE fakes ../../../../libmpv/src/main/cpp/include)
|
||||
target_link_libraries(mpv_lifecycle_test PRIVATE Threads::Threads)
|
||||
|
||||
add_test(NAME mpv_lifecycle_test COMMAND mpv_lifecycle_test)
|
||||
set_tests_properties(mpv_lifecycle_test PROPERTIES TIMEOUT 30)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#define ANDROID_LOG_INFO 4
|
||||
#define ANDROID_LOG_WARN 5
|
||||
#define ANDROID_LOG_ERROR 6
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -14,6 +15,10 @@ using jboolean = uint8_t;
|
||||
using jbyte = int8_t;
|
||||
using jint = int32_t;
|
||||
using jsize = jint;
|
||||
using jlong = int64_t;
|
||||
using jdouble = double;
|
||||
using jobject = void*;
|
||||
using jmethodID = void*;
|
||||
|
||||
struct _jclass {};
|
||||
using jclass = _jclass*;
|
||||
@@ -28,11 +33,53 @@ struct _jstring {
|
||||
};
|
||||
using jstring = _jstring*;
|
||||
|
||||
struct _jobjectArray {
|
||||
std::vector<jobject> objects;
|
||||
};
|
||||
using jobjectArray = _jobjectArray*;
|
||||
|
||||
class JavaVM {
|
||||
public:
|
||||
void (*on_detach)() = nullptr;
|
||||
|
||||
jint DetachCurrentThread() {
|
||||
if (on_detach) on_detach();
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
class JNIEnv {
|
||||
public:
|
||||
bool exception_pending = false;
|
||||
bool fail_next_write = false;
|
||||
|
||||
JavaVM* vm = nullptr;
|
||||
jobject (*on_new_global_ref)(jobject) = nullptr;
|
||||
void (*on_delete_global_ref)(jobject) = nullptr;
|
||||
void (*on_static_void_method)(jmethodID, va_list) = nullptr;
|
||||
|
||||
jint GetJavaVM(JavaVM** result) {
|
||||
*result = vm;
|
||||
return 0;
|
||||
}
|
||||
|
||||
jobject NewGlobalRef(jobject object) { return on_new_global_ref ? on_new_global_ref(object) : object; }
|
||||
|
||||
void DeleteGlobalRef(jobject object) {
|
||||
if (on_delete_global_ref) on_delete_global_ref(object);
|
||||
}
|
||||
|
||||
void DeleteLocalRef(jobject) {}
|
||||
|
||||
void CallStaticVoidMethod(jclass, jmethodID method, ...) {
|
||||
va_list args;
|
||||
va_start(args, method);
|
||||
if (on_static_void_method) on_static_void_method(method, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
jsize GetArrayLength(jobjectArray array) { return static_cast<jsize>(array->objects.size()); }
|
||||
jobject GetObjectArrayElement(jobjectArray array, jsize index) { return array->objects.at(index); }
|
||||
jsize GetArrayLength(jbyteArray array) { return static_cast<jsize>(array->bytes.size()); }
|
||||
|
||||
void GetByteArrayRegion(jbyteArray array, jsize offset, jsize length, jbyte* destination) {
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
#include <pthread.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
static int tracked_mutex_lock(pthread_mutex_t* mutex);
|
||||
static int tracked_write_lock(pthread_rwlock_t* lock);
|
||||
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg);
|
||||
|
||||
// Compile the real JNI entries, admission guards, event loop and surface
|
||||
// cleanup. Only external dependencies are controlled; no copy of the lock
|
||||
// algorithm or direct assignment to production lifecycle globals is used.
|
||||
#define UTIL_EXTERN
|
||||
#define pthread_mutex_lock tracked_mutex_lock
|
||||
#define pthread_rwlock_wrlock tracked_write_lock
|
||||
#define pthread_create controlled_thread_create
|
||||
// Android's two-argument thread naming API is not available on macOS.
|
||||
#define pthread_setname_np(thread, name) ((void)0)
|
||||
#include "../../../../libmpv/src/main/cpp/main.cpp"
|
||||
#undef pthread_setname_np
|
||||
#undef pthread_create
|
||||
#undef pthread_rwlock_wrlock
|
||||
#undef pthread_mutex_lock
|
||||
#include "../../../../libmpv/src/main/cpp/event.cpp"
|
||||
#include "../../../../libmpv/src/main/cpp/render.cpp"
|
||||
|
||||
struct mpv_handle {
|
||||
bool initialized = false;
|
||||
bool terminated = false;
|
||||
bool event_started = false;
|
||||
bool event_exited = false;
|
||||
bool woken = false;
|
||||
bool hook_pending = false;
|
||||
bool reject_hook_during_destroy = false;
|
||||
bool callback_entered = false;
|
||||
bool callback_returned = false;
|
||||
bool command_active = false;
|
||||
bool termination_entered = false;
|
||||
int initialize_result = 0;
|
||||
int hook_continuations = 0;
|
||||
int commands = 0;
|
||||
jlong callback_session = 0;
|
||||
jobject video = nullptr;
|
||||
jobject osd = nullptr;
|
||||
mpv_event_hook hook{"on_preloaded", 17};
|
||||
mpv_event event{};
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gate;
|
||||
std::condition_variable changed;
|
||||
JavaVM vm;
|
||||
JNIEnv jni;
|
||||
int app_context;
|
||||
mpv_handle* active_handle = nullptr;
|
||||
// Retain fake allocations after termination so any erroneous late MPV access
|
||||
// fails explicitly rather than depending on allocator reuse or undefined UAF.
|
||||
std::vector<std::unique_ptr<mpv_handle>> handles;
|
||||
std::vector<std::unique_ptr<_jstring>> callback_strings;
|
||||
std::map<jobject, int> global_refs;
|
||||
mpv_handle* held_termination = nullptr;
|
||||
bool allow_termination = false;
|
||||
bool allow_command = false;
|
||||
bool successor_waiting = false;
|
||||
bool reader_draining = false;
|
||||
bool fail_thread_create = false;
|
||||
thread_local mpv_handle* event_handle = nullptr;
|
||||
enum class Operation { ordinary, successor, replacing_reader };
|
||||
thread_local Operation operation = Operation::ordinary;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (condition) return;
|
||||
std::fprintf(stderr, "%s\n", message);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
template <typename Predicate>
|
||||
void await(std::unique_lock<std::mutex>& lock, Predicate predicate, const char* message) {
|
||||
require(changed.wait_for(lock, std::chrono::seconds(5), predicate), message);
|
||||
}
|
||||
|
||||
void require_live(mpv_handle* handle) {
|
||||
require(handle && handle == active_handle && !handle->terminated, "MPV accessed outside its native lifetime");
|
||||
}
|
||||
|
||||
void require_surfaces(mpv_handle* handle) {
|
||||
if (handle->video) require(global_refs[handle->video] == 1, "video Surface released before termination completed");
|
||||
if (handle->osd) require(global_refs[handle->osd] == 1, "OSD Surface released before termination completed");
|
||||
}
|
||||
|
||||
jobject new_global_ref(jobject object) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
if (object) ++global_refs[object];
|
||||
return object;
|
||||
}
|
||||
|
||||
void delete_global_ref(jobject object) {
|
||||
if (!object) return;
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(global_refs[object] > 0, "JNI global reference released more than once");
|
||||
if (active_handle && (active_handle->video == object || active_handle->osd == object)) {
|
||||
require(active_handle->terminated, "Surface cleanup preceded native termination");
|
||||
}
|
||||
--global_refs[object];
|
||||
}
|
||||
|
||||
void detach_event_thread() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(event_handle);
|
||||
event_handle->event_exited = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
|
||||
void on_static_void_method(jmethodID method, va_list args) {
|
||||
require(method == mpv_MpvPlayer_onHook, "unexpected callback in lifecycle scenario");
|
||||
const jlong session = va_arg(args, jlong);
|
||||
const jstring name = va_arg(args, jstring);
|
||||
const jlong hook = va_arg(args, jlong);
|
||||
require(name->value == "on_preloaded", "wrong hook callback delivered");
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(event_handle);
|
||||
event_handle->callback_session = session;
|
||||
event_handle->callback_entered = true;
|
||||
changed.notify_all();
|
||||
if (event_handle->reject_hook_during_destroy) {
|
||||
// The callback is already inside Java when teardown starts. Kotlin's
|
||||
// rejected-channel path continues synchronously on this event thread.
|
||||
await(lock, [] { return event_handle->woken; }, "destroy never woke the overlapping hook callback");
|
||||
}
|
||||
}
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, session, hook);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
event_handle->callback_returned = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
jlong create_player() {
|
||||
const jlong session = jni_func_name(nativeCreate)(&jni, nullptr, &app_context);
|
||||
require(session > 0, "nativeCreate failed");
|
||||
return session;
|
||||
}
|
||||
|
||||
jlong command(jlong session, const char* name = "play") {
|
||||
_jstring argument{name};
|
||||
_jobjectArray arguments{{&argument}};
|
||||
return jni_func_name(nativeCommand)(&jni, nullptr, session, &arguments);
|
||||
}
|
||||
|
||||
void initialize_player(jlong session) {
|
||||
require(jni_func_name(nativeInit)(&jni, nullptr, session) == 0, "nativeInit failed");
|
||||
}
|
||||
|
||||
void destroy_player(jlong session) { jni_func_name(nativeDestroy)(&jni, nullptr, session); }
|
||||
|
||||
void reset_dependencies() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "previous test left a live MPV instance");
|
||||
for (const auto& entry : global_refs) {
|
||||
require(entry.first == &app_context || entry.second == 0, "terminal Surface cleanup leaked a global reference");
|
||||
}
|
||||
handles.clear();
|
||||
callback_strings.clear();
|
||||
global_refs.clear();
|
||||
held_termination = nullptr;
|
||||
allow_termination = false;
|
||||
allow_command = false;
|
||||
successor_waiting = false;
|
||||
reader_draining = false;
|
||||
fail_thread_create = false;
|
||||
jni.exception_pending = false;
|
||||
}
|
||||
|
||||
void rejected_hook_and_successor_retirement() {
|
||||
reset_dependencies();
|
||||
const jlong old_session = create_player();
|
||||
mpv_handle* old = active_handle;
|
||||
int video, osd;
|
||||
jni_func_name(nativeAttachSurface)(&jni, nullptr, old_session, &video);
|
||||
jni_func_name(nativeAttachOsdSurface)(&jni, nullptr, old_session, &osd);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
old->hook_pending = true;
|
||||
old->reject_hook_during_destroy = true;
|
||||
held_termination = old;
|
||||
}
|
||||
initialize_player(old_session);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->callback_entered; }, "event loop did not deliver the hook");
|
||||
}
|
||||
std::thread retiring([&] { destroy_player(old_session); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->termination_entered; }, "retirement deadlocked with the synchronous hook callback");
|
||||
require(old->callback_returned && old->event_exited, "termination preceded event-thread completion");
|
||||
require(old->callback_session == old_session, "retiring callback lost its bound session");
|
||||
require(old->hook_continuations == 0, "revoked hook reached the retiring MPV handle");
|
||||
require_surfaces(old);
|
||||
}
|
||||
|
||||
jlong successor_session = 0;
|
||||
std::thread creating([&] {
|
||||
operation = Operation::successor;
|
||||
successor_session = create_player();
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return successor_waiting; }, "successor did not wait for terminal retirement");
|
||||
}
|
||||
// S must be available while termination holds L. These calls must reject
|
||||
// without touching the retiring core, surfaces, or a future successor.
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "revoked command was admitted during termination");
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
|
||||
jni_func_name(nativeDetachSurface)(&jni, nullptr, old_session);
|
||||
jni_func_name(nativeDetachOsdSurface)(&jni, nullptr, old_session);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_surfaces(old);
|
||||
allow_termination = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
retiring.join();
|
||||
creating.join();
|
||||
require(successor_session > old_session, "successor did not receive a new session");
|
||||
mpv_handle* successor = active_handle;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
successor->hook_pending = true;
|
||||
}
|
||||
jni_func_name(nativeAttachSurface)(&jni, nullptr, successor_session, &video);
|
||||
jni_func_name(nativeAttachOsdSurface)(&jni, nullptr, successor_session, &osd);
|
||||
initialize_player(successor_session);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return successor->callback_returned; }, "successor event loop did not serve its own hook");
|
||||
require(successor->callback_session == successor_session, "successor inherited the old event binding");
|
||||
require(successor->hook_continuations == 1, "live hook was not continued exactly once");
|
||||
}
|
||||
destroy_player(old_session);
|
||||
require(
|
||||
jni_func_name(nativeInit)(&jni, nullptr, old_session) == MPV_ERROR_UNINITIALIZED, "stale init reached successor");
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "stale command reached successor");
|
||||
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
|
||||
jni_func_name(nativeDetachSurface)(&jni, nullptr, old_session);
|
||||
jni_func_name(nativeDetachOsdSurface)(&jni, nullptr, old_session);
|
||||
require(command(successor_session) == 0, "stale teardown retired the successor");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_surfaces(successor);
|
||||
require(successor->hook_continuations == 1, "old hook id was forwarded to successor");
|
||||
}
|
||||
destroy_player(successor_session);
|
||||
}
|
||||
|
||||
void admitted_command_survives_replacement() {
|
||||
reset_dependencies();
|
||||
const jlong old_session = create_player();
|
||||
mpv_handle* old = active_handle;
|
||||
initialize_player(old_session);
|
||||
jlong result = MPV_ERROR_GENERIC;
|
||||
std::thread reader([&] { result = command(old_session, "hold"); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [&] { return old->command_active; }, "command was not admitted");
|
||||
}
|
||||
jlong successor_session = 0;
|
||||
std::thread replacing([&] {
|
||||
operation = Operation::replacing_reader;
|
||||
successor_session = create_player();
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
await(lock, [] { return reader_draining; }, "replacement did not drain the admitted command");
|
||||
require(!old->termination_entered && !old->woken, "retirement overtook an admitted command");
|
||||
allow_command = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
reader.join();
|
||||
replacing.join();
|
||||
require(result == 0 && old->commands == 1, "admitted command lost its handle before returning");
|
||||
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "late old-session command was admitted");
|
||||
destroy_player(old_session);
|
||||
initialize_player(successor_session);
|
||||
require(command(successor_session) == 0, "replacement was affected by stale owner traffic");
|
||||
destroy_player(successor_session);
|
||||
}
|
||||
|
||||
void partial_initialization_can_retire() {
|
||||
enum class Failure { configuration, initialization, thread_start };
|
||||
for (Failure failure : {Failure::configuration, Failure::initialization, Failure::thread_start}) {
|
||||
reset_dependencies();
|
||||
const jlong session = create_player();
|
||||
mpv_handle* failed = active_handle;
|
||||
if (failure == Failure::configuration) {
|
||||
_jstring invalid{"not-a-level"};
|
||||
require(
|
||||
jni_func_name(nativeSetLogLevel)(&jni, nullptr, session, &invalid) < 0, "invalid configuration succeeded");
|
||||
} else {
|
||||
failed->initialize_result = failure == Failure::initialization ? MPV_ERROR_GENERIC : 0;
|
||||
fail_thread_create = failure == Failure::thread_start;
|
||||
require(jni_func_name(nativeInit)(&jni, nullptr, session) < 0, "injected initialization failure was lost");
|
||||
if (failure == Failure::thread_start)
|
||||
require(jni.ExceptionCheck(), "thread-start failure lost its JNI exception");
|
||||
}
|
||||
destroy_player(session);
|
||||
require(
|
||||
failed->terminated && !failed->event_started && !failed->woken, "partial init used an unstarted event thread");
|
||||
require(command(session) == MPV_ERROR_UNINITIALIZED, "failed session retained public admission");
|
||||
jni.exception_pending = false;
|
||||
const jlong successor = create_player();
|
||||
initialize_player(successor);
|
||||
require(command(successor) == 0, "partial init prevented the next player from working");
|
||||
destroy_player(successor);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Observe real contention to arrange overlap without sleeps or a guessed
|
||||
// scheduling delay. The production guards still acquire the real pthread
|
||||
// locks; the notification only lets the test release its external MPV gates.
|
||||
static int tracked_mutex_lock(pthread_mutex_t* mutex) {
|
||||
if (operation != Operation::successor) return pthread_mutex_lock(mutex);
|
||||
const int result = pthread_mutex_trylock(mutex);
|
||||
if (result != EBUSY) return result;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
successor_waiting = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
return pthread_mutex_lock(mutex);
|
||||
}
|
||||
|
||||
static int tracked_write_lock(pthread_rwlock_t* lock) {
|
||||
if (operation != Operation::replacing_reader) return pthread_rwlock_wrlock(lock);
|
||||
const int result = pthread_rwlock_trywrlock(lock);
|
||||
if (result != EBUSY) return result;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(gate);
|
||||
reader_draining = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
return pthread_rwlock_wrlock(lock);
|
||||
}
|
||||
|
||||
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
if (fail_thread_create) {
|
||||
fail_thread_create = false;
|
||||
return EAGAIN;
|
||||
}
|
||||
const int result = pthread_create(thread, attr, entry, arg);
|
||||
if (result == 0) active_handle->event_started = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
extern "C" mpv_handle* mpv_create() {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "successor MPV creation overlapped predecessor termination");
|
||||
for (const auto& entry : global_refs) {
|
||||
require(entry.first == &app_context || entry.second == 0, "successor creation preceded retiring Surface cleanup");
|
||||
}
|
||||
handles.push_back(std::make_unique<mpv_handle>());
|
||||
active_handle = handles.back().get();
|
||||
return active_handle;
|
||||
}
|
||||
|
||||
extern "C" int mpv_initialize(mpv_handle* handle) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
if (handle->initialize_result < 0) return handle->initialize_result;
|
||||
handle->initialized = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" void mpv_wakeup(mpv_handle* handle) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(!handle->command_active, "wake overtook an admitted command");
|
||||
require(handle->event_started, "wake targeted an unstarted event thread");
|
||||
handle->woken = true;
|
||||
changed.notify_all();
|
||||
}
|
||||
|
||||
extern "C" void mpv_terminate_destroy(mpv_handle* handle) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(!handle->command_active, "termination overtook an admitted command");
|
||||
require(!handle->event_started || handle->event_exited, "termination overtook the bound event thread");
|
||||
require_surfaces(handle);
|
||||
handle->termination_entered = true;
|
||||
changed.notify_all();
|
||||
if (handle == held_termination)
|
||||
await(lock, [] { return allow_termination; }, "test did not release native termination");
|
||||
require_surfaces(handle);
|
||||
handle->terminated = true;
|
||||
active_handle = nullptr;
|
||||
}
|
||||
|
||||
extern "C" mpv_event* mpv_wait_event(mpv_handle* handle, double) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
event_handle = handle;
|
||||
require_live(handle);
|
||||
await(lock, [&] { return handle->hook_pending || handle->woken; }, "event loop was not woken for teardown");
|
||||
handle->event = {};
|
||||
if (handle->hook_pending) {
|
||||
handle->hook_pending = false;
|
||||
handle->event.event_id = MPV_EVENT_HOOK;
|
||||
handle->event.data = &handle->hook;
|
||||
}
|
||||
return &handle->event;
|
||||
}
|
||||
|
||||
extern "C" int mpv_hook_add(mpv_handle* handle, uint64_t, const char*, int) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_hook_continue(mpv_handle* handle, uint64_t id) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(id == handle->hook.id && handle->hook_continuations == 0, "hook continued twice or on the wrong handle");
|
||||
++handle->hook_continuations;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_command_ret(mpv_handle* handle, const char** args, mpv_node* result) {
|
||||
std::unique_lock<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(handle->initialized, "command reached an uninitialized core");
|
||||
handle->command_active = true;
|
||||
changed.notify_all();
|
||||
if (std::strcmp(args[0], "hold") == 0)
|
||||
await(lock, [] { return allow_command; }, "test did not release admitted command");
|
||||
require_live(handle);
|
||||
handle->command_active = false;
|
||||
++handle->commands;
|
||||
*result = {};
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_request_log_messages(mpv_handle* handle, const char* level) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
return std::strcmp(level, "not-a-level") == 0 ? MPV_ERROR_INVALID_PARAMETER : 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_set_option(mpv_handle* handle, const char* name, mpv_format format, void* data) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require_live(handle);
|
||||
require(format == MPV_FORMAT_INT64, "unexpected Surface option format");
|
||||
jobject object = reinterpret_cast<jobject>(static_cast<intptr_t>(*static_cast<int64_t*>(data)));
|
||||
if (std::strcmp(name, "wid") == 0) {
|
||||
handle->video = object;
|
||||
} else {
|
||||
require(std::strcmp(name, "vo-mediacodec-osd-surface") == 0, "unexpected Surface option");
|
||||
handle->osd = object;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int mpv_get_property(mpv_handle*, const char*, mpv_format, void*) {
|
||||
require(false, "unexpected property read in lifecycle scenario");
|
||||
return MPV_ERROR_GENERIC;
|
||||
}
|
||||
|
||||
extern "C" const char* mpv_error_string(int) { return "controlled MPV failure"; }
|
||||
extern "C" void mpv_free_node_contents(mpv_node*) {}
|
||||
extern "C" int av_jni_set_java_vm(void*, void*) { return 0; }
|
||||
extern "C" int av_jni_set_android_app_ctx(void*, void*) { return 0; }
|
||||
extern "C" int __android_log_print(int, const char*, const char*, ...) { return 0; }
|
||||
|
||||
bool acquire_jni_env(JavaVM* supplied_vm, JNIEnv** env) {
|
||||
require(supplied_vm == &vm, "event thread acquired the wrong Java VM");
|
||||
*env = &jni;
|
||||
return true;
|
||||
}
|
||||
|
||||
void init_methods_cache(JNIEnv*) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
require(active_handle == nullptr, "JNI environment rewritten before predecessor retirement");
|
||||
mpv_MpvPlayer_onHook = reinterpret_cast<jmethodID>(1);
|
||||
}
|
||||
|
||||
jstring new_java_string(JNIEnv*, const char* value) {
|
||||
std::lock_guard<std::mutex> lock(gate);
|
||||
callback_strings.push_back(std::make_unique<_jstring>(_jstring{value ? value : ""}));
|
||||
return callback_strings.back().get();
|
||||
}
|
||||
|
||||
std::string java_string_to_utf8(JNIEnv*, jstring value) { return value ? value->value : ""; }
|
||||
void die(const char*) { jni.exception_pending = true; }
|
||||
|
||||
int main() {
|
||||
jni.vm = &vm;
|
||||
jni.on_new_global_ref = new_global_ref;
|
||||
jni.on_delete_global_ref = delete_global_ref;
|
||||
jni.on_static_void_method = on_static_void_method;
|
||||
vm.on_detach = detach_event_thread;
|
||||
rejected_hook_and_successor_retirement();
|
||||
admitted_command_survives_replacement();
|
||||
partial_initialization_can_retire();
|
||||
reset_dependencies();
|
||||
std::puts("MPV lifecycle: callback overlap, reader lifetime, successor isolation and partial init passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -236,7 +236,8 @@ class ExoPlayerPluginTest {
|
||||
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val exoCore = ExoPlayerCore(activity)
|
||||
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
|
||||
val plugin = ExoPlayerPlugin()
|
||||
val sink = RecordingEventSink()
|
||||
plugin.onListen(null, sink)
|
||||
@@ -273,15 +274,30 @@ class ExoPlayerPluginTest {
|
||||
assertEquals(1, superseded.completionCount)
|
||||
assertEquals("OPEN_SUPERSEDED", superseded.errorCode)
|
||||
assertEquals(0, active.completionCount)
|
||||
assertTrue(loads.isEmpty())
|
||||
|
||||
initializeCallback!!(true)
|
||||
mpvCore.delegate?.onEvent("file-loaded", null)
|
||||
awaitCompletion(active)
|
||||
|
||||
assertEquals(null, active.errorCode)
|
||||
assertEquals(true, getField(plugin, "usingMpvFallback"))
|
||||
assertEquals(false, getField(plugin, "fallbackInProgress"))
|
||||
assertNull(getField(plugin, "pendingOpen"))
|
||||
// mpv received exactly the newest open, with its play intent; neither the
|
||||
// failed Exo URI nor the superseded episode was ever loaded.
|
||||
assertEquals(listOf("https://example.test/episode-3.mkv"), loads.map { it[1] })
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
// The fallback owns subsequent opens: they reach mpv directly, without
|
||||
// another backend switch.
|
||||
val next = RecordingResult()
|
||||
plugin.onMethodCall(
|
||||
MethodCall("open", mapOf("uri" to "https://example.test/episode-4.mkv", "autoPlay" to false)),
|
||||
next
|
||||
)
|
||||
awaitCompletion(next)
|
||||
assertEquals(null, next.errorCode)
|
||||
assertEquals("https://example.test/episode-4.mkv", loads.last()[1])
|
||||
assertEquals("pause=yes", loads.last()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
val disposeResult = RecordingResult()
|
||||
@@ -643,7 +659,9 @@ class ExoPlayerPluginTest {
|
||||
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core that lost its surface mid-playback with a resume still deferred.
|
||||
core.setPrivateField("desiredPaused", false)
|
||||
core.setPrivateField("cachedPaused", false)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", false)
|
||||
@@ -664,11 +682,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(true, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(true, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
|
||||
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
|
||||
// The load itself carries the hold; the stale deferred resume must not
|
||||
// undo it with a late pause write.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=yes", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
core.dispose()
|
||||
}
|
||||
@@ -677,10 +694,10 @@ class ExoPlayerPluginTest {
|
||||
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
core.setPrivateField("desiredPaused", true)
|
||||
core.setPrivateField("cachedPaused", true)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", true)
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core whose previous load was explicitly held.
|
||||
core.setPauseIntentForLoad(true)
|
||||
val plugin = reusedFallbackPlugin(activity, core)
|
||||
val result = RecordingResult()
|
||||
|
||||
@@ -695,9 +712,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(false, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(false, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
// The new load carries the play intent; the earlier hold is not replayed
|
||||
// as a separate pause write before or after it.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
|
||||
core.dispose()
|
||||
@@ -955,6 +973,14 @@ class ExoPlayerPluginTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts every native command, recording each one; a `loadfile` yields playlist entry 1. */
|
||||
private fun recordingLoads(
|
||||
loads: ConcurrentLinkedQueue<List<String>>
|
||||
): suspend (Array<String>) -> Long? = { args ->
|
||||
loads += args.toList()
|
||||
if (args.firstOrNull() == "loadfile") 1L else null
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -101,6 +101,40 @@ class GpuVoPolicyTest {
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false))
|
||||
}
|
||||
|
||||
// The per-file policies run inside on_preloaded, before mpv selects a
|
||||
// track, so the track they decide for comes from the pending selection.
|
||||
|
||||
@Test
|
||||
fun `auto selection follows mpv's pending choice, not track-list order`() {
|
||||
// Cover art first, the default-flagged feature second: mpv picks 2.
|
||||
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("auto", "2", listOf(1L, 2L)))
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", "1", listOf(1L, 2L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit vid answers on its own`() {
|
||||
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("2", pendingVid = null, videoTrackIds = listOf(1L, 2L)))
|
||||
// A user's explicit choice is never re-selected, even when mpv would
|
||||
// pick differently.
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("1", "2", listOf(1L, 2L)))
|
||||
// No such track: mpv selects nothing, so nothing is routed.
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("7", "2", listOf(1L, 2L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no video selection yields no track`() {
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("no", "1", listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "no", listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "1", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the pending-vid property the first track is the fallback`() {
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", null, listOf(1L, 2L)))
|
||||
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId(null, null, listOf(1L, 2L)))
|
||||
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", null, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cheap render tier needs a GL vo on a driver without norm16`() {
|
||||
assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false))
|
||||
|
||||
@@ -49,6 +49,22 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandWithoutNativePlayerReportsFailureInsteadOfSilentSuccess() {
|
||||
// A load that never reached mpv produces no source; answering success
|
||||
// would leave Dart waiting on a start-file that never comes.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore(null))
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("command", mapOf("args" to listOf("loadfile", "x", "replace"))), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals("COMMAND_FAILED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
|
||||
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
|
||||
@@ -758,15 +774,53 @@ class MpvPlayerPluginTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelReportsUnsupported() {
|
||||
fun setLogLevelWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "warn")), result)
|
||||
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelRejectsMissingOrNonStringLevel() {
|
||||
for (level in listOf(null, 42)) {
|
||||
val result = RecordingResult()
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(MethodCall("setLogLevel", mapOf("level" to level)), result)
|
||||
|
||||
assertEquals("INVALID_ARGS", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disposeCompletesQueuedLogLevelChangeOnceWithoutAnActiveNativePlayer() {
|
||||
val blockerStarted = CountDownLatch(1)
|
||||
val releaseBlocker = CountDownLatch(1)
|
||||
val core = testCore { _, _ ->
|
||||
blockerStarted.countDown()
|
||||
check(releaseBlocker.await(2, TimeUnit.SECONDS))
|
||||
}
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, core)
|
||||
val result = RecordingResult()
|
||||
try {
|
||||
core.setProperty("block", "value")
|
||||
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "v")), result)
|
||||
core.dispose()
|
||||
} finally {
|
||||
releaseBlocker.countDown()
|
||||
}
|
||||
awaitCompletion(result)
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(
|
||||
MethodCall("setLogLevel", mapOf("level" to "warn")),
|
||||
result
|
||||
)
|
||||
|
||||
assertEquals("UNSUPPORTED", result.errorCode)
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OsdPlanePolicyTest {
|
||||
@Test
|
||||
fun `full resolution leaves the plane tracking its view`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fractions shrink both dimensions by the same scale`() {
|
||||
assertEquals(OsdPlanePolicy.Size(1440, 810), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.75f))
|
||||
assertEquals(OsdPlanePolicy.Size(960, 540), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.5f))
|
||||
assertEquals(OsdPlanePolicy.Size(640, 360), OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odd results round down to even`() {
|
||||
// 1/3 of 1000 is 333.3: rounds to 333, then to the even 332.
|
||||
assertEquals(OsdPlanePolicy.Size(332, 250), OsdPlanePolicy.fixedSizeFor(1000, 750, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlaid-out views and nonsense scales are ignored`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(0, 0, 0.5f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 0f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1.5f))
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,13 @@
|
||||
# jni_utils.cpp caches MpvPlayer with FindClass and resolves these static callbacks
|
||||
# with GetStaticMethodID on the native event thread. R8 sees no reference to the
|
||||
# class name or the member names, so both must stay alive and un-renamed.
|
||||
# Match callback names, return type and static access without duplicating JNI
|
||||
# argument descriptors here: adding the session token made the old signatures
|
||||
# silently stop matching. Native initialization resolves every exact descriptor.
|
||||
-keep class com.edde746.plezy.libmpv.MpvPlayer {
|
||||
public static void onPropertyChanged(java.lang.String, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, boolean, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, long, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, double, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, java.lang.String, long, boolean);
|
||||
public static void onEvent(int, long, boolean, double, boolean);
|
||||
public static void onEndFile(int, long, boolean);
|
||||
public static void onLogMessage(java.lang.String, int, java.lang.String);
|
||||
public static void onHook(java.lang.String, long);
|
||||
public static void onPropertyChanged(...);
|
||||
public static void onEvent(...);
|
||||
public static void onEndFile(...);
|
||||
public static void onLogMessage(...);
|
||||
public static void onHook(...);
|
||||
}
|
||||
|
||||
@@ -7,34 +7,50 @@
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
// The session every callback below names, bound once before the thread
|
||||
// starts (event_thread_bind). Kotlin drops a callback whose session is not
|
||||
// the wrapper it published for, so a retiring core's tail — end-file,
|
||||
// property changes, a hook raised just before teardown — can never be read
|
||||
// as the successor's. L keeps this binding immutable through join, including
|
||||
// the interval after admission is revoked; callbacks never acquire L.
|
||||
static mpv_handle* thread_mpv;
|
||||
static uint64_t thread_session;
|
||||
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session) {
|
||||
thread_mpv = mpv;
|
||||
thread_session = session;
|
||||
}
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop, int64_t source_id, bool has_source_id) {
|
||||
jstring jprop = new_java_string(env, prop->name);
|
||||
jstring jvalue = NULL;
|
||||
const jboolean jhas_source_id = has_source_id ? JNI_TRUE : JNI_FALSE;
|
||||
const jlong jsession = (jlong)thread_session;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jprop, (jlong)source_id, jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jsession, jprop, (jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jprop, (jboolean) * (int*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jsession, jprop, (jboolean) * (int*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jprop, (jlong) * (int64_t*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jsession, jprop, (jlong) * (int64_t*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jprop, (jdouble) * (double*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jsession, jprop, (jdouble) * (double*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = new_java_string(env, *(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jprop, jvalue, (jlong)source_id, jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jsession, jprop, jvalue, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -47,8 +63,8 @@ static void sendEventToJava(
|
||||
JNIEnv* env, int event, int64_t source_id, bool has_source_id, double position_seconds = 0.0,
|
||||
bool has_position_seconds = false) {
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jint)event, (jlong)source_id, has_source_id ? JNI_TRUE : JNI_FALSE,
|
||||
(jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jlong)thread_session, (jint)event, (jlong)source_id,
|
||||
has_source_id ? JNI_TRUE : JNI_FALSE, (jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
|
||||
}
|
||||
|
||||
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
@@ -56,24 +72,28 @@ static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
const int reason = end_file ? end_file->reason : -1;
|
||||
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason, (jlong)source_id, end_file ? JNI_TRUE : JNI_FALSE);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
|
||||
end_file ? JNI_TRUE : JNI_FALSE);
|
||||
}
|
||||
|
||||
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
|
||||
jstring jprefix = new_java_string(env, msg->prefix);
|
||||
jstring jtext = new_java_string(env, msg->text);
|
||||
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, (jlong)thread_session, jprefix, (jint)msg->log_level, jtext);
|
||||
|
||||
if (jprefix) env->DeleteLocalRef(jprefix);
|
||||
if (jtext) env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
// A hook holds mpv (playback does not proceed) until Kotlin answers with
|
||||
// nativeHookContinue(id); MpvPlayer guarantees that answer for every hook.
|
||||
// nativeHookContinue(session, id); MpvPlayer guarantees that answer for every
|
||||
// hook it is handed, and mpv itself continues any hook still held when the
|
||||
// handle is destroyed.
|
||||
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
|
||||
jstring jname = new_java_string(env, hook->name);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, jname, (jlong)hook->id);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, (jlong)thread_session, jname, (jlong)hook->id);
|
||||
if (jname) env->DeleteLocalRef(jname);
|
||||
}
|
||||
|
||||
@@ -90,7 +110,7 @@ void* event_thread(void* arg) {
|
||||
mpv_event_property* mp_property;
|
||||
mpv_event_log_message* msg;
|
||||
|
||||
mp_event = mpv_wait_event(g_mpv, -1.0);
|
||||
mp_event = mpv_wait_event(thread_mpv, -1.0);
|
||||
|
||||
if (g_event_thread_request_exit) break;
|
||||
|
||||
@@ -99,7 +119,6 @@ void* event_thread(void* arg) {
|
||||
switch (mp_event->event_id) {
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
msg = (mpv_event_log_message*)mp_event->data;
|
||||
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
@@ -113,12 +132,10 @@ void* event_thread(void* arg) {
|
||||
mpv_event_start_file* start_file = (mpv_event_start_file*)mp_event->data;
|
||||
has_source_id = start_file != NULL;
|
||||
source_id = start_file ? start_file->playlist_entry_id : 0;
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
|
||||
break;
|
||||
case MPV_EVENT_HOOK:
|
||||
@@ -127,9 +144,8 @@ void* event_thread(void* arg) {
|
||||
case MPV_EVENT_PLAYBACK_RESTART: {
|
||||
double position_seconds = 0.0;
|
||||
const bool has_position_seconds =
|
||||
mpv_get_property(g_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
|
||||
mpv_get_property(thread_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
|
||||
std::isfinite(position_seconds);
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id, position_seconds, has_position_seconds);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Names the session the next event_thread serves. Called under L and S before
|
||||
// pthread_create publishes the binding. L prevents rebinding until that thread
|
||||
// is joined; the thread never reads the live admission slots and can safely
|
||||
// finish a callback after revocation. Callbacks must never acquire L.
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session);
|
||||
|
||||
void* event_thread(void* arg);
|
||||
|
||||
@@ -2,9 +2,46 @@
|
||||
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
extern JavaVM* g_vm;
|
||||
|
||||
// The process-global native session. One mpv_handle lives at a time; each is
|
||||
// identified by a monotonic session id handed to Kotlin by nativeCreate. Every
|
||||
// JNI entry names the session it was issued for, every callback carries the
|
||||
// session it originated from, so a retired wrapper can neither touch nor be
|
||||
// fed by its successor.
|
||||
//
|
||||
// g_session_lock (S) guards admission: write-held for publication, init and
|
||||
// revocation, read-held by every other JNI entry through its last handle use.
|
||||
// Retirement drains admitted readers and clears these slots under S, then
|
||||
// releases S BEFORE wake/join/terminate so callbacks can reenter and reject.
|
||||
// A separate lifecycle mutex (L, private to main.cpp) serializes create/init/
|
||||
// destroy through termination AND surface cleanup. Lock order is L -> S;
|
||||
// readers and event callbacks never acquire L. The event thread borrows its
|
||||
// immutable bound handle until joined, even after the admission slots clear.
|
||||
extern mpv_handle* g_mpv;
|
||||
extern uint64_t g_session;
|
||||
extern pthread_rwlock_t g_session_lock;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
|
||||
// Read-locked admission of one JNI entry to the live session. `mpv` is NULL
|
||||
// when `session` is not the live one (retired, superseded, or never created);
|
||||
// the caller then reports MPV_ERROR_UNINITIALIZED / null and touches nothing.
|
||||
// A refused entry is an expected outcome of teardown racing in-flight work,
|
||||
// not a programming error, so it never throws into Java.
|
||||
class SessionGuard {
|
||||
public:
|
||||
explicit SessionGuard(jlong session) {
|
||||
pthread_rwlock_rdlock(&g_session_lock);
|
||||
mpv = (g_mpv && g_session == (uint64_t)session) ? g_mpv : NULL;
|
||||
}
|
||||
~SessionGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionGuard(const SessionGuard&) = delete;
|
||||
SessionGuard& operator=(const SessionGuard&) = delete;
|
||||
|
||||
mpv_handle* mpv;
|
||||
};
|
||||
|
||||
@@ -50,21 +50,22 @@ void init_methods_cache(JNIEnv* env) {
|
||||
|
||||
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
|
||||
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
|
||||
// Every callback leads with the origin session (J); see event.cpp.
|
||||
mpv_MpvPlayer_onPropertyChanged_SJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SZJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;ZJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SJJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SDJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;DJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;DJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SSJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(IJZDZ)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(IJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;Ljava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(JIJZDZ)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZ)V");
|
||||
mpv_MpvPlayer_onLogMessage =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
|
||||
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(Ljava/lang/String;J)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(JLjava/lang/String;ILjava/lang/String;)V");
|
||||
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(JLjava/lang/String;J)V");
|
||||
|
||||
methods_initialized = true;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define DEBUG 1
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#if DEBUG
|
||||
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define ALOGV(...) (void)0
|
||||
#endif
|
||||
|
||||
void die(const char* msg);
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -16,6 +16,7 @@ extern "C" {
|
||||
}
|
||||
|
||||
#include "event.h"
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
@@ -24,20 +25,48 @@ extern "C" {
|
||||
void render_cleanup(JNIEnv* env);
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeCreate, jobject appctx);
|
||||
jni_func(void, nativeInit);
|
||||
jni_func(void, nativeDestroy);
|
||||
jni_func(jlong, nativeCreate, jobject appctx);
|
||||
jni_func(jint, nativeInit, jlong session);
|
||||
jni_func(void, nativeDestroy, jlong session);
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray);
|
||||
jni_func(void, nativeHookContinue, jlong id);
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring level);
|
||||
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray);
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id);
|
||||
};
|
||||
|
||||
JavaVM* g_vm;
|
||||
mpv_handle* g_mpv;
|
||||
uint64_t g_session;
|
||||
pthread_rwlock_t g_session_lock = PTHREAD_RWLOCK_INITIALIZER;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
// Lifecycle serialization (L) is separate from session admission (S).
|
||||
// Only nativeCreate/nativeInit/nativeDestroy acquire L, always before S.
|
||||
static pthread_mutex_t lifecycle_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static uint64_t next_session = 0;
|
||||
static pthread_t event_thread_id;
|
||||
static std::mutex g_lifecycle_mutex;
|
||||
static bool event_thread_started = false;
|
||||
|
||||
// Held through retirement, termination and surface cleanup, so a successor
|
||||
// cannot overlap or rebind the retiring event thread. Callbacks never take L.
|
||||
class LifecycleGuard {
|
||||
public:
|
||||
LifecycleGuard() { pthread_mutex_lock(&lifecycle_lock); }
|
||||
~LifecycleGuard() { pthread_mutex_unlock(&lifecycle_lock); }
|
||||
LifecycleGuard(const LifecycleGuard&) = delete;
|
||||
LifecycleGuard& operator=(const LifecycleGuard&) = delete;
|
||||
};
|
||||
|
||||
// Bounded admission-write scope (S), always nested inside L. Never hold this
|
||||
// while joining or terminating: an event callback can reenter SessionGuard.
|
||||
class SessionWriteGuard {
|
||||
public:
|
||||
SessionWriteGuard() { pthread_rwlock_wrlock(&g_session_lock); }
|
||||
~SessionWriteGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionWriteGuard(const SessionWriteGuard&) = delete;
|
||||
SessionWriteGuard& operator=(const SessionWriteGuard&) = delete;
|
||||
};
|
||||
|
||||
static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
setlocale(LC_NUMERIC, "C");
|
||||
@@ -50,40 +79,69 @@ static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
init_methods_cache(env);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCreate, jobject appctx) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
prepare_environment(env, appctx);
|
||||
// Caller holds L and S(write). Acquiring S drained every admitted JNI reader;
|
||||
// revoke further admission before letting an in-flight callback finish.
|
||||
// The lifecycle owner retains the handle and immutable event-thread binding.
|
||||
static mpv_handle* revoke_locked() {
|
||||
mpv_handle* local_mpv = g_mpv;
|
||||
g_mpv = NULL;
|
||||
g_session = 0;
|
||||
if (event_thread_started) g_event_thread_request_exit = true;
|
||||
return local_mpv;
|
||||
}
|
||||
|
||||
mpv_handle* leaked_mpv = NULL;
|
||||
if (g_mpv) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
leaked_mpv = g_mpv;
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(leaked_mpv);
|
||||
// Caller holds L, but NOT S. The event thread is the revoked handle's only
|
||||
// remaining borrower; a rejected hook can take S and return during this join.
|
||||
static void destroy_locked(JNIEnv* env, mpv_handle* local_mpv) {
|
||||
// Configuration (including an invalid initial log level) can fail before
|
||||
// nativeInit starts the event thread.
|
||||
if (event_thread_started) {
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
g_mpv = NULL;
|
||||
mpv_terminate_destroy(leaked_mpv);
|
||||
render_cleanup(env);
|
||||
event_thread_started = false;
|
||||
}
|
||||
// The MediaCodec VO can retain the Surface until final decoder teardown.
|
||||
// Keep its JNI refs alive for the entire blocking termination.
|
||||
mpv_terminate_destroy(local_mpv);
|
||||
render_cleanup(env);
|
||||
}
|
||||
|
||||
jni_func(jlong, nativeCreate, jobject appctx) {
|
||||
LifecycleGuard lock;
|
||||
mpv_handle* predecessor;
|
||||
{
|
||||
SessionWriteGuard admission;
|
||||
predecessor = revoke_locked();
|
||||
}
|
||||
if (predecessor) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
destroy_locked(env, predecessor);
|
||||
}
|
||||
|
||||
// Do not rewrite process-wide JNI state while the predecessor can callback.
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
SessionWriteGuard admission;
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv) {
|
||||
die("context init failed");
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
g_session = ++next_session;
|
||||
|
||||
mpv_request_log_messages(g_mpv, "v");
|
||||
mpv_request_log_messages(g_mpv, "warn");
|
||||
return (jlong)g_session;
|
||||
}
|
||||
|
||||
jni_func(void, nativeInit) {
|
||||
if (!g_mpv) {
|
||||
die("mpv is not created");
|
||||
return;
|
||||
}
|
||||
jni_func(jint, nativeInit, jlong session) {
|
||||
LifecycleGuard lock;
|
||||
SessionWriteGuard admission;
|
||||
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
if (mpv_initialize(g_mpv) < 0) {
|
||||
die("mpv init failed");
|
||||
return;
|
||||
const int result = mpv_initialize(g_mpv);
|
||||
if (result < 0) {
|
||||
ALOGE("mpv_initialize returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land
|
||||
@@ -93,41 +151,53 @@ jni_func(void, nativeInit) {
|
||||
mpv_hook_add(g_mpv, 0, "on_preloaded", 0);
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
event_thread_bind(g_mpv, g_session);
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
|
||||
die("thread create failed");
|
||||
return;
|
||||
return MPV_ERROR_GENERIC;
|
||||
}
|
||||
event_thread_started = true;
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
return 0;
|
||||
}
|
||||
|
||||
jni_func(void, nativeDestroy) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
if (!g_mpv) {
|
||||
ALOGV("mpv destroy called but it's already destroyed");
|
||||
return;
|
||||
jni_func(void, nativeDestroy, jlong session) {
|
||||
LifecycleGuard lock;
|
||||
mpv_handle* local_mpv;
|
||||
{
|
||||
SessionWriteGuard admission;
|
||||
// A wrapper whose session a later nativeCreate already retired has nothing
|
||||
// left to destroy; the successor is not its to touch.
|
||||
if (!g_mpv || g_session != (uint64_t)session) return;
|
||||
local_mpv = revoke_locked();
|
||||
}
|
||||
mpv_handle* local_mpv = g_mpv;
|
||||
|
||||
g_event_thread_request_exit = true;
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
|
||||
g_mpv = NULL;
|
||||
|
||||
// The MediaCodec VO can retain the Surface until final decoder teardown.
|
||||
// Keep its JNI refs alive for the entire blocking termination.
|
||||
mpv_terminate_destroy(local_mpv);
|
||||
render_cleanup(env);
|
||||
destroy_locked(env, local_mpv);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring jlevel) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const std::string level = java_string_to_utf8(env, jlevel);
|
||||
if (env->ExceptionCheck()) return MPV_ERROR_NOMEM;
|
||||
const int result = mpv_request_log_messages(guard.mpv, level.c_str());
|
||||
if (result < 0) ALOGE("mpv_request_log_messages returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Runs a command synchronously. Returns the negative mpv error on failure,
|
||||
// the `playlist_entry_id` a `loadfile` created (always > 0), or 0 for a
|
||||
// command that succeeded without one. A retired session reports
|
||||
// MPV_ERROR_UNINITIALIZED like any other rejected command.
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= (int)ARRAYLEN(arguments)) {
|
||||
die("too many command arguments");
|
||||
return;
|
||||
return MPV_ERROR_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
std::vector<std::string> storage;
|
||||
@@ -139,10 +209,32 @@ jni_func(void, nativeCommand, jobjectArray jarray) {
|
||||
env->DeleteLocalRef(jarg);
|
||||
}
|
||||
|
||||
mpv_command(g_mpv, arguments);
|
||||
mpv_node result{};
|
||||
const int status = mpv_command_ret(guard.mpv, arguments, &result);
|
||||
if (status < 0) {
|
||||
ALOGE("mpv_command(%s) returned error %s", len > 0 ? arguments[0] : "", mpv_error_string(status));
|
||||
return status;
|
||||
}
|
||||
|
||||
jlong playlist_entry_id = 0;
|
||||
const mpv_node_list* map = result.format == MPV_FORMAT_NODE_MAP ? result.u.list : nullptr;
|
||||
if (map && map->keys && map->values) {
|
||||
for (int i = 0; i < map->num; ++i) {
|
||||
if (map->keys[i] && strcmp(map->keys[i], "playlist_entry_id") == 0 && map->values[i].format == MPV_FORMAT_INT64) {
|
||||
playlist_entry_id = (jlong)map->values[i].u.int64;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
mpv_free_node_contents(&result);
|
||||
return playlist_entry_id;
|
||||
}
|
||||
|
||||
jni_func(void, nativeHookContinue, jlong id) {
|
||||
if (!g_mpv) return;
|
||||
mpv_hook_continue(g_mpv, (uint64_t)id);
|
||||
// A continuation for a revoked session is dropped, even while its handle is
|
||||
// still retiring. Destruction releases any outstanding hooks; the successor
|
||||
// must never receive an old hook id.
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
mpv_hook_continue(guard.mpv, (uint64_t)id);
|
||||
}
|
||||
|
||||
@@ -9,49 +9,55 @@
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
|
||||
jni_func(jint, nativeSetOptionString, jlong session, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue);
|
||||
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format);
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
|
||||
CHECK_MPV_INIT_RET(0);
|
||||
jni_func(jint, nativeSetOptionString, jlong session, jstring joption, jstring jvalue) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* option = env->GetStringUTFChars(joption, NULL);
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
|
||||
int result = mpv_set_option_string(g_mpv, option, value.c_str());
|
||||
int result = mpv_set_option_string(guard.mpv, option, value.c_str());
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
// A retired session reads as "no value" and writes nowhere: the in-flight
|
||||
// hook handler of a torn-down player must not learn about, or reconfigure,
|
||||
// its successor.
|
||||
static int common_get_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* output) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(g_mpv, prop, format, output);
|
||||
int result = mpv_get_property(guard.mpv, prop, format, output);
|
||||
if (result < 0) ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
static int common_set_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* value) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(g_mpv, prop, format, value);
|
||||
int result = mpv_set_property(guard.mpv, prop, format, value);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
@@ -59,57 +65,58 @@ static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format
|
||||
return result;
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty) {
|
||||
char* value;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
jstring jvalue = new_java_string(env, value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue) {
|
||||
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue) {
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
const char* value_ptr = value.c_str();
|
||||
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
}
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
const char* prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
|
||||
int result = mpv_observe_property(guard.mpv, 0, prop, (mpv_format)format);
|
||||
if (result < 0) ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(property, prop);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeAttachSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachSurface);
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachOsdSurface);
|
||||
jni_func(void, nativeAttachSurface, jlong session, jobject surface_);
|
||||
jni_func(void, nativeDetachSurface, jlong session);
|
||||
jni_func(void, nativeAttachOsdSurface, jlong session, jobject surface_);
|
||||
jni_func(void, nativeDetachOsdSurface, jlong session);
|
||||
};
|
||||
|
||||
static jobject surface;
|
||||
|
||||
jni_func(void, nativeAttachSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(void, nativeAttachSurface, jlong session, jobject surface_) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
|
||||
surface = env->NewGlobalRef(surface_);
|
||||
if (!surface) {
|
||||
@@ -23,15 +24,16 @@ jni_func(void, nativeAttachSurface, jobject surface_) {
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(surface);
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
int result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(void, nativeDetachSurface, jlong session) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
int result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(surface);
|
||||
@@ -42,8 +44,9 @@ static jobject osd_surface;
|
||||
|
||||
// The OSD plane of vo=mediacodec. Same lifetime rules as the video surface:
|
||||
// the global ref must outlive the VO, so detach only after vo has been unset.
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(void, nativeAttachOsdSurface, jlong session, jobject surface_) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
|
||||
osd_surface = env->NewGlobalRef(surface_);
|
||||
if (!osd_surface) {
|
||||
@@ -51,22 +54,25 @@ jni_func(void, nativeAttachOsdSurface, jobject surface_) {
|
||||
return;
|
||||
}
|
||||
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
}
|
||||
|
||||
jni_func(void, nativeDetachOsdSurface) {
|
||||
CHECK_MPV_INIT();
|
||||
if (!osd_surface) return;
|
||||
jni_func(void, nativeDetachOsdSurface, jlong session) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv || !osd_surface) return;
|
||||
|
||||
int64_t wid = 0;
|
||||
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
|
||||
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
|
||||
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
}
|
||||
|
||||
// Caller holds L after revoking admission, draining JNI readers, joining the
|
||||
// event thread and terminating mpv. S is not held. L prevents a successor from
|
||||
// publishing new surfaces until these retiring references have been released.
|
||||
void render_cleanup(JNIEnv* env) {
|
||||
if (surface) {
|
||||
env->DeleteGlobalRef(surface);
|
||||
|
||||
@@ -20,7 +20,19 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
class MpvPlayer private constructor() : AutoCloseable {
|
||||
/**
|
||||
* Kotlin face of the process-global native player. Each instance is bound to
|
||||
* one immutable native [session]; the JNI layer refuses any call that names
|
||||
* a session it has retired and Kotlin drops any callback stamped with a
|
||||
* session other than the published wrapper's. Together they make a retired
|
||||
* wrapper's in-flight work (a hook handler still reshaping tracks, a queued
|
||||
* property write, a late hook continuation) inert against its successor,
|
||||
* and keep a retiring core's tail events out of the successor's flows.
|
||||
*/
|
||||
class MpvPlayer private constructor(
|
||||
/** Identity of the native session this wrapper owns; see nativeCreate. */
|
||||
val session: Long
|
||||
) : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** Upper bound for a hook handler; longer stalls playback start. */
|
||||
@@ -43,64 +55,76 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
configure: MpvPlayerConfig.() -> Unit = {}
|
||||
): MpvPlayer = withContext(Dispatchers.IO) {
|
||||
checkNotMainThread("MPV initialization")
|
||||
val player = MpvPlayer()
|
||||
// Atomically replace; mark old as closed so its background close() skips nativeDestroy
|
||||
instance.getAndSet(player)?.also { it.closed = true }
|
||||
// nativeCreate's safety net handles any leaked native session
|
||||
// Retires any leaked predecessor natively and mints the new session.
|
||||
val player = MpvPlayer(nativeCreate(context.applicationContext))
|
||||
synchronized(instance) {
|
||||
val current = instance.get()
|
||||
// Sessions are monotonic: a concurrent create that already published a
|
||||
// newer one has retired this native session underneath us.
|
||||
if (current != null && current.session > player.session) {
|
||||
player.closed = true
|
||||
throw MpvException("MPV session ${player.session} was superseded before it initialized")
|
||||
}
|
||||
instance.set(player)
|
||||
// The predecessor's native session is gone; its close() finds nothing to destroy.
|
||||
current?.closed = true
|
||||
}
|
||||
try {
|
||||
nativeCreate(context.applicationContext)
|
||||
MpvPlayerConfig().apply(configure)
|
||||
nativeInit()
|
||||
MpvPlayerConfig(player.session).apply(configure)
|
||||
val result = nativeInit(player.session)
|
||||
if (result < 0) throw MpvException("Failed to initialize mpv: error $result")
|
||||
ensureActive()
|
||||
player
|
||||
} catch (e: Throwable) {
|
||||
instance.compareAndSet(player, null)
|
||||
try {
|
||||
nativeDestroy()
|
||||
} catch (_: Throwable) {}
|
||||
player.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// JNI callbacks — called from native event thread
|
||||
// JNI callbacks — called from the native event thread. Each names the
|
||||
// session it originated from; only the wrapper published for that
|
||||
// session may receive it.
|
||||
|
||||
private fun target(session: Long): MpvPlayer? = instance.get()?.takeIf { it.session == session }
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEvent(
|
||||
session: Long,
|
||||
eventId: Int,
|
||||
sourceId: Long,
|
||||
hasSourceId: Boolean,
|
||||
@@ -112,12 +136,12 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
sourceId.takeIf { hasSourceId },
|
||||
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
|
||||
) ?: return
|
||||
instance.get()?.rawEvents?.trySend(event)
|
||||
target(session)?.rawEvents?.trySend(event)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawEvents?.trySend(
|
||||
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawEvents?.trySend(
|
||||
MpvEvent.EndFile(
|
||||
EndFileReason.fromId(reason),
|
||||
sourceId.takeIf { hasSourceId }
|
||||
@@ -126,17 +150,18 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onLogMessage(prefix: String, level: Int, text: String) {
|
||||
fun onLogMessage(session: Long, prefix: String, level: Int, text: String) {
|
||||
val logLevel = LogLevel.fromNative(level) ?: return
|
||||
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
target(session)?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onHook(name: String, id: Long) {
|
||||
val player = instance.get()
|
||||
fun onHook(session: Long, name: String, id: Long) {
|
||||
val player = target(session)
|
||||
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
|
||||
// Nobody will answer: release mpv rather than leave it waiting.
|
||||
nativeHookContinue(id)
|
||||
// Nobody will answer: release mpv rather than leave it waiting. The
|
||||
// native side drops this if the session has since been retired.
|
||||
nativeHookContinue(session, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,47 +171,62 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native declarations — private to avoid internal name mangling
|
||||
// JNI native declarations — private to avoid internal name mangling.
|
||||
// Every entry after nativeCreate names the session it acts for; the
|
||||
// native side refuses a retired one.
|
||||
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context)
|
||||
/** Retires any leaked native session and returns the new session's identity. */
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context): Long
|
||||
|
||||
@JvmStatic private external fun nativeInit()
|
||||
/** 0 on success, otherwise a negative mpv error. */
|
||||
@JvmStatic private external fun nativeInit(session: Long): Int
|
||||
|
||||
@JvmStatic private external fun nativeDestroy()
|
||||
@JvmStatic private external fun nativeDestroy(session: Long)
|
||||
|
||||
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
|
||||
/** Negative mpv error, the playlist entry id a `loadfile` created, or 0 when the command returned none. */
|
||||
@JvmStatic private external fun nativeCommand(session: Long, cmd: Array<out String>): Long
|
||||
|
||||
@JvmStatic private external fun nativeHookContinue(id: Long)
|
||||
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
|
||||
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
|
||||
|
||||
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
|
||||
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeDetachSurface()
|
||||
@JvmStatic private external fun nativeAttachSurface(session: Long, surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
|
||||
@JvmStatic private external fun nativeDetachSurface(session: Long)
|
||||
|
||||
@JvmStatic private external fun nativeDetachOsdSurface()
|
||||
@JvmStatic private external fun nativeAttachOsdSurface(session: Long, surface: Surface)
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
|
||||
@JvmStatic private external fun nativeDetachOsdSurface(session: Long)
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
|
||||
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
|
||||
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
|
||||
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
|
||||
|
||||
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
|
||||
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
|
||||
|
||||
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
|
||||
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
|
||||
|
||||
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
|
||||
|
||||
internal fun requestLogMessages(session: Long, level: String) {
|
||||
checkNotMainThread("MPV log level change")
|
||||
val result = nativeSetLogLevel(session, level)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set log level: error $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The native event thread hands everything to unbounded channels: trySend
|
||||
@@ -222,7 +262,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
pumpScope.launch {
|
||||
for (hook in rawHooks) {
|
||||
try {
|
||||
val handler = hookHandler
|
||||
val handler = if (closed) null else hookHandler
|
||||
if (handler != null) {
|
||||
withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) }
|
||||
?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback")
|
||||
@@ -230,7 +270,10 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e)
|
||||
} finally {
|
||||
if (!closed) nativeHookContinue(hook.id)
|
||||
// Validated natively against this session, atomically with its
|
||||
// retirement: a continuation that lost the race is dropped, never
|
||||
// delivered to a successor's core.
|
||||
nativeHookContinue(session, hook.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,89 +287,103 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
// Commands
|
||||
|
||||
suspend fun command(vararg args: String) {
|
||||
/**
|
||||
* Runs an mpv command. `loadfile` returns the id of the playlist entry it created — the
|
||||
* `sourceId` carried by that source's start-file / playback-restart / end-file events; every
|
||||
* other command returns null. A command mpv rejects throws [MpvException]: a rejected load
|
||||
* never produces a source, so the caller must not wait for one.
|
||||
*/
|
||||
suspend fun command(vararg args: String): Long? {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeCommand(args) }
|
||||
val status = withContext(Dispatchers.IO) { nativeCommand(session, args) }
|
||||
if (status < 0) throw MpvException("Command '${args.firstOrNull() ?: ""}' failed: error $status")
|
||||
return if (status > 0) status else null
|
||||
}
|
||||
|
||||
/** Called on the core's ordered IO writer, without suspending between writes. */
|
||||
fun setLogLevel(level: String) {
|
||||
checkNotClosed()
|
||||
requestLogMessages(session, level)
|
||||
}
|
||||
|
||||
// Surface — not suspend, called from SurfaceHolder.Callback
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachSurface(surface)
|
||||
nativeAttachSurface(session, surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachSurface()
|
||||
nativeDetachSurface(session)
|
||||
}
|
||||
|
||||
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
|
||||
fun attachOsdSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachOsdSurface(surface)
|
||||
nativeAttachOsdSurface(session, surface)
|
||||
}
|
||||
|
||||
fun detachOsdSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachOsdSurface()
|
||||
nativeDetachOsdSurface(session)
|
||||
}
|
||||
|
||||
// Property getters
|
||||
|
||||
suspend fun getInt(name: String): Int? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getDouble(name: String): Double? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getFlag(name: String): Boolean? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getString(name: String): String? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(session, name) }
|
||||
}
|
||||
|
||||
// Property setters
|
||||
|
||||
suspend fun setProperty(name: String, value: Int) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Double) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Boolean) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(session, name, value) }
|
||||
}
|
||||
|
||||
// Property observation
|
||||
|
||||
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, format.nativeValue)
|
||||
nativeObserveProperty(session, name, format.nativeValue)
|
||||
return propertyFlow.filter { it.name == name }
|
||||
}
|
||||
|
||||
fun observeFlag(name: String): Flow<Boolean> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Flag.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Flag>()
|
||||
.filter { it.name == name }
|
||||
@@ -335,7 +392,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeInt(name: String): Flow<Long> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Int64.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Int64>()
|
||||
.filter { it.name == name }
|
||||
@@ -344,7 +401,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeDouble(name: String): Flow<Double> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Double.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Double>()
|
||||
.filter { it.name == name }
|
||||
@@ -353,7 +410,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeString(name: String): Flow<String> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.String.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Str>()
|
||||
.filter { it.name == name }
|
||||
@@ -370,16 +427,18 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
* Android main thread so a slow vendor decoder cannot stall the UI.
|
||||
*/
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
checkNotMainThread("MPV destruction")
|
||||
closed = true
|
||||
// Only destroy native if we're still the active player.
|
||||
// If create() already replaced us, nativeCreate's safety net handles native cleanup.
|
||||
if (instance.compareAndSet(this, null)) {
|
||||
nativeDestroy()
|
||||
synchronized(instance) {
|
||||
if (closed) return
|
||||
checkNotMainThread("MPV destruction")
|
||||
closed = true
|
||||
hookHandler = null
|
||||
instance.compareAndSet(this, null)
|
||||
}
|
||||
// After nativeDestroy no callback can produce: closing the channels
|
||||
// lets each pump drain what is already queued and then complete.
|
||||
// A no-op natively when a later create() already retired this session;
|
||||
// the successor is never ours to destroy.
|
||||
nativeDestroy(session)
|
||||
// After nativeDestroy no callback can produce for this session: closing
|
||||
// the channels lets each pump drain what is already queued and complete.
|
||||
rawEvents.close()
|
||||
rawHooks.close()
|
||||
rawPropertyChanges.close()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvPlayerConfig internal constructor() {
|
||||
/** Pre-initialize configuration of one native session; every write names it. */
|
||||
class MpvPlayerConfig internal constructor(private val session: Long) {
|
||||
fun setOption(name: String, value: String) {
|
||||
val result = MpvPlayer.setOptionString(name, value)
|
||||
val result = MpvPlayer.setOptionString(session, name, value)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set option '$name' to '$value': error $result")
|
||||
}
|
||||
}
|
||||
|
||||
fun setLogLevel(level: String) {
|
||||
MpvPlayer.requestLogMessages(session, level)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- os_media_controls (0.2.4):
|
||||
- Flutter
|
||||
- universal_gamepad (1.5.8):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
universal_gamepad:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
|
||||
|
||||
PODFILE CHECKSUM: 0adb115f74a3cde46cf0e15649428c9b4e6975a0
|
||||
|
||||
@@ -802,7 +802,7 @@
|
||||
repositoryURL = "https://github.com/edde746/mpv-build";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79;
|
||||
revision = 3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
|
||||
"revision" : "3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
|
||||
"revision" : "3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -232,6 +234,14 @@ KeyEventResult _handleInputKey({
|
||||
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
|
||||
}
|
||||
|
||||
// A TV back press this field already claimed on KeyDown (its own onBack
|
||||
// below, or the host closing a native session) leaves the matching KeyUp
|
||||
// in flight: swallow it here regardless of onBack, so a field without one
|
||||
// never lets the KeyUp bubble to an ancestor that acts on KeyUp.
|
||||
if (event.logicalKey.isBackKey && PlatformDetector.isTV() && BackKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
return finish(KeyEventResult.handled, 'suppressed-back');
|
||||
}
|
||||
|
||||
if (onBack != null && event.logicalKey.isBackKey) {
|
||||
// On TV the native text-input path can swallow the matching KeyUp (the
|
||||
// closing IME session eats it), so back fires on KeyDown — the same
|
||||
@@ -239,7 +249,6 @@ KeyEventResult _handleInputKey({
|
||||
// mark included so a parallel back dispatch still dedupes. Elsewhere the
|
||||
// shared handler's KeyUp semantics apply.
|
||||
if (PlatformDetector.isTV()) {
|
||||
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
|
||||
if (event is KeyDownEvent) {
|
||||
BackKeyCoordinator.markHandled();
|
||||
onBack();
|
||||
@@ -792,7 +801,29 @@ class _FocusableTextInputHost extends StatefulWidget {
|
||||
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
enum _NativeTextInputEndReason {
|
||||
dismissed,
|
||||
completed,
|
||||
explicitClose,
|
||||
restart,
|
||||
focusLost,
|
||||
configurationChanged,
|
||||
disposed,
|
||||
}
|
||||
|
||||
/// Kept after dismissal so an IME action already in flight can still complete
|
||||
/// this session, but never a replacement session on the same field.
|
||||
class _NativeTextInputSession {
|
||||
final FocusNode focusNode;
|
||||
_NativeTextInputEndReason? endReason;
|
||||
bool keyboardWasVisible = false;
|
||||
|
||||
_NativeTextInputSession(this.focusNode);
|
||||
|
||||
bool get isActive => endReason == null;
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with WidgetsBindingObserver {
|
||||
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
@@ -806,10 +837,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
bool _suppressTvKeyboardAutoOpen = false;
|
||||
bool _hasSeenTvKeyboardFocus = false;
|
||||
bool _suppressTvKeyboardForCurrentFocus = false;
|
||||
bool _nativeTextInputActivated = false;
|
||||
_NativeTextInputSession? _nativeTextInputSession;
|
||||
int _nativeTextInputGeneration = 0;
|
||||
ui.FlutterView? _nativeTextInputView;
|
||||
bool _hasSeenNativeTextInputFocus = false;
|
||||
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||
bool _nativeTextInputCompletionHandled = false;
|
||||
|
||||
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
|
||||
|
||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
@@ -820,6 +854,40 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
widget.input.tvTextInputController?._attach(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_nativeTextInputView != null) {
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
_nativeTextInputSession?.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final session = _nativeTextInputSession;
|
||||
if (!mounted || session == null || !session.isActive || !session.focusNode.hasFocus) return;
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
session.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
return;
|
||||
}
|
||||
if (view.viewInsets.bottom > 0) {
|
||||
session.keyboardWasVisible = true;
|
||||
} else if (session.keyboardWasVisible) {
|
||||
// Leanback can consume Back and hide without closing the connection or
|
||||
// sending performAction. Zero before any visible metrics is not a hide:
|
||||
// the IME may still be opening, or this may be a hardware keyboard.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -836,13 +904,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_hasSeenTvKeyboardFocus = false;
|
||||
_suppressTvKeyboardForCurrentFocus = false;
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
|
||||
_nativeTextInputActivated = false;
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior ||
|
||||
oldWidget.input.enabled != widget.input.enabled ||
|
||||
oldWidget.input.controller != widget.input.controller ||
|
||||
oldWidget.input.keyboardType != widget.input.keyboardType ||
|
||||
oldWidget.input.maxLines != widget.input.maxLines) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
@@ -854,6 +926,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.input.tvTextInputController?._detach(this);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.disposed, rebuild: false);
|
||||
_restoreInstalledHandler();
|
||||
// The keyboard is a navigator route — it must not outlive the field that
|
||||
// opened it (e.g. a form section swapped out while the keyboard is up).
|
||||
@@ -895,11 +968,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final scope = node.enclosingScope;
|
||||
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
final generation = _nativeTextInputGeneration;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || generation != _nativeTextInputGeneration) return;
|
||||
final target = _installedFocusNode;
|
||||
if (target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
// Set before requesting focus so the resulting focus-change callback
|
||||
// cannot reopen the keyboard we were just dismissed out of.
|
||||
@@ -913,7 +987,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
||||
if (!focused) {
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
return;
|
||||
}
|
||||
if (_suppressNativeTextInputForCurrentFocus) return;
|
||||
@@ -929,47 +1003,109 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
case TvTextInputAutoOpenBehavior.automatic:
|
||||
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenNativeTextInputFocus) {
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
return;
|
||||
}
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.never:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputActivated(bool activated) {
|
||||
if (_nativeTextInputActivated == activated) return;
|
||||
if (activated) _nativeTextInputCompletionHandled = false;
|
||||
if (!mounted) {
|
||||
_nativeTextInputActivated = activated;
|
||||
void _beginNativeTextInput() {
|
||||
final node = _installedFocusNode;
|
||||
if (_nativeTextInputActivated || node == null || !node.hasFocus) return;
|
||||
_nativeTextInputGeneration++;
|
||||
_nativeTextInputSession = _NativeTextInputSession(node);
|
||||
if (!PlatformDetector.isAppleTV() && defaultTargetPlatform == TargetPlatform.android) {
|
||||
// Observe the owning view, not MediaQuery: Scaffold removes the bottom
|
||||
// inset before building its body. A focus handoff can keep the same IME
|
||||
// visible, so sample the current view rather than wait for another show.
|
||||
_nativeTextInputView = View.of(context);
|
||||
_nativeTextInputSession!.keyboardWasVisible = _nativeTextInputView!.viewInsets.bottom > 0;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
_syncNativeTextInputFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _endNativeTextInput(_NativeTextInputEndReason reason, {bool rebuild = true}) {
|
||||
final session = _nativeTextInputSession;
|
||||
if (session == null) {
|
||||
if (reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.configurationChanged ||
|
||||
reason == _NativeTextInputEndReason.disposed) {
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() => _nativeTextInputActivated = activated);
|
||||
if (session.endReason == reason) return;
|
||||
final wasActive = session.isActive;
|
||||
session.endReason = reason;
|
||||
session.keyboardWasVisible = false;
|
||||
_nativeTextInputGeneration++;
|
||||
if (_nativeTextInputView != null) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_nativeTextInputView = null;
|
||||
}
|
||||
if (reason == _NativeTextInputEndReason.dismissed ||
|
||||
reason == _NativeTextInputEndReason.completed ||
|
||||
reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.restart) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
}
|
||||
_setNativeTextInputFocused(false);
|
||||
if (wasActive && rebuild && mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _scheduleNativeTextInputReactivation() {
|
||||
final generation = _nativeTextInputGeneration;
|
||||
final node = _installedFocusNode;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(node, _installedFocusNode) ||
|
||||
node?.hasFocus != true) {
|
||||
return;
|
||||
}
|
||||
_activateNativeTextInput();
|
||||
});
|
||||
}
|
||||
|
||||
void _activateNativeTextInput() {
|
||||
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
}
|
||||
|
||||
VoidCallback? get _effectiveOnEditingComplete {
|
||||
final input = widget.input;
|
||||
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
|
||||
return _handleNativeEditingComplete;
|
||||
final session = _nativeTextInputSession;
|
||||
return () => _handleNativeEditingComplete(session);
|
||||
}
|
||||
|
||||
void _handleNativeEditingComplete() {
|
||||
if (_nativeTextInputCompletionHandled) return;
|
||||
_nativeTextInputCompletionHandled = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
void _handleNativeEditingComplete(_NativeTextInputSession? session) {
|
||||
if (!mounted ||
|
||||
session == null ||
|
||||
!identical(session, _nativeTextInputSession) ||
|
||||
!session.focusNode.hasFocus ||
|
||||
!widget.input.enabled ||
|
||||
!widget.input._usesNativeTvKeyboard) {
|
||||
return;
|
||||
}
|
||||
// Hide and performAction are separate platform notifications. A hide is
|
||||
// not completion, but must not consume a real Done/Previous delivered for
|
||||
// this same connection before EditableText detaches it at rebuild. Accept
|
||||
// that action once even if metrics won the race; every other terminal
|
||||
// reason (or a successor session) makes the callback stale.
|
||||
if (!session.isActive && session.endReason != _NativeTextInputEndReason.dismissed) return;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.completed);
|
||||
|
||||
final input = widget.input;
|
||||
final callback = input._effectiveOnEditingComplete;
|
||||
@@ -1139,9 +1275,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
/// that must show results instead of the keyboard). Suppresses auto-reopen
|
||||
/// so a field that keeps or regains focus does not relaunch it.
|
||||
void _dismissTvKeyboard() {
|
||||
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
|
||||
// No-op for the Flutter presentation when no overlay is up: setting its
|
||||
@@ -1161,12 +1296,20 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
final generation = _nativeTextInputGeneration;
|
||||
focusNode.requestFocus();
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(focusNode, _installedFocusNode) ||
|
||||
focusNode.hasFocus ||
|
||||
_tvKeyboardOpen ||
|
||||
_tvKeyboardOpenScheduled) {
|
||||
return;
|
||||
}
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
});
|
||||
@@ -1184,11 +1327,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
return;
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
final generation = _nativeTextInputGeneration;
|
||||
// Focus lands in FocusManager's microtask. Activating before that would be
|
||||
// undone by the focus sync this frame's build already scheduled, which
|
||||
// deactivates an unfocused field.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
|
||||
if (mounted &&
|
||||
generation == _nativeTextInputGeneration &&
|
||||
identical(focusNode, _installedFocusNode) &&
|
||||
focusNode.hasFocus) {
|
||||
_openTextInputForFocusedField();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1238,8 +1387,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
// keyboard while Flutter focus stayed on the field. Restore the
|
||||
// read-only gate so this press navigates Flutter instead of reopening
|
||||
// the input connection.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(
|
||||
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
|
||||
);
|
||||
activateNativeTextInput = true;
|
||||
if (event.logicalKey.isBackKey) {
|
||||
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
||||
@@ -1247,30 +1397,28 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.isTvSelectEvent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey.isBackKey) {
|
||||
// Android: a healthy IME consumes Back to dismiss itself before the
|
||||
// app ever sees it. One arriving here means the keyboard is already
|
||||
// gone (or its key session is broken and MainActivity's repair budget
|
||||
// ran out): close the session and consume the press so it cannot also
|
||||
// pop the route underneath.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
// ran out): close the session and claim the whole press — coordinator
|
||||
// mark so a same-press platform popRoute dedupes, suppressor armed so
|
||||
// the matching KeyUp (delivered to this node with the session closed,
|
||||
// or to an ancestor acting on KeyUp) cannot pop the route underneath.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
BackKeyCoordinator.markHandled();
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.isTvSelectEvent) {
|
||||
// Android: Select on a field whose keyboard was dismissed re-raises
|
||||
// it (EditText parity). Toggle the connection so the engine issues a
|
||||
// fresh TextInput.show; MainActivity's show-retry covers the
|
||||
// served-view race (#1051/#1079).
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_endNativeTextInput(_NativeTextInputEndReason.restart);
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Android arrows fall through deliberately: a healthy visible IME
|
||||
@@ -1311,7 +1459,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
void _restoreInstalledHandler() {
|
||||
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
||||
_setNativeTextInputFocused(false);
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final node = _installedFocusNode;
|
||||
if (node != null) {
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
|
||||
"playbackCancelled": "Oynatma ləğv edildi.",
|
||||
"playbackFailed": "Oynatma başladılarkən xəta.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fayl məlumatı yüklənərkən xəta: ${error}",
|
||||
"errorLoadingSeries": "Serial yüklənərkən xəta",
|
||||
"musicNotSupported": "Musiqi oynatması hələ dəstəklənmir",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Rele serveri vaxtında cavab vermədi",
|
||||
"connectionLost": "Bağlantı seans hazır olmamış kəsildi",
|
||||
"invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi",
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi"
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr sessiya kukisi yaratmadı",
|
||||
"freshCookieRejected": "Seerr yeni sessiya kukisini rədd etdi",
|
||||
"noUserInformation": "Seerr istifadəçi məlumatlarını qaytarmadı",
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Xidmətlər",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
|
||||
"playbackCancelled": "Възпроизвеждането беше отменено.",
|
||||
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
|
||||
"errorLoadingSeries": "Грешка при зареждане на сериала",
|
||||
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Релейният сървър не отговори навреме",
|
||||
"connectionLost": "Връзката се затвори, преди сесията да е готова",
|
||||
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
|
||||
"sessionEnded": "Организаторът прекрати сесията"
|
||||
"sessionEnded": "Организаторът прекрати сесията",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
|
||||
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
|
||||
"noUserInformation": "Seerr не върна информация за потребителя",
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
|
||||
"playbackCancelled": "Afspilningen blev annulleret.",
|
||||
"playbackFailed": "Afspilningen kunne ikke startes.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
|
||||
"errorLoadingSeries": "Fejl ved indlæsning af serie",
|
||||
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Relayserveren svarede ikke i tide",
|
||||
"connectionLost": "Forbindelsen blev lukket, før sessionen var klar",
|
||||
"invalidRelayResponse": "Relayserveren sendte et uventet svar",
|
||||
"sessionEnded": "Værten afsluttede sessionen"
|
||||
"sessionEnded": "Værten afsluttede sessionen",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr udstedte ikke en sessionscookie",
|
||||
"freshCookieRejected": "Seerr afviste den nye sessionscookie",
|
||||
"noUserInformation": "Seerr returnerede ikke brugeroplysninger",
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
|
||||
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
|
||||
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
|
||||
"errorLoadingSeries": "Fehler beim Laden der Serie",
|
||||
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
|
||||
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
|
||||
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet"
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr hat kein Sitzungscookie ausgestellt",
|
||||
"freshCookieRejected": "Seerr hat das neue Sitzungscookie abgelehnt",
|
||||
"noUserInformation": "Seerr hat keine Benutzerinformationen zurückgegeben",
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Dienste",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "The server returned invalid playback information.",
|
||||
"playbackCancelled": "Playback was canceled.",
|
||||
"playbackFailed": "Playback could not be started.",
|
||||
"mediaUnavailable": "This content is no longer available.",
|
||||
"errorLoadingFileInfo": "Error loading file info: ${error}",
|
||||
"errorLoadingSeries": "Error loading series",
|
||||
"musicNotSupported": "Music playback is not yet supported",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "The relay did not respond in time",
|
||||
"connectionLost": "The connection closed before the session was ready",
|
||||
"invalidRelayResponse": "The relay sent an unexpected response",
|
||||
"sessionEnded": "The host ended the session"
|
||||
"sessionEnded": "The host ended the session",
|
||||
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr did not issue a session cookie",
|
||||
"freshCookieRejected": "Seerr rejected the new session cookie",
|
||||
"noUserInformation": "Seerr did not return user information",
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again",
|
||||
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
|
||||
"permissionRevoked": "You no longer have permission to request this"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "El servidor devolvió información de reproducción no válida.",
|
||||
"playbackCancelled": "Se canceló la reproducción.",
|
||||
"playbackFailed": "No se pudo iniciar la reproducción.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Error al cargar la información del archivo: ${error}",
|
||||
"errorLoadingSeries": "Error al cargar la serie",
|
||||
"musicNotSupported": "La reproducción de música aún no es compatible",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "El servidor de retransmisión no respondió a tiempo",
|
||||
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
|
||||
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
|
||||
"sessionEnded": "El anfitrión finalizó la sesión"
|
||||
"sessionEnded": "El anfitrión finalizó la sesión",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr no proporcionó una cookie de sesión",
|
||||
"freshCookieRejected": "Seerr rechazó la nueva cookie de sesión",
|
||||
"noUserInformation": "Seerr no devolvió información del usuario",
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Le serveur a renvoyé des informations de lecture non valides.",
|
||||
"playbackCancelled": "La lecture a été annulée.",
|
||||
"playbackFailed": "Impossible de démarrer la lecture.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier : ${error}",
|
||||
"errorLoadingSeries": "Erreur lors du chargement de la série",
|
||||
"musicNotSupported": "La lecture de musique n'est pas encore prise en charge",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Le serveur relais n’a pas répondu à temps",
|
||||
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
|
||||
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
|
||||
"sessionEnded": "L’hôte a mis fin à la session"
|
||||
"sessionEnded": "L’hôte a mis fin à la session",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr n’a pas fourni de cookie de session",
|
||||
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
|
||||
"noUserInformation": "Seerr n’a renvoyé aucune information sur l’utilisateur",
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "A szerver érvénytelen lejátszási adatokat küldött.",
|
||||
"playbackCancelled": "A lejátszás megszakítva.",
|
||||
"playbackFailed": "Nem sikerült elindítani a lejátszást.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Hiba a fájlinformációk betöltésekor: ${error}",
|
||||
"errorLoadingSeries": "Hiba a sorozat betöltésekor",
|
||||
"musicNotSupported": "A zenelejátszás még nem támogatott",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "A relészerver nem válaszolt időben",
|
||||
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
|
||||
"invalidRelayResponse": "A relészerver váratlan választ küldött",
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet"
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "A Seerr nem adott ki munkamenet-sütit",
|
||||
"freshCookieRejected": "A Seerr elutasította az új munkamenet-sütit",
|
||||
"noUserInformation": "A Seerr nem adott vissza felhasználói adatokat",
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Szolgáltatások",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
|
||||
"playbackCancelled": "Riproduzione annullata.",
|
||||
"playbackFailed": "Impossibile avviare la riproduzione.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Errore durante il caricamento delle informazioni sul file: ${error}",
|
||||
"errorLoadingSeries": "Errore durante il caricamento della serie",
|
||||
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Il server relay non ha risposto in tempo",
|
||||
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
|
||||
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
|
||||
"sessionEnded": "L’host ha terminato la sessione"
|
||||
"sessionEnded": "L’host ha terminato la sessione",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr non ha generato un cookie di sessione",
|
||||
"freshCookieRejected": "Seerr ha rifiutato il nuovo cookie di sessione",
|
||||
"noUserInformation": "Seerr non ha restituito le informazioni sull'utente",
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servizi",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
|
||||
"playbackCancelled": "再生がキャンセルされました。",
|
||||
"playbackFailed": "再生を開始できませんでした。",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
|
||||
"errorLoadingSeries": "シリーズの読み込みエラー",
|
||||
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
||||
@@ -1649,7 +1650,8 @@
|
||||
"timedOut": "リレーサーバーが時間内に応答しませんでした",
|
||||
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
|
||||
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
|
||||
"sessionEnded": "ホストがセッションを終了しました"
|
||||
"sessionEnded": "ホストがセッションを終了しました",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2105,7 +2107,9 @@
|
||||
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
|
||||
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
|
||||
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "サービス",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
|
||||
"playbackCancelled": "Ойнатудан бас тартылды.",
|
||||
"playbackFailed": "Ойнатуды іске қосу қатесі.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
|
||||
"errorLoadingSeries": "Сериалды жүктеу қатесі",
|
||||
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Реле сервері уақытында жауап бермеді",
|
||||
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
|
||||
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
|
||||
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
|
||||
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Қызметтер",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
|
||||
"playbackCancelled": "재생이 취소되었습니다.",
|
||||
"playbackFailed": "재생을 시작할 수 없습니다.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
|
||||
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
||||
"musicNotSupported": "음악 재생 미지원",
|
||||
@@ -1649,7 +1650,8 @@
|
||||
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
|
||||
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
|
||||
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다"
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2105,7 +2107,9 @@
|
||||
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
|
||||
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
|
||||
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "서비스",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
|
||||
"playbackCancelled": "Avspillingen ble avbrutt.",
|
||||
"playbackFailed": "Kunne ikke starte avspillingen.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
|
||||
"errorLoadingSeries": "Feil ved lasting av serie",
|
||||
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Reléserveren svarte ikke i tide",
|
||||
"connectionLost": "Tilkoblingen ble lukket før økten var klar",
|
||||
"invalidRelayResponse": "Reléserveren sendte et uventet svar",
|
||||
"sessionEnded": "Verten avsluttet økten"
|
||||
"sessionEnded": "Verten avsluttet økten",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
|
||||
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
|
||||
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.",
|
||||
"playbackCancelled": "Het afspelen is geannuleerd.",
|
||||
"playbackFailed": "Het afspelen kon niet worden gestart.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}",
|
||||
"errorLoadingSeries": "Fout bij laden van serie",
|
||||
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "De relayserver heeft niet op tijd gereageerd",
|
||||
"connectionLost": "De verbinding is verbroken voordat de sessie gereed was",
|
||||
"invalidRelayResponse": "De relayserver heeft een onverwacht antwoord verzonden",
|
||||
"sessionEnded": "De host heeft de sessie beëindigd"
|
||||
"sessionEnded": "De host heeft de sessie beëindigd",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr heeft geen sessiecookie verstrekt",
|
||||
"freshCookieRejected": "Seerr heeft de nieuwe sessiecookie geweigerd",
|
||||
"noUserInformation": "Seerr heeft geen gebruikersgegevens geretourneerd",
|
||||
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd"
|
||||
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Diensten",
|
||||
|
||||
@@ -787,6 +787,7 @@
|
||||
"playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.",
|
||||
"playbackCancelled": "Odtwarzanie zostało anulowane.",
|
||||
"playbackFailed": "Nie udało się rozpocząć odtwarzania.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
|
||||
"errorLoadingSeries": "Błąd ładowania serialu",
|
||||
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
|
||||
@@ -1682,7 +1683,8 @@
|
||||
"timedOut": "Serwer pośredniczący nie odpowiedział w wymaganym czasie",
|
||||
"connectionLost": "Połączenie zostało zamknięte, zanim sesja była gotowa",
|
||||
"invalidRelayResponse": "Serwer pośredniczący wysłał nieoczekiwaną odpowiedź",
|
||||
"sessionEnded": "Gospodarz zakończył sesję"
|
||||
"sessionEnded": "Gospodarz zakończył sesję",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2138,7 +2140,9 @@
|
||||
"noSessionCookie": "Seerr nie utworzył pliku cookie sesji",
|
||||
"freshCookieRejected": "Seerr odrzucił nowy plik cookie sesji",
|
||||
"noUserInformation": "Seerr nie zwrócił informacji o użytkowniku",
|
||||
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu"
|
||||
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Usługi",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "O servidor retornou informações de reprodução inválidas.",
|
||||
"playbackCancelled": "A reprodução foi cancelada.",
|
||||
"playbackFailed": "Não foi possível iniciar a reprodução.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Erro ao carregar as informações do arquivo: ${error}",
|
||||
"errorLoadingSeries": "Erro ao carregar série",
|
||||
"musicNotSupported": "A reprodução de música ainda não é compatível",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "O servidor de retransmissão não respondeu a tempo",
|
||||
"connectionLost": "A conexão foi encerrada antes de a sessão ficar pronta",
|
||||
"invalidRelayResponse": "O servidor de retransmissão enviou uma resposta inesperada",
|
||||
"sessionEnded": "O anfitrião encerrou a sessão"
|
||||
"sessionEnded": "O anfitrião encerrou a sessão",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "O Seerr não forneceu um cookie de sessão",
|
||||
"freshCookieRejected": "O Seerr rejeitou o novo cookie de sessão",
|
||||
"noUserInformation": "O Seerr não retornou informações do usuário",
|
||||
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente"
|
||||
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Serviços",
|
||||
|
||||
@@ -787,6 +787,7 @@
|
||||
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
|
||||
"playbackCancelled": "Воспроизведение отменено.",
|
||||
"playbackFailed": "Не удалось начать воспроизведение.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
|
||||
"errorLoadingSeries": "Ошибка загрузки сериала",
|
||||
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
|
||||
@@ -1682,7 +1683,8 @@
|
||||
"timedOut": "Сервер ретрансляции не ответил вовремя",
|
||||
"connectionLost": "Соединение закрылось до того, как сессия была готова",
|
||||
"invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ",
|
||||
"sessionEnded": "Организатор завершил сессию"
|
||||
"sessionEnded": "Организатор завершил сессию",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2138,7 +2140,9 @@
|
||||
"noSessionCookie": "Seerr не выдал cookie-файл сеанса",
|
||||
"freshCookieRejected": "Seerr отклонил новый cookie-файл сеанса",
|
||||
"noUserInformation": "Seerr не вернул сведения о пользователе",
|
||||
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа"
|
||||
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Сервисы",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 43683 (1985 per locale)
|
||||
/// Strings: 43687 (1985 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2272,6 +2272,9 @@ class Translations$messages$en {
|
||||
/// en: 'Playback could not be started.'
|
||||
String get playbackFailed => 'Playback could not be started.';
|
||||
|
||||
/// en: 'This content is no longer available.'
|
||||
String get mediaUnavailable => 'This content is no longer available.';
|
||||
|
||||
/// en: 'Error loading file info: ${error}'
|
||||
String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}';
|
||||
|
||||
@@ -5335,6 +5338,12 @@ class Translations$seerr$en {
|
||||
|
||||
/// en: 'The session was rejected after signing in again'
|
||||
String get sessionRejectedAfterReauth => 'The session was rejected after signing in again';
|
||||
|
||||
/// en: 'Seerr denied this action: your account no longer has the required permission'
|
||||
String get permissionDenied => 'Seerr denied this action: your account no longer has the required permission';
|
||||
|
||||
/// en: 'You no longer have permission to request this'
|
||||
String get permissionRevoked => 'You no longer have permission to request this';
|
||||
}
|
||||
|
||||
// Path: services
|
||||
@@ -6559,6 +6568,9 @@ class Translations$watchTogether$errors$en {
|
||||
|
||||
/// en: 'The host ended the session'
|
||||
String get sessionEnded => 'The host ended the session';
|
||||
|
||||
/// en: 'Unable to resume this session. Join or create a room to continue.'
|
||||
String get sessionUnavailable => 'Unable to resume this session. Join or create a room to continue.';
|
||||
}
|
||||
|
||||
// Path: downloads.backgroundWarning
|
||||
@@ -7737,6 +7749,7 @@ extension on Translations {
|
||||
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
|
||||
'messages.playbackCancelled' => 'Playback was canceled.',
|
||||
'messages.playbackFailed' => 'Playback could not be started.',
|
||||
'messages.mediaUnavailable' => 'This content is no longer available.',
|
||||
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
|
||||
'messages.errorLoadingSeries' => 'Error loading series',
|
||||
'messages.musicNotSupported' => 'Music playback is not yet supported',
|
||||
@@ -8032,9 +8045,9 @@ extension on Translations {
|
||||
'libraries.folders' => 'folders',
|
||||
'libraries.tabs.recommended' => 'Recommended',
|
||||
'libraries.tabs.browse' => 'Browse',
|
||||
'libraries.tabs.collections' => 'Collections',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.tabs.collections' => 'Collections',
|
||||
'libraries.tabs.playlists' => 'Playlists',
|
||||
'libraries.groupings.title' => 'Grouping',
|
||||
'libraries.groupings.all' => 'All',
|
||||
@@ -8511,6 +8524,7 @@ extension on Translations {
|
||||
'watchTogether.errors.connectionLost' => 'The connection closed before the session was ready',
|
||||
'watchTogether.errors.invalidRelayResponse' => 'The relay sent an unexpected response',
|
||||
'watchTogether.errors.sessionEnded' => 'The host ended the session',
|
||||
'watchTogether.errors.sessionUnavailable' => 'Unable to resume this session. Join or create a room to continue.',
|
||||
'downloads.title' => 'Downloads',
|
||||
'downloads.manage' => 'Manage',
|
||||
'downloads.tvShows' => 'TV Shows',
|
||||
@@ -8545,10 +8559,10 @@ extension on Translations {
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
|
||||
'downloads.noDownloadsTree' => 'No downloads',
|
||||
'downloads.pauseAll' => 'Pause all',
|
||||
'downloads.resumeAll' => 'Resume all',
|
||||
'downloads.deleteAll' => 'Delete all',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.resumeAll' => 'Resume all',
|
||||
'downloads.deleteAll' => 'Delete all',
|
||||
'downloads.selectVersion' => 'Select Version',
|
||||
'downloads.allEpisodes' => 'All episodes',
|
||||
'downloads.unwatchedOnly' => 'Unwatched only',
|
||||
@@ -8936,6 +8950,8 @@ extension on Translations {
|
||||
'seerr.freshCookieRejected' => 'Seerr rejected the new session cookie',
|
||||
'seerr.noUserInformation' => 'Seerr did not return user information',
|
||||
'seerr.sessionRejectedAfterReauth' => 'The session was rejected after signing in again',
|
||||
'seerr.permissionDenied' => 'Seerr denied this action: your account no longer has the required permission',
|
||||
'seerr.permissionRevoked' => 'You no longer have permission to request this',
|
||||
'services.title' => 'Services',
|
||||
'services.hubSubtitle' => 'Sync watch progress and request new titles.',
|
||||
'services.integrations' => 'Integrations',
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.",
|
||||
"playbackCancelled": "Uppspelningen avbröts.",
|
||||
"playbackFailed": "Det gick inte att starta uppspelningen.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
|
||||
"errorLoadingSeries": "Fel vid laddning av serie",
|
||||
"musicNotSupported": "Musikuppspelning stöds inte ännu",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Reläservern svarade inte i tid",
|
||||
"connectionLost": "Anslutningen stängdes innan sessionen var klar",
|
||||
"invalidRelayResponse": "Reläservern skickade ett oväntat svar",
|
||||
"sessionEnded": "Värden avslutade sessionen"
|
||||
"sessionEnded": "Värden avslutade sessionen",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr skapade ingen sessionscookie",
|
||||
"freshCookieRejected": "Seerr avvisade den nya sessionscookien",
|
||||
"noUserInformation": "Seerr returnerade ingen användarinformation",
|
||||
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen"
|
||||
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjänster",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.",
|
||||
"playbackCancelled": "Oynatma iptal edildi.",
|
||||
"playbackFailed": "Oynatma başlatılamadı.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}",
|
||||
"errorLoadingSeries": "Dizi yüklenirken hata",
|
||||
"musicNotSupported": "Müzik oynatma henüz desteklenmiyor",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Aktarıcı sunucusu zamanında yanıt vermedi",
|
||||
"connectionLost": "Oturum hazır olmadan bağlantı kapandı",
|
||||
"invalidRelayResponse": "Aktarıcı sunucusu beklenmeyen bir yanıt gönderdi",
|
||||
"sessionEnded": "Kurucu oturumu sonlandırdı"
|
||||
"sessionEnded": "Kurucu oturumu sonlandırdı",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr bir oturum çerezi oluşturmadı",
|
||||
"freshCookieRejected": "Seerr yeni oturum çerezini reddetti",
|
||||
"noUserInformation": "Seerr kullanıcı bilgilerini döndürmedi",
|
||||
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi"
|
||||
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servisler",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.",
|
||||
"playbackCancelled": "Ijro bekor qilindi.",
|
||||
"playbackFailed": "Ijroni ishga tushirishda xatolik.",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}",
|
||||
"errorLoadingSeries": "Serialni yuklashda xatolik",
|
||||
"musicNotSupported": "Musiqa ijrosi hali qoʻllab-quvvatlanmaydi",
|
||||
@@ -1660,7 +1661,8 @@
|
||||
"timedOut": "Rele serveri oʻz vaqtida javob bermadi",
|
||||
"connectionLost": "Seans tayyor boʻlmasidan ulanish uzildi",
|
||||
"invalidRelayResponse": "Rele serveri kutilmagan javob yubordi",
|
||||
"sessionEnded": "Tashkilotchi seansni tugatdi"
|
||||
"sessionEnded": "Tashkilotchi seansni tugatdi",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2116,7 +2118,9 @@
|
||||
"noSessionCookie": "Seerr seans cookie-faylini bermadi",
|
||||
"freshCookieRejected": "Seerr yangi seans cookie-faylini rad etdi",
|
||||
"noUserInformation": "Seerr foydalanuvchi maʼlumotlarini qaytarmadi",
|
||||
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi"
|
||||
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Xizmatlar",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "無法開始播放。",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
|
||||
"errorLoadingSeries": "載入影集時發生錯誤",
|
||||
"musicNotSupported": "目前不支援播放音樂",
|
||||
@@ -1649,7 +1650,8 @@
|
||||
"timedOut": "中繼伺服器未及時回應",
|
||||
"connectionLost": "工作階段就緒前連線已中斷",
|
||||
"invalidRelayResponse": "中繼伺服器傳回了非預期的回應",
|
||||
"sessionEnded": "主持人已結束工作階段"
|
||||
"sessionEnded": "主持人已結束工作階段",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2105,7 +2107,9 @@
|
||||
"noSessionCookie": "Seerr 未發出工作階段 Cookie",
|
||||
"freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie",
|
||||
"noUserInformation": "Seerr 未傳回使用者資訊",
|
||||
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕"
|
||||
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "外部服務",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "服务器返回了无效的播放信息。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "无法开始播放。",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
|
||||
"errorLoadingSeries": "加载剧集时出错",
|
||||
"musicNotSupported": "尚不支持播放音乐",
|
||||
@@ -1649,7 +1650,8 @@
|
||||
"timedOut": "中继服务器未及时响应",
|
||||
"connectionLost": "会话准备就绪前连接已断开",
|
||||
"invalidRelayResponse": "中继服务器返回了意外的响应",
|
||||
"sessionEnded": "主持人已结束会话"
|
||||
"sessionEnded": "主持人已结束会话",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2105,7 +2107,9 @@
|
||||
"noSessionCookie": "Seerr 未签发会话 Cookie",
|
||||
"freshCookieRejected": "Seerr 拒绝了新的会话 Cookie",
|
||||
"noUserInformation": "Seerr 未返回用户信息",
|
||||
"sessionRejectedAfterReauth": "重新登录后会话被拒绝"
|
||||
"sessionRejectedAfterReauth": "重新登录后会话被拒绝",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "服务",
|
||||
|
||||
@@ -13,7 +13,7 @@ class AccountPreferenceTarget {
|
||||
required this.ref,
|
||||
required this.label,
|
||||
this.subtitle,
|
||||
this.isActiveProfileAccount = false,
|
||||
this.isDefaultConnection = false,
|
||||
});
|
||||
|
||||
final AccountRef ref;
|
||||
@@ -24,9 +24,9 @@ class AccountPreferenceTarget {
|
||||
/// Secondary row label: user · URL (MediaBrowser) or Home user (Plex).
|
||||
final String? subtitle;
|
||||
|
||||
/// Whether this is the account the active profile is currently browsing
|
||||
/// with. Used to order the picker; not a permission.
|
||||
final bool isActiveProfileAccount;
|
||||
/// Whether the profile designated this connection as its picker default.
|
||||
/// Presentation only: this does not establish playback authority.
|
||||
final bool isDefaultConnection;
|
||||
|
||||
MediaBackend get backend => ref.backend;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ class LibraryChangeEvent {
|
||||
final ServerId serverId;
|
||||
|
||||
/// Backend-native ids of the affected libraries (Plex section ids,
|
||||
/// MediaBrowser collection-folder ids). Empty when the backend did not say.
|
||||
/// MediaBrowser collection-folder ids). Empty when the backend did not say,
|
||||
/// which consumers must read as [targetsWholeServer] — never as "nothing".
|
||||
final Set<String> libraryIds;
|
||||
|
||||
/// Backend-native ids of removed items, when the backend named them (Plex
|
||||
@@ -41,6 +42,12 @@ class LibraryChangeEvent {
|
||||
|
||||
bool get hasChanges => itemsAdded || itemsRemoved || itemsUpdated;
|
||||
|
||||
/// Whether the change's scope is unidentified and therefore covers every
|
||||
/// library on [serverId]. Absorbing under coalescing: merging any event
|
||||
/// with a whole-server one must yield a whole-server event, never a
|
||||
/// narrower union of the named ids.
|
||||
bool get targetsWholeServer => libraryIds.isEmpty;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'LibraryChangeEvent($serverId, libraries: $libraryIds, '
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'media_backend.dart';
|
||||
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
|
||||
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
|
||||
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
|
||||
/// and `api_key=` query fallback. Plezy therefore drives both through one
|
||||
/// and a token query fallback. Plezy therefore drives both through one
|
||||
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
|
||||
///
|
||||
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
|
||||
@@ -76,8 +76,16 @@ enum MediaBrowserDialect {
|
||||
MediaBrowserDialect.emby => const [8920, 8096],
|
||||
};
|
||||
|
||||
/// Jellyfin 12 rejects legacy `api_key` when `EnableLegacyAuthorization` is
|
||||
/// false, while Emby requires it; `ApiKey` works across Jellyfin versions.
|
||||
String get tokenQueryParam => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'ApiKey',
|
||||
MediaBrowserDialect.emby => 'api_key',
|
||||
};
|
||||
|
||||
/// Path of the realtime notification websocket. Same protocol on both
|
||||
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
|
||||
/// dialects (`?ApiKey=` on Jellyfin, `?api_key=` on Emby,
|
||||
/// `ForceKeepAlive`/`KeepAlive`,
|
||||
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
|
||||
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
|
||||
String get webSocketPath => switch (this) {
|
||||
|
||||
@@ -2,15 +2,42 @@ import 'ids.dart';
|
||||
import 'media_item.dart';
|
||||
import 'media_version.dart';
|
||||
|
||||
/// The library a copy belongs to: [MediaItem.libraryId] with the
|
||||
/// [MediaItem.libraryTitle] that labels it. The two travel as a pair, since a
|
||||
/// title is only meaningful next to the id it names.
|
||||
typedef _LibraryPair = ({String? libraryId, String? libraryTitle});
|
||||
|
||||
/// The pair [fresh] belongs to, backed by [known] only where [fresh] leaves
|
||||
/// room for it.
|
||||
///
|
||||
/// A fresh copy naming a different library than [known] takes both fields
|
||||
/// verbatim — a null title too, so the UI falls back to the server name rather
|
||||
/// than labeling the new library with the old one's name. One naming the same
|
||||
/// library fills a missing title from [known]. One naming no library at all
|
||||
/// keeps the known pair: the stamp is best-effort on both backends, and its
|
||||
/// absence is not a move.
|
||||
_LibraryPair _libraryPair(MediaItem fresh, MediaItem? known) {
|
||||
final freshId = fresh.libraryId;
|
||||
final knownId = known?.libraryId;
|
||||
if (freshId == null) {
|
||||
return knownId == null
|
||||
? (libraryId: null, libraryTitle: fresh.libraryTitle ?? known?.libraryTitle)
|
||||
: (libraryId: knownId, libraryTitle: known!.libraryTitle);
|
||||
}
|
||||
return (libraryId: freshId, libraryTitle: fresh.libraryTitle ?? (freshId == knownId ? known!.libraryTitle : null));
|
||||
}
|
||||
|
||||
/// Merge freshly fetched metadata with identity and library context already
|
||||
/// known by the caller. The fetched item owns descriptive fields, while
|
||||
/// existing context wins when the backend omits it.
|
||||
/// existing context wins when the backend omits it. Library id and title
|
||||
/// follow [_libraryPair].
|
||||
MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId fallbackServerId, MediaItem? existing}) {
|
||||
final library = _libraryPair(fetched, existing);
|
||||
return fetched.copyWith(
|
||||
serverId: existing?.serverId ?? fetched.serverId ?? fallbackServerId,
|
||||
serverName: existing?.serverName ?? fetched.serverName,
|
||||
libraryId: fetched.libraryId ?? existing?.libraryId,
|
||||
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
|
||||
libraryId: library.libraryId,
|
||||
libraryTitle: library.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,26 +67,44 @@ int compareLibraryCopies(MediaItem a, MediaItem b) {
|
||||
return a.globalKey.compareTo(b.globalKey);
|
||||
}
|
||||
|
||||
/// Fold a re-resolved copy into the one already on screen.
|
||||
/// Fold a re-resolved copy into the one already known for its
|
||||
/// [MediaItem.globalKey].
|
||||
///
|
||||
/// The addition is fresher, but a degraded pass must not erase context. The
|
||||
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
|
||||
/// hands back an unstamped item when it fails, and a copy that lost its
|
||||
/// library title is indistinguishable from its sibling in the same server's
|
||||
/// other library — exactly the ambiguity the chooser exists to resolve. The
|
||||
/// version list behind the resolution hint is treated the same way.
|
||||
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
|
||||
/// library pair follows [_libraryPair]; the version list behind the
|
||||
/// resolution hint is treated the same way as the stamp.
|
||||
///
|
||||
/// Shared by the on-screen union ([mergeLibraryCopies]) and the per-server
|
||||
/// replacement in `CatalogLibraryMatcher`, so one policy decides what a
|
||||
/// returning copy keeps from its predecessor. Returns [addition] itself when
|
||||
/// it inherits nothing: [MediaItem] compares by identity, and a caller
|
||||
/// holding on to the fresh copy must still recognize it.
|
||||
MediaItem mergeLibraryCopy(MediaItem existing, MediaItem addition) {
|
||||
final versions = addition.mediaVersions;
|
||||
final library = _libraryPair(addition, existing);
|
||||
final serverName = addition.serverName ?? existing.serverName;
|
||||
final mediaVersions = versions == null || versions.isEmpty ? existing.mediaVersions : versions;
|
||||
if (library.libraryId == addition.libraryId &&
|
||||
library.libraryTitle == addition.libraryTitle &&
|
||||
serverName == addition.serverName &&
|
||||
identical(mediaVersions, versions)) {
|
||||
return addition;
|
||||
}
|
||||
return addition.copyWith(
|
||||
libraryId: addition.libraryId ?? existing.libraryId,
|
||||
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
|
||||
serverName: addition.serverName ?? existing.serverName,
|
||||
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
|
||||
libraryId: library.libraryId,
|
||||
libraryTitle: library.libraryTitle,
|
||||
serverName: serverName,
|
||||
mediaVersions: mediaVersions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by
|
||||
/// [mergeLibraryCopy].
|
||||
///
|
||||
/// Never removes a copy, and never downgrades one. The cross-server fan-out
|
||||
/// behind these lists logs and skips per-server failures, so a later pass can
|
||||
@@ -69,7 +114,7 @@ List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaIt
|
||||
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
|
||||
for (final item in additions) {
|
||||
final existing = byKey[item.globalKey];
|
||||
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
|
||||
byKey[item.globalKey] = existing == null ? item : mergeLibraryCopy(existing, item);
|
||||
}
|
||||
return byKey.values.toList()..sort(compareLibraryCopies);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ abstract class MediaServerClient {
|
||||
MediaBackend get backend;
|
||||
ServerCapabilities get capabilities;
|
||||
|
||||
/// Opaque identity of the committed authentication session, independent of
|
||||
/// the endpoint. Compare by identity: successful effective credential or
|
||||
/// profile changes install a fresh identity; failed and no-op updates do not.
|
||||
Object get authenticationSessionId;
|
||||
|
||||
/// Release HTTP resources and any other long-lived state. Idempotent.
|
||||
void close();
|
||||
|
||||
@@ -622,9 +627,9 @@ abstract class MediaServerClient {
|
||||
///
|
||||
/// Every entry of [titles] is searched, and the union of their id-verified
|
||||
/// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`)
|
||||
/// leads with the native title — both search indexes cover `originalTitle`,
|
||||
/// so it reaches a copy filed under any display language — and bounds the
|
||||
/// list; that cap is the request budget. A title that hit MUST NOT stop the
|
||||
/// prioritizes original/display titles and bounds best-effort alternatives;
|
||||
/// original titles may be native or romanized depending on the source.
|
||||
/// The cap is the title-search budget. A title that hit MUST NOT stop the
|
||||
/// others: id verification means an extra title can only add genuine
|
||||
/// copies. A sequel entry's own title never matches its parent show, which
|
||||
/// is why the list carries season-stripped forms. [year] is a hint for
|
||||
@@ -644,12 +649,14 @@ abstract class MediaServerClient {
|
||||
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
|
||||
/// on a guess.
|
||||
///
|
||||
/// Returns an empty list when this server has no match or [kind] is not
|
||||
/// movie/show, and throws when the server could not be asked — a slow or
|
||||
/// unreachable server is not evidence of absence, and the aggregation layer
|
||||
/// reports it separately. Used to match external catalog items (Explore
|
||||
/// tab) back to the user's libraries.
|
||||
Future<List<MediaItem>> findByExternalIds(
|
||||
/// Returns null when this backend cannot execute the supplied query (for
|
||||
/// example, Jellyfin/Emby need external ids and a title, whereas Plex can
|
||||
/// query a Plex guid or legacy-agent id without titles). Unsupported kinds
|
||||
/// also return null. An empty list means a supported lookup completed and
|
||||
/// found no match. Failures and cancellation throw rather than supplying
|
||||
/// negative membership evidence. Used to match external catalog items
|
||||
/// (Explore tab) back to the user's libraries.
|
||||
Future<List<MediaItem>?> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> titles = const [],
|
||||
@@ -685,7 +692,15 @@ abstract class MediaServerClient {
|
||||
/// playback path to recover audio/subtitle track info (track ids, language
|
||||
/// codes, displayTitles) without hitting the network. Returns `null` when
|
||||
/// the row isn't cached or carries no usable media source.
|
||||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
|
||||
/// Uses playback's source-selection order: stable [mediaSourceId], then a
|
||||
/// sibling [preferredVersionSignature], then [mediaIndex], subject to the
|
||||
/// backend's playable-source rules.
|
||||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(
|
||||
String itemId, {
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
String? preferredVersionSignature,
|
||||
});
|
||||
|
||||
/// Build a scrub preview source for [item] using [mediaSource]. Plex
|
||||
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
|
||||
|
||||
@@ -15,6 +15,10 @@ class MediaSourceInfo {
|
||||
/// `MediaSources` id.
|
||||
final String? mediaSourceId;
|
||||
|
||||
/// Effective version/part positions after backend selection and fallback.
|
||||
final int? mediaIndex;
|
||||
final int? partIndex;
|
||||
|
||||
/// Jellyfin default stream indexes for this source. A subtitle index of -1
|
||||
/// is an explicit server/user decision to start with subtitles off.
|
||||
final int? defaultAudioStreamIndex;
|
||||
@@ -36,6 +40,8 @@ class MediaSourceInfo {
|
||||
this.partId,
|
||||
this.displayCriteria,
|
||||
this.mediaSourceId,
|
||||
this.mediaIndex,
|
||||
this.partIndex,
|
||||
this.defaultAudioStreamIndex,
|
||||
this.defaultSubtitleStreamIndex,
|
||||
this.trickplayByWidth,
|
||||
@@ -54,6 +60,8 @@ class MediaSourceInfo {
|
||||
partId: partId,
|
||||
displayCriteria: displayCriteria,
|
||||
mediaSourceId: mediaSourceId,
|
||||
mediaIndex: mediaIndex,
|
||||
partIndex: partIndex,
|
||||
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
trickplayByWidth: trickplayByWidth,
|
||||
@@ -108,6 +116,9 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
final String? displayTitle;
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
|
||||
/// Container fallback, independent of the server/user-selected row.
|
||||
final bool isDefault;
|
||||
final bool external;
|
||||
|
||||
MediaAudioTrack({
|
||||
@@ -120,6 +131,7 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
this.displayTitle,
|
||||
this.channels,
|
||||
required this.selected,
|
||||
this.isDefault = false,
|
||||
this.external = false,
|
||||
});
|
||||
|
||||
@@ -137,6 +149,7 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
displayTitle: displayTitle,
|
||||
channels: channels,
|
||||
selected: selected,
|
||||
isDefault: isDefault,
|
||||
external: external,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Manages a map of grid-item [FocusNode]s with focus-tracking and restoration.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Lazy creation of per-index focus nodes via [getGridItemFocusNode].
|
||||
/// - Focus tracking ([lastFocusedGridIndex], [gridContentVersion]) so callers
|
||||
/// can restore focus after rebuilds.
|
||||
/// - [cleanupGridFocusNodes] to prune nodes for indices beyond the current count.
|
||||
/// - [disposeGridFocusNodes] for full teardown.
|
||||
/// A detached FocusNode retains its last context, even after that host unmounts.
|
||||
/// Keep the attachment token so eviction can distinguish history from borrowers.
|
||||
class _GridFocusNode extends FocusNode {
|
||||
_GridFocusNode({super.debugLabel});
|
||||
|
||||
FocusAttachment? _gridAttachment;
|
||||
bool _hasPendingFocusRequest = false;
|
||||
|
||||
bool get _isAttached => _gridAttachment?.isAttached ?? false;
|
||||
|
||||
@override
|
||||
FocusAttachment attach(
|
||||
BuildContext? context, {
|
||||
FocusOnKeyEventCallback? onKeyEvent,
|
||||
// ignore: deprecated_member_use
|
||||
FocusOnKeyCallback? onKey,
|
||||
}) {
|
||||
// Forward the legacy argument because this still implements FocusNode.attach.
|
||||
// ignore: deprecated_member_use
|
||||
final attachment = super.attach(context, onKeyEvent: onKeyEvent, onKey: onKey);
|
||||
_gridAttachment = attachment;
|
||||
_hasPendingFocusRequest = false;
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@override
|
||||
void requestFocus([FocusNode? node]) {
|
||||
if (node == null && !_isAttached && parent == null && canRequestFocus) {
|
||||
_hasPendingFocusRequest = true;
|
||||
}
|
||||
super.requestFocus(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hasPendingFocusRequest = false;
|
||||
super.dispose();
|
||||
_gridAttachment = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns grid nodes independently of their current spatial index. A content
|
||||
/// commit reconciles every realized item's node, including scope history and
|
||||
/// references captured by menus; it never requests focus behind a cover.
|
||||
mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
final Map<int, FocusNode> gridItemFocusNodes = {};
|
||||
final Map<int, Object> _gridItemIdentities = {};
|
||||
final Set<FocusNode> _retiredGridFocusNodes = {};
|
||||
bool _gridRetirementScheduled = false;
|
||||
int? lastFocusedGridIndex;
|
||||
int gridContentVersion = 0;
|
||||
int lastFocusedGridContentVersion = 0;
|
||||
|
||||
/// Get or create a focus node for a grid item at [index].
|
||||
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item'}) {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index'));
|
||||
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item', String? debugLabel}) {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => _GridFocusNode(debugLabel: debugLabel ?? '${prefix}_$index'));
|
||||
}
|
||||
|
||||
/// Get the focus node for [index], routing index 0 through [firstNode] when
|
||||
/// the grid pins a dedicated node for the first item (e.g. `firstItemFocusNode`).
|
||||
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix}) {
|
||||
/// [firstNode] must resolve the current index-zero node owned by this mixin,
|
||||
/// not a permanently slot-bound node owned by the caller.
|
||||
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix, Object? itemIdentity}) {
|
||||
if (itemIdentity != null) _gridItemIdentities[index] = itemIdentity;
|
||||
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
|
||||
}
|
||||
|
||||
@@ -32,39 +71,88 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last-focused index is still valid for restoration.
|
||||
/// Atomically reassign spatial indices after an accepted content commit.
|
||||
/// Surviving items keep the same node and attachment. Removed items retire
|
||||
/// after their cards unmount, so captured references cannot focus a new item.
|
||||
void reconcileGridFocusNodes(Map<Object, int> indices) {
|
||||
final nodes = <int, FocusNode>{};
|
||||
final identities = <int, Object>{};
|
||||
final rememberedIndex = lastFocusedGridIndex;
|
||||
int? restoredIndex;
|
||||
for (final entry in gridItemFocusNodes.entries) {
|
||||
final identity = _gridItemIdentities[entry.key];
|
||||
final index = identity == null ? null : indices[identity];
|
||||
if (index == null) {
|
||||
_retireGridFocusNode(entry.value);
|
||||
} else {
|
||||
nodes[index] = entry.value;
|
||||
identities[index] = identity!;
|
||||
if (entry.key == rememberedIndex) restoredIndex = index;
|
||||
}
|
||||
}
|
||||
gridItemFocusNodes
|
||||
..clear()
|
||||
..addAll(nodes);
|
||||
_gridItemIdentities
|
||||
..clear()
|
||||
..addAll(identities);
|
||||
lastFocusedGridIndex = restoredIndex;
|
||||
lastFocusedGridContentVersion = gridContentVersion;
|
||||
}
|
||||
|
||||
bool get shouldRestoreGridFocus =>
|
||||
lastFocusedGridIndex != null && lastFocusedGridContentVersion == gridContentVersion && lastFocusedGridIndex! >= 0;
|
||||
|
||||
void _retireGridFocusNode(FocusNode node) {
|
||||
// Disable stale menu/route destinations immediately, without issuing a
|
||||
// replacement request. The owning scope supplies its normal fallback.
|
||||
node.canRequestFocus = false;
|
||||
if (node is _GridFocusNode) node._hasPendingFocusRequest = false;
|
||||
_retiredGridFocusNodes.add(node);
|
||||
if (_gridRetirementScheduled) return;
|
||||
_gridRetirementScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_gridRetirementScheduled = false;
|
||||
while (_retiredGridFocusNodes.isNotEmpty) {
|
||||
final retired = _retiredGridFocusNodes.first;
|
||||
_retiredGridFocusNodes.remove(retired);
|
||||
retired.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cleanupGridFocusNodes(int itemCount) {
|
||||
final keysToRemove = gridItemFocusNodes.keys.where((i) => i >= itemCount).toList();
|
||||
for (final key in keysToRemove) {
|
||||
gridItemFocusNodes[key]?.dispose();
|
||||
gridItemFocusNodes.remove(key);
|
||||
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
|
||||
_gridItemIdentities.remove(key);
|
||||
}
|
||||
if (lastFocusedGridIndex != null && lastFocusedGridIndex! >= itemCount) lastFocusedGridIndex = null;
|
||||
}
|
||||
|
||||
/// Evict focus nodes far from [centerIndex], keeping at most [keepCount] around it.
|
||||
/// Evict detached nodes far from the viewport, never live attachments or
|
||||
/// remembered/pending destinations. The budget is soft while those survive.
|
||||
void evictDistantFocusNodes(int centerIndex, {int keepCount = 200}) {
|
||||
if (gridItemFocusNodes.length <= keepCount) return;
|
||||
|
||||
final halfKeep = keepCount ~/ 2;
|
||||
final keepStart = centerIndex - halfKeep;
|
||||
final keepEnd = centerIndex + halfKeep;
|
||||
|
||||
final keysToRemove = <int>[];
|
||||
for (final key in gridItemFocusNodes.keys) {
|
||||
if (key < keepStart || key > keepEnd) {
|
||||
keysToRemove.add(key);
|
||||
for (final entry in gridItemFocusNodes.entries) {
|
||||
final node = entry.value;
|
||||
if ((entry.key < keepStart || entry.key > keepEnd) &&
|
||||
node is _GridFocusNode &&
|
||||
!node._isAttached &&
|
||||
!node._hasPendingFocusRequest &&
|
||||
!(shouldRestoreGridFocus && entry.key == lastFocusedGridIndex) &&
|
||||
!node.hasFocus &&
|
||||
node.enclosingScope?.focusedChild != node) {
|
||||
keysToRemove.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in keysToRemove) {
|
||||
final node = gridItemFocusNodes[key];
|
||||
// A focused node is still borrowed by its mounted card. Keep ownership
|
||||
// and indexed identity until a later eviction or final teardown.
|
||||
if (node == null || node.hasFocus) continue;
|
||||
gridItemFocusNodes.remove(key);
|
||||
node.dispose();
|
||||
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
|
||||
_gridItemIdentities.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +160,11 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
for (final node in gridItemFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
for (final node in _retiredGridFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
gridItemFocusNodes.clear();
|
||||
_gridItemIdentities.clear();
|
||||
_retiredGridFocusNodes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'grid_focus_node_mixin.dart';
|
||||
|
||||
/// Mixin that provides focus management for library tabs.
|
||||
/// Handles the lifecycle of a focus node for the first item and provides
|
||||
/// a method to request focus on that item.
|
||||
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Focus node for the first item (for programmatic focus)
|
||||
late final FocusNode firstItemFocusNode;
|
||||
/// Tab-entry focus resolves the current first item's grid-owned node. Reorders
|
||||
/// may move the former first node elsewhere without invalidating menu restores.
|
||||
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T>, GridFocusNodeMixin<T> {
|
||||
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: focusNodeDebugLabel);
|
||||
|
||||
String get focusNodeDebugLabel;
|
||||
|
||||
int get itemCount;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstItemFocusNode = FocusNode(debugLabel: focusNodeDebugLabel);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
firstItemFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Focus the first item in the grid/list (for tab activation)
|
||||
void focusFirstItem() {
|
||||
if (itemCount > 0) {
|
||||
|
||||
@@ -424,7 +424,13 @@ class CatalogItem {
|
||||
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
|
||||
endDate: detail.endDate ?? endDate,
|
||||
originalTitle: detail.originalTitle ?? originalTitle,
|
||||
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
|
||||
// Detail aliases get first chance at the bounded lookup budget, without
|
||||
// losing names known only to the row.
|
||||
altTitles: detail.altTitles.isEmpty
|
||||
? altTitles
|
||||
: altTitles.isEmpty
|
||||
? detail.altTitles
|
||||
: {...detail.altTitles, ...altTitles}.toList(),
|
||||
tagline: detail.tagline ?? tagline,
|
||||
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
|
||||
format: detail.format ?? format,
|
||||
|
||||
@@ -74,6 +74,10 @@ abstract class Player {
|
||||
///
|
||||
/// [media] - The media source to open.
|
||||
/// [play] - Whether to start playback immediately (default: true).
|
||||
///
|
||||
/// Backends that can identify the source they started resolve with its id
|
||||
/// (mpv: the playlist entry id carried by that source's stream events, see
|
||||
/// `PlayerNative.open`); the base contract promises nothing.
|
||||
Future<void> open(
|
||||
Media media, {
|
||||
bool play = true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../media/media_display_criteria.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models.dart';
|
||||
import 'audio_rendering_mode.dart';
|
||||
@@ -238,6 +239,8 @@ class PlayerNative extends PlayerBase {
|
||||
// to dispose-and-recreate the in-flight core, hanging playback (#930).
|
||||
Future<void>? _initFuture;
|
||||
Future<void> _audioStateTail = Future<void>.value();
|
||||
String _requestedLogLevel = 'warn';
|
||||
Future<void> _logLevelTail = Future<void>.value();
|
||||
Future<void>? _disposeFuture;
|
||||
bool _disposing = false;
|
||||
|
||||
@@ -259,11 +262,19 @@ class PlayerNative extends PlayerBase {
|
||||
Future<void> _doInitialize() async {
|
||||
try {
|
||||
// The video core carries the session's decode intent so Android can
|
||||
// choose its vo before mpv_initialize. `instanceId` names this Dart
|
||||
// instance so a later `dispose` that lost the ownership race is
|
||||
// provably stale; handlers that predate either argument ignore them.
|
||||
// choose its vo before mpv_initialize, and the subtitle "Render
|
||||
// Resolution" fraction for its vo=mediacodec OSD plane (the same knob the
|
||||
// ExoPlayer overlay honors; other platforms size the OSD themselves).
|
||||
// `instanceId` names this Dart instance so a later `dispose` that lost
|
||||
// the ownership race is provably stale; handlers that predate any of
|
||||
// these arguments ignore them.
|
||||
final result = await invoke<Object>('initialize', {
|
||||
if (!audioOnly) 'hardwareDecoding': _hardwareDecoding,
|
||||
if (!audioOnly && Platform.isAndroid)
|
||||
'subtitleRenderScale': SettingsService.instance
|
||||
.read(SettingsService.subtitleRenderResolution)
|
||||
.androidRenderScale,
|
||||
if (Platform.isAndroid) 'logLevel': _requestedLogLevel,
|
||||
'instanceId': nativeInstanceId,
|
||||
});
|
||||
if (result != true) {
|
||||
@@ -359,17 +370,25 @@ class PlayerNative extends PlayerBase {
|
||||
return ('fdclose://$fd', fd);
|
||||
}
|
||||
|
||||
/// Opens [media] and resolves with the id of the mpv playlist entry the
|
||||
/// `loadfile` created — the `sourceId` that entry's `start-file`,
|
||||
/// `playback-restart` and `end-file` events carry — or null when the load
|
||||
/// never reached mpv (core unavailable) or the core did not report one.
|
||||
///
|
||||
/// Method-channel contract: the `command` reply for `loadfile` is
|
||||
/// `{'playlistEntryId': int}` on every native core; every other command
|
||||
/// replies null.
|
||||
@override
|
||||
Future<void> open(
|
||||
Future<int?> open(
|
||||
Media media, {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration? timelineDuration,
|
||||
}) async {
|
||||
if (_nativeCoreUnavailable) return;
|
||||
if (_nativeCoreUnavailable) return null;
|
||||
await _ensureInitialized();
|
||||
if (_nativeCoreUnavailable) return;
|
||||
if (_nativeCoreUnavailable) return null;
|
||||
// `loadfile replace` (below) clears the native playlist, dropping any
|
||||
// gapless entry armed via setNext — settle its content-fd claim first.
|
||||
// No transition is surfaced: the caller is replacing playback anyway.
|
||||
@@ -440,7 +459,11 @@ class PlayerNative extends PlayerBase {
|
||||
loadfileArgs.addAll(['-1', loadfileOption]);
|
||||
}
|
||||
if (audioOnly) _expectOpenFileLoad = true;
|
||||
await command(loadfileArgs);
|
||||
// The core can be torn down while the awaits above were suspended; the
|
||||
// `command` path makes the same re-check before dispatching.
|
||||
if (_nativeCoreUnavailable) return null;
|
||||
final loadfileReply = await invoke<Map>('command', {'args': loadfileArgs});
|
||||
final playlistEntryId = loadfileReply?['playlistEntryId'];
|
||||
|
||||
// mpv's pause property survives loadfile; in-place reloads pause the old
|
||||
// file before resolving, so explicitly unpause for the replacement. Set
|
||||
@@ -449,6 +472,7 @@ class PlayerNative extends PlayerBase {
|
||||
if (play) {
|
||||
await setProperty('pause', 'no');
|
||||
}
|
||||
return playlistEntryId is int ? playlistEntryId : null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -885,6 +909,18 @@ class PlayerNative extends PlayerBase {
|
||||
@override
|
||||
Future<void> setLogLevel(String level) async {
|
||||
if (_nativeCoreUnavailable) return;
|
||||
if (Platform.isAndroid) {
|
||||
// Carry the preference into native creation, even if another operation
|
||||
// starts initialization before this ordered runtime write gets its turn.
|
||||
_requestedLogLevel = level;
|
||||
final request = _logLevelTail.then((_) async {
|
||||
if (_nativeCoreUnavailable) return;
|
||||
await _ensureInitialized();
|
||||
await invoke('setLogLevel', {'level': level});
|
||||
});
|
||||
_logLevelTail = request.catchError((Object _) {});
|
||||
return request;
|
||||
}
|
||||
await _ensureInitialized();
|
||||
await invoke('setLogLevel', {'level': level});
|
||||
}
|
||||
|
||||
@@ -209,7 +209,11 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
||||
create: (context) => ExploreProvider(context.read<CatalogSourcesProvider>()),
|
||||
lazy: true,
|
||||
),
|
||||
Provider(create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()), lazy: true),
|
||||
Provider(
|
||||
create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()),
|
||||
dispose: (_, matcher) => matcher.dispose(),
|
||||
lazy: true,
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
|
||||
|
||||
@@ -16,7 +16,7 @@ class ActivePlexToken {
|
||||
final String token;
|
||||
}
|
||||
|
||||
/// THE resolver for the active profile's effective Plex credential.
|
||||
/// Resolve the active profile's effective Plex credential for cloud callers.
|
||||
///
|
||||
/// One policy: the profile's per-user token (the `ProfileConnection` row
|
||||
/// bound to the identity's account, minted via `/home/users/{uuid}/switch`)
|
||||
@@ -24,11 +24,9 @@ class ActivePlexToken {
|
||||
///
|
||||
/// [allowAccountTokenForHomeUser] controls the owner-token fallback for Plex
|
||||
/// Home profiles. Cloud Discover and Seerr pass `true` — any credential on
|
||||
/// the account is acceptable there. User-settings resolution passes `false`
|
||||
/// because the owner's token belongs to a *different* plex.tv user, so a
|
||||
/// Home profile without its switched token resolves to `null` instead of
|
||||
/// silently impersonating the owner. Local profiles always fall back: their
|
||||
/// selected account *is* the profile's identity.
|
||||
/// the account is acceptable there. Account preferences resolve separately:
|
||||
/// their Home principal requires its exact parent's switched token, without
|
||||
/// owner or other-account fallback. Local profiles retain owner-token fallback.
|
||||
Future<ActivePlexToken?> resolveActivePlexToken({
|
||||
required ActiveProfileProvider activeProfile,
|
||||
required ConnectionRegistry connections,
|
||||
|
||||
@@ -64,6 +64,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
StreamSubscription<List<ProfileConnection>>? _profileConnectionsSubscription;
|
||||
Profile? _watchedProfile;
|
||||
String? _lastSeenActiveProfileId;
|
||||
int _generation = 0;
|
||||
int _accountGeneration = 0;
|
||||
|
||||
/// Latest rows the registry watchers delivered, null until each has emitted
|
||||
/// for the current registry/profile. Accounts resolve from these rather than
|
||||
@@ -100,7 +102,11 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
/// awaits this before entering the session so the first playback does not
|
||||
/// race the fetch; an already loaded value is kept rather than re-fetched.
|
||||
Future<void> ensureActiveLoaded() async {
|
||||
await _activeProfile?.awaitBindingSettle();
|
||||
while (!isDisposed) {
|
||||
final generation = _generation;
|
||||
await _activeProfile?.awaitBindingSettle();
|
||||
if (generation == _generation) break;
|
||||
}
|
||||
// Every watcher emission after attach supersedes the previous resolve;
|
||||
// waiting for the chain to go quiet is what makes the cached check below
|
||||
// meaningful.
|
||||
@@ -125,12 +131,18 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
}) {
|
||||
if (isDisposed) return;
|
||||
_serverManager = serverManager;
|
||||
final dependenciesChanged =
|
||||
(_connections != null && !identical(_connections, connections)) ||
|
||||
(_profileConnections != null && !identical(_profileConnections, profileConnections)) ||
|
||||
(_activeProfile != null && !identical(_activeProfile, activeProfile));
|
||||
if (dependenciesChanged) _resetScope();
|
||||
|
||||
if (!identical(_connections, connections)) {
|
||||
_connections = connections;
|
||||
_connectionRows = null;
|
||||
_connectionsSubscription?.cancel();
|
||||
_connectionsSubscription = connections.watchConnections().listen((rows) {
|
||||
if (isDisposed || !identical(_connections, connections)) return;
|
||||
_connectionRows = rows;
|
||||
_resolve();
|
||||
});
|
||||
@@ -159,17 +171,31 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
final active = _activeProfile;
|
||||
if (active == null) return;
|
||||
final id = active.activeId;
|
||||
if (id == _lastSeenActiveProfileId) return;
|
||||
if (id == _lastSeenActiveProfileId) {
|
||||
final profile = active.active;
|
||||
if (_watchedProfile?.kind != profile?.kind ||
|
||||
_watchedProfile?.parentConnectionId != profile?.parentConnectionId ||
|
||||
_watchedProfile?.plexHomeUserUuid != profile?.plexHomeUserUuid) {
|
||||
_resetScope();
|
||||
}
|
||||
_watchedProfile = profile;
|
||||
_resolve();
|
||||
return;
|
||||
}
|
||||
_lastSeenActiveProfileId = id;
|
||||
// Another user's values must not survive the switch, not even for the
|
||||
// duration of the next fetch.
|
||||
_activeRef = null;
|
||||
repository.clear();
|
||||
safeNotifyListeners();
|
||||
_resetScope();
|
||||
_watchActiveProfile(active.active);
|
||||
_resolve();
|
||||
}
|
||||
|
||||
void _resetScope() {
|
||||
_generation++;
|
||||
_activeRef = null;
|
||||
_accounts = const [];
|
||||
repository.clear();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
void _watchActiveProfile(Profile? profile) {
|
||||
final registry = _profileConnections;
|
||||
if (_watchedProfile?.id == profile?.id && _profileConnectionsSubscription != null) return;
|
||||
@@ -181,6 +207,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
if (registry == null || profile == null) return;
|
||||
|
||||
_profileConnectionsSubscription = registry.watchForProfile(profile.id).listen((rows) {
|
||||
if (isDisposed || _watchedProfile?.id != profile.id || !identical(_profileConnections, registry)) return;
|
||||
_profileConnectionRows = rows;
|
||||
_resolve();
|
||||
});
|
||||
@@ -191,23 +218,24 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
/// [ensureActiveLoaded] has something to wait for. No active profile
|
||||
/// resolves to no accounts at once.
|
||||
void _resolve() {
|
||||
if (isDisposed) return;
|
||||
final profile = _watchedProfile;
|
||||
final connectionRows = _connectionRows;
|
||||
final profileRows = profile == null ? const <ProfileConnection>[] : _profileConnectionRows;
|
||||
if (connectionRows == null || profileRows == null) {
|
||||
if (profile != null && (connectionRows == null || profileRows == null)) {
|
||||
_resolveTask = (_firstRows ??= Completer<void>()).future;
|
||||
return;
|
||||
}
|
||||
List<AccountPreferenceAccount> resolved;
|
||||
AccountPreferenceResolution resolved;
|
||||
try {
|
||||
resolved = resolveAccountPreferenceAccounts(
|
||||
profile: profile,
|
||||
profileConnections: profileRows,
|
||||
connections: connectionRows,
|
||||
profileConnections: profileRows ?? const [],
|
||||
connections: connectionRows ?? const [],
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
|
||||
resolved = const [];
|
||||
resolved = const AccountPreferenceResolution();
|
||||
}
|
||||
final firstRows = _firstRows;
|
||||
_firstRows = null;
|
||||
@@ -215,30 +243,52 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
firstRows?.complete();
|
||||
}
|
||||
|
||||
Future<void> _applyAccounts(List<AccountPreferenceAccount> resolved) async {
|
||||
final changed = !_sameAccounts(_accounts, resolved);
|
||||
if (changed) {
|
||||
_accounts = resolved;
|
||||
safeNotifyListeners();
|
||||
Future<void> _applyAccounts(AccountPreferenceResolution resolved) async {
|
||||
final accounts = resolved.accounts;
|
||||
final byRef = {for (final account in accounts) account.ref: account};
|
||||
final previousRefs = {for (final account in _accounts) account.ref};
|
||||
for (final account in accounts) {
|
||||
if (!previousRefs.contains(account.ref)) repository.invalidate(account.ref);
|
||||
}
|
||||
// Sorted best-first, so the head is the active profile's own account.
|
||||
final ref = resolved.isEmpty ? null : resolved.first.ref;
|
||||
var credentialsChanged = false;
|
||||
for (final previous in _accounts) {
|
||||
final current = byRef[previous.ref];
|
||||
if (current == null || !_sameCredentials(previous, current)) {
|
||||
credentialsChanged = true;
|
||||
repository.invalidate(previous.ref);
|
||||
}
|
||||
}
|
||||
if (credentialsChanged) _accountGeneration++;
|
||||
final changed = !_sameAccounts(_accounts, accounts);
|
||||
final ref = resolved.playbackRef;
|
||||
final refChanged = ref != _activeRef;
|
||||
_accounts = accounts;
|
||||
_activeRef = ref;
|
||||
if (ref == null) return;
|
||||
// Re-read on any account change (a re-minted token must not serve the old
|
||||
// cached value) and on a profile switch that resolves to the same account
|
||||
// (the switch cleared the cache).
|
||||
if (changed || refChanged) await _loadActive(ref);
|
||||
// Consumers must observe the new list, authority and invalidated cache in
|
||||
// the same notification, including transitions to no eligible principal.
|
||||
if (changed || refChanged) safeNotifyListeners();
|
||||
if (ref != null && (refChanged || repository.cached(ref) == null)) await _loadActive(ref);
|
||||
}
|
||||
|
||||
static bool _sameCredentials(AccountPreferenceAccount a, AccountPreferenceAccount b) {
|
||||
if (a.plexToken != b.plexToken) return false;
|
||||
final left = a.connection;
|
||||
final right = b.connection;
|
||||
if (left is JellyfinConnection && right is JellyfinConnection) {
|
||||
return left.accessToken == right.accessToken && left.userId == right.userId && left.deviceId == right.deviceId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _loadActive(AccountRef ref) async {
|
||||
final generation = _generation;
|
||||
final accountGeneration = _accountGeneration;
|
||||
// The binder mints a Home token and connects servers after activation;
|
||||
// reading before it settles would fail on an unminted token or an absent
|
||||
// client. Concurrent loads for one account share a request in the
|
||||
// repository, so re-entering here is cheap.
|
||||
await _activeProfile?.awaitBindingSettle();
|
||||
if (isDisposed || ref != _activeRef) return;
|
||||
if (isDisposed || generation != _generation || accountGeneration != _accountGeneration || ref != _activeRef) return;
|
||||
try {
|
||||
await repository.load(ref, forceRefresh: true);
|
||||
} on AccountPreferencesUnavailableException {
|
||||
@@ -252,21 +302,27 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AccountPreferenceAccount>> _readAccounts() async {
|
||||
Future<AccountPreferenceResolution> _readAccounts() async {
|
||||
final generation = _generation;
|
||||
final connections = _connections;
|
||||
final profileConnections = _profileConnections;
|
||||
final profile = _activeProfile?.active;
|
||||
if (connections == null || profileConnections == null || profile == null) return const [];
|
||||
if (connections == null || profileConnections == null || profile == null) {
|
||||
return const AccountPreferenceResolution();
|
||||
}
|
||||
|
||||
try {
|
||||
final profileRows = await profileConnections.listForProfile(profile.id);
|
||||
final connectionRows = await connections.list();
|
||||
if (isDisposed || generation != _generation) return const AccountPreferenceResolution();
|
||||
return resolveAccountPreferenceAccounts(
|
||||
profile: profile,
|
||||
profileConnections: await profileConnections.listForProfile(profile.id),
|
||||
connections: await connections.list(),
|
||||
profileConnections: profileRows,
|
||||
connections: connectionRows,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
|
||||
return const [];
|
||||
return const AccountPreferenceResolution();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +330,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i].ref != b[i].ref || a[i].target.label != b[i].target.label) return false;
|
||||
if (a[i].target.subtitle != b[i].target.subtitle || a[i].plexToken != b[i].plexToken) return false;
|
||||
if (a[i].target.subtitle != b[i].target.subtitle || !_sameCredentials(a[i], b[i])) return false;
|
||||
if (a[i].target.isDefaultConnection != b[i].target.isDefaultConnection) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -282,7 +339,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
/// The account backing [connectionId] for the active profile, resolved fresh
|
||||
/// so a caller during startup does not race the first snapshot.
|
||||
Future<AccountPreferenceAccount?> accountForConnectionId(String connectionId) async {
|
||||
for (final account in await _readAccounts()) {
|
||||
for (final account in (await _readAccounts()).accounts) {
|
||||
if (account.ref.connectionId == connectionId) return account;
|
||||
}
|
||||
return null;
|
||||
@@ -292,30 +349,41 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
|
||||
/// unreachable. Resolved per call: a Plex Home token is minted lazily by the
|
||||
/// binder and a MediaBrowser client only exists once its server is online.
|
||||
Future<AccountPreferencesSource?> _sourceFor(AccountRef ref) async {
|
||||
final generation = _generation;
|
||||
AccountPreferenceAccount? selected;
|
||||
for (final account in (await _readAccounts()).accounts) {
|
||||
if (account.ref == ref) {
|
||||
selected = account;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isDisposed || generation != _generation || selected == null) return null;
|
||||
for (final current in _accounts) {
|
||||
if (current.ref == ref && !_sameCredentials(current, selected)) return null;
|
||||
}
|
||||
switch (ref.backend) {
|
||||
case MediaBackend.jellyfin:
|
||||
case MediaBackend.emby:
|
||||
final client = _serverManager?.getJellyfinClientByCompoundId(ref.connectionId);
|
||||
if (client is! JellyfinClient) return null;
|
||||
final connection = selected.connection;
|
||||
if (connection is! JellyfinConnection ||
|
||||
client.connection.accessToken != connection.accessToken ||
|
||||
client.connection.userId != connection.userId ||
|
||||
client.connection.deviceId != connection.deviceId) {
|
||||
return null;
|
||||
}
|
||||
return MediaBrowserAccountPreferencesSource(client);
|
||||
case MediaBackend.plex:
|
||||
final token = await _resolvePlexToken(ref);
|
||||
final token = selected.plexToken;
|
||||
if (token == null || token.isEmpty) return null;
|
||||
return PlexAccountPreferencesSource(authToken: token, serviceFactory: _plexServiceFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/// The token for [ref], re-resolved rather than read off the cached account
|
||||
/// list so a freshly minted Home-user token is picked up immediately.
|
||||
Future<String?> _resolvePlexToken(AccountRef ref) async {
|
||||
for (final account in await _readAccounts()) {
|
||||
if (account.ref == ref) return account.plexToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_generation++;
|
||||
_activeProfile?.removeListener(_onActiveProfileChanged);
|
||||
_connectionsSubscription?.cancel();
|
||||
_profileConnectionsSubscription?.cancel();
|
||||
|
||||
@@ -982,7 +982,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
if (changed) safeNotifyListeners();
|
||||
unawaited(refreshContinueWatching());
|
||||
if (event.origin == DeletionOrigin.local) {
|
||||
unawaited(refreshContinueWatching());
|
||||
}
|
||||
}
|
||||
|
||||
void _onHiddenLibrariesChanged() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/library_content_notifier.dart';
|
||||
import '../utils/coalesced_load_coordinator.dart';
|
||||
import 'multi_server_provider.dart';
|
||||
@@ -96,27 +97,37 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
|
||||
void _onLibraryContentChanged(LibraryChangeEvent event) {
|
||||
if (isDisposed || !event.hasChanges) return;
|
||||
// Resolved once per event, not per library: the loop below is the only
|
||||
// hot consumer and resolution walks the named ids.
|
||||
final coversServer = _eventCoversServer(event);
|
||||
for (final library in _libraries) {
|
||||
if (!eventTargetsLibrary(event, library)) continue;
|
||||
if (library.serverId != event.serverId.value) continue;
|
||||
if (!coversServer && !event.libraryIds.contains(library.id)) continue;
|
||||
_contentEpochByGlobalKey[library.globalKey] = (_contentEpochByGlobalKey[library.globalKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Single matcher for push events, shared with the visible tab's live pass
|
||||
/// so epoch marking and live refreshes always agree (#1646). An event with
|
||||
/// no library ids targets the whole server; ids that match no loaded
|
||||
/// library on that server (a brand-new library, or a backend id the event
|
||||
/// names differently) fall back to the whole server rather than nothing.
|
||||
/// no library ids targets the whole server; so does one naming any id that
|
||||
/// matches no loaded library on that server (a brand-new library, or a
|
||||
/// backend id the app never sees as a library — Jellyfin reports physical
|
||||
/// collection folders, and a grouped view's folders never appear as its
|
||||
/// id). Partial resolution widens rather than narrows: the unresolved ids
|
||||
/// may belong to any loaded library, so every one of them is marked
|
||||
/// alongside the exact matches. Only a fully resolved id set stays precise.
|
||||
bool eventTargetsLibrary(LibraryChangeEvent event, MediaLibrary library) {
|
||||
if (library.serverId == null || library.serverId != event.serverId.value) return false;
|
||||
if (event.libraryIds.isEmpty) return true;
|
||||
if (event.libraryIds.contains(library.id)) return true;
|
||||
return !_serverHasLibraryIn(event.serverId.value, event.libraryIds);
|
||||
return event.libraryIds.contains(library.id) || _eventCoversServer(event);
|
||||
}
|
||||
|
||||
bool _serverHasLibraryIn(String serverId, Set<String> libraryIds) {
|
||||
for (final library in _libraries) {
|
||||
if (library.serverId == serverId && libraryIds.contains(library.id)) return true;
|
||||
/// Whether [event] must be read as touching every loaded library on its
|
||||
/// server: unidentified scope, or a named id that resolves to nothing.
|
||||
bool _eventCoversServer(LibraryChangeEvent event) {
|
||||
if (event.targetsWholeServer) return true;
|
||||
_ensureLookups();
|
||||
for (final id in event.libraryIds) {
|
||||
if (!_byGlobalKey.containsKey(buildGlobalKey(event.serverId, id))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../services/seerr/seerr_auth_service.dart';
|
||||
import '../services/seerr/seerr_client.dart';
|
||||
import '../services/seerr/seerr_session_store.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/serial_future_queue.dart';
|
||||
import '../services/trackers/future_coalescer.dart';
|
||||
|
||||
/// Resolve the active profile's Plex token for Seerr sign-in/re-auth:
|
||||
/// the profile's per-user token when a bind exists (a Home user's Seerr
|
||||
@@ -49,12 +49,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final SeerrAuthService authService;
|
||||
SeerrPlexTokenSupplier? _plexTokenSupplier;
|
||||
|
||||
/// Store writes go through one queue: save() awaits an AES-GCM protect
|
||||
/// step, so two rapid unawaited writes could otherwise persist
|
||||
/// last-started-first (and a clear could lose to a still-pending save).
|
||||
final SerialFutureQueue _persistence = SerialFutureQueue();
|
||||
|
||||
Future<void> _enqueuePersistence(Future<void> Function() op) => _persistence.run(op);
|
||||
final FutureCoalescer<void> _userRefresh = FutureCoalescer();
|
||||
|
||||
void _logPersistenceFailure(Object e) => appLogger.w('Seerr: session persistence failed', error: e);
|
||||
|
||||
@@ -67,6 +62,13 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
bool get isConnected => _session != null;
|
||||
String? get displayName => _session?.displayName;
|
||||
|
||||
/// The signed-in user's permission bitmask, null when disconnected. A
|
||||
/// scalar for `context.select`: request surfaces re-derive their gates
|
||||
/// from it, since permission changes are adopted in place (bind-time
|
||||
/// refresh, silent re-auth, a denied request's `/auth/me` probe) and never
|
||||
/// rebuild the client.
|
||||
int? get permissions => _session?.permissions;
|
||||
|
||||
/// Client for the catalog/request surfaces; null when disconnected.
|
||||
SeerrClient? get catalogClient => _catalogClient;
|
||||
|
||||
@@ -91,9 +93,11 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final userUuid = newUserUuid ?? '';
|
||||
final generation = ++_bindingGeneration;
|
||||
_activeUserUuid = userUuid;
|
||||
_setSessionAndRebind(userUuid, generation, null);
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
final loaded = await _store.load(userUuid);
|
||||
_setSessionAndRebind(userUuid, generation, loaded);
|
||||
unawaited(_refreshUser(userUuid, generation));
|
||||
if (_isCurrentBinding(userUuid, generation)) unawaited(refreshUser());
|
||||
}
|
||||
|
||||
/// A stored session's permissions and display name date from sign-in;
|
||||
@@ -102,8 +106,10 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
/// Request action without a reconnect. Best-effort: while the instance is
|
||||
/// unreachable the session keeps working on its snapshot, and a rejected
|
||||
/// cookie re-auths or unlinks through the client's own path.
|
||||
Future<void> _refreshUser(String userUuid, int generation) async {
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
Future<void> refreshUser() => _userRefresh.run(_refreshUser);
|
||||
|
||||
Future<void> _refreshUser() async {
|
||||
if (isDisposed) return;
|
||||
final client = _catalogClient;
|
||||
if (client == null) return;
|
||||
try {
|
||||
@@ -115,22 +121,32 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
|
||||
/// Persist and bind a session the connect screen established.
|
||||
Future<void> adoptSession(SeerrSession session) async {
|
||||
if (isDisposed) return;
|
||||
final userUuid = _activeUserUuid;
|
||||
await _enqueuePersistence(() => _store.save(userUuid, session));
|
||||
_setSessionAndRebind(userUuid, ++_bindingGeneration, session);
|
||||
final generation = ++_bindingGeneration;
|
||||
// Retire the old client before persistence: its callbacks must not enqueue
|
||||
// authority writes behind this explicit adoption.
|
||||
_setSessionAndRebind(userUuid, generation, null);
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
await _store.save(userUuid, session);
|
||||
_setSessionAndRebind(userUuid, generation, session);
|
||||
}
|
||||
|
||||
/// Sign out server-side (best effort) and clear local state.
|
||||
Future<void> disconnect() async {
|
||||
if (isDisposed) return;
|
||||
final userUuid = _activeUserUuid;
|
||||
final session = _session;
|
||||
_setSessionAndRebind(userUuid, ++_bindingGeneration, null);
|
||||
await _enqueuePersistence(() => _store.clear(userUuid));
|
||||
final generation = ++_bindingGeneration;
|
||||
_setSessionAndRebind(userUuid, generation, null);
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
await _store.clear(userUuid);
|
||||
if (session != null) await authService.signOut(session);
|
||||
}
|
||||
|
||||
void _setSessionAndRebind(String userUuid, int generation, SeerrSession? session) {
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
_userRefresh.reset();
|
||||
_session = session;
|
||||
_catalogClient?.dispose();
|
||||
_catalogClient = session == null
|
||||
@@ -155,7 +171,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
void _handleSessionUpdated(String userUuid, int generation, SeerrSession session) {
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
_session = session;
|
||||
unawaited(_enqueuePersistence(() => _store.save(userUuid, session)).catchError(_logPersistenceFailure));
|
||||
unawaited(_store.save(userUuid, session).catchError(_logPersistenceFailure));
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@@ -164,7 +180,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
void _handleSessionInvalidated(String userUuid, int generation) {
|
||||
if (!_isCurrentBinding(userUuid, generation)) return;
|
||||
final nextGeneration = ++_bindingGeneration;
|
||||
unawaited(_enqueuePersistence(() => _store.clear(userUuid)).catchError(_logPersistenceFailure));
|
||||
unawaited(_store.clear(userUuid).catchError(_logPersistenceFailure));
|
||||
_setSessionAndRebind(userUuid, nextGeneration, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import '../models/catalog/catalog_item.dart';
|
||||
import '../models/catalog/catalog_labels.dart';
|
||||
import '../models/catalog/catalog_metadata.dart';
|
||||
import '../providers/catalog_sources_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/seerr_account_provider.dart';
|
||||
import '../services/catalog/catalog_library_matcher.dart';
|
||||
import '../services/catalog/catalog_source.dart';
|
||||
import '../services/catalog/seerr_catalog_source.dart';
|
||||
@@ -93,7 +95,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
|
||||
List<FocusNode> _libraryMatchFocusNodes = const [];
|
||||
CatalogSource? _watchlistSource;
|
||||
SeerrCatalogSource? _requestSource;
|
||||
bool _mutatingWatchlist = false;
|
||||
|
||||
CatalogItem? _detailItem;
|
||||
@@ -101,11 +102,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
/// Library items matching this catalog item; null while resolving.
|
||||
List<MediaItem>? _matches;
|
||||
|
||||
/// Servers that answered at least one resolution pass, and servers that
|
||||
/// failed one and have never answered. A server in the second set is shown
|
||||
/// as unchecked rather than counted as "not in your library" — a slow or
|
||||
/// unreachable server is no evidence of absence (#2098).
|
||||
final Set<String> _checkedServerIds = {};
|
||||
/// Coverage belongs to the newest launched (detail-enriched) query, not the
|
||||
/// union of historical successes. Verified copies are merged independently.
|
||||
int _resolutionGeneration = 0;
|
||||
bool _resolvingMatches = false;
|
||||
final Set<String> _uncheckedServerIds = {};
|
||||
|
||||
/// Cast/characters from the item's own source; null while loading (the
|
||||
@@ -129,16 +129,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
unawaited(_loadDetail());
|
||||
final sources = context.read<CatalogSourcesProvider>();
|
||||
_watchlistSource = sources.watchlistSourceFor(widget.item);
|
||||
// Request needs a connected Seerr, the permission for this kind, and a
|
||||
// tmdb id. The id is checked at build time from the detail-enriched item,
|
||||
// not here: Trakt items carry one natively and MAL items get theirs from
|
||||
// the Fribb mapping at row time, but Plex Discover's hub/search/related
|
||||
// endpoints ignore includeGuids, so a Plex item's tmdb id only arrives
|
||||
// with the detail fetch (issue #1959).
|
||||
final seerr = sources.seerrSource;
|
||||
if (seerr != null && seerr.canRequest(widget.item.kind)) {
|
||||
_requestSource = seerr;
|
||||
}
|
||||
final source = _watchlistSource;
|
||||
if (source != null) {
|
||||
source.watchlistChanges.addListener(_onWatchlistChanged);
|
||||
@@ -177,22 +167,32 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _resolveMatches(CatalogItem item) async {
|
||||
final generation = ++_resolutionGeneration;
|
||||
_resolvingMatches = true;
|
||||
LibraryLookupResult result;
|
||||
try {
|
||||
result = await context.read<CatalogLibraryMatcher>().match(item);
|
||||
} catch (e) {
|
||||
appLogger.w('Catalog library match failed for ${item.identityKey}', error: e);
|
||||
// A failed pass is no evidence about copies an earlier pass already
|
||||
// found; only claim "not in your library" when nothing has resolved.
|
||||
if (_matches == null) _mergeMatches(const []);
|
||||
if (!mounted) return;
|
||||
if (generation == _resolutionGeneration) {
|
||||
_resolvingMatches = false;
|
||||
_uncheckedServerIds
|
||||
..clear()
|
||||
..addAll(context.read<MultiServerProvider>().expectedServerIds);
|
||||
}
|
||||
_mergeMatches(const []);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
_checkedServerIds.addAll(result.succeededServerIds);
|
||||
_uncheckedServerIds
|
||||
..addAll(result.failedServerIds)
|
||||
..addAll(result.cancelledServerIds)
|
||||
..removeAll(_checkedServerIds);
|
||||
if (generation == _resolutionGeneration) {
|
||||
_resolvingMatches = false;
|
||||
_uncheckedServerIds
|
||||
..clear()
|
||||
..addAll(result.failedServerIds)
|
||||
..addAll(result.cancelledServerIds)
|
||||
..addAll(result.unqueriedServerIds);
|
||||
}
|
||||
_mergeMatches(result.items);
|
||||
}
|
||||
|
||||
@@ -319,8 +319,24 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
|
||||
bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false;
|
||||
|
||||
/// The Seerr source when the signed-in user may request this kind. Read
|
||||
/// live rather than captured in initState: the account provider adopts
|
||||
/// permission changes in place (bind-time refresh, silent re-auth, a
|
||||
/// denied request's `/auth/me` probe) without rebuilding the client, and a
|
||||
/// disconnect swaps the source for null — a captured source would outlive
|
||||
/// its disposed client. `build` subscribes to both so the action bar
|
||||
/// tracks grants and revocations.
|
||||
SeerrCatalogSource? get _requestSource {
|
||||
final seerr = context.read<CatalogSourcesProvider>().seerrSource;
|
||||
return seerr != null && seerr.canRequest(widget.item.kind) ? seerr : null;
|
||||
}
|
||||
|
||||
/// Whether the Request action can actually render: the tmdb id may only
|
||||
/// arrive with the detail fetch (see initState), so read the enriched item.
|
||||
/// arrive with the detail fetch (see [_loadDetail]), so read the enriched
|
||||
/// item. Trakt items carry one natively and MAL items get theirs from the
|
||||
/// Fribb mapping at row time, but Plex Discover's hub/search/related
|
||||
/// endpoints ignore includeGuids, so a Plex item's tmdb id only arrives
|
||||
/// with the detail fetch (issue #1959).
|
||||
bool get _canRequest => _requestSource != null && _item.ids.tmdb != null;
|
||||
|
||||
bool get _hasActions => _watchlistSource != null || _canRequest || _hasTrailer;
|
||||
@@ -660,7 +676,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: mutedColor);
|
||||
final matches = _matches;
|
||||
|
||||
if (matches == null) {
|
||||
if (matches == null || (matches.isEmpty && _resolvingMatches)) {
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
@@ -1289,6 +1305,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
final theme = Theme.of(context);
|
||||
final onWatchlist = _isOnWatchlist;
|
||||
final tmdbId = item.ids.tmdb;
|
||||
// Subscriptions for [_requestSource]: the source itself (disconnect) and
|
||||
// the permission mask (grant/revoke), both adopted in place upstream.
|
||||
context.select<CatalogSourcesProvider, SeerrCatalogSource?>((sources) => sources.seerrSource);
|
||||
context.select<SeerrAccountProvider?, int?>((account) => account?.permissions);
|
||||
final artworkDpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
// The backdrop strip spans the full screen width (Positioned left/right: 0
|
||||
// below), and backdropFor is width-keyed: target the rendered width, not
|
||||
@@ -1343,161 +1363,176 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
|
||||
Text(
|
||||
tagline,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
|
||||
fontStyle: FontStyle.italic,
|
||||
// Horizontal-only SafeArea: the backdrop above stays
|
||||
// full-bleed; only the foreground content clears the
|
||||
// landscape notch. Vertical insets are baked into the
|
||||
// padding (see above).
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
|
||||
Text(
|
||||
tagline,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
item.title,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
if (_metaLine.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
_metaLine,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
item.title,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
],
|
||||
if (item.genres?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.genres!.join(' • '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
if (_metaLine.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_metaLine,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_hasActions)
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
onNavigateDown: _focusSectionBelowActions,
|
||||
actions: [
|
||||
if (_watchlistSource != null)
|
||||
FocusableAction(
|
||||
// Stable identities so the focused binding survives the
|
||||
// list-shape changes of async enrichment (watchlist/request
|
||||
// sources and trailer URL arrive at different times).
|
||||
debugLabel: 'catalog_watchlist',
|
||||
icon: onWatchlist ?? false
|
||||
? Symbols.bookmark_added_rounded
|
||||
: Symbols.bookmark_add_rounded,
|
||||
tooltip: onWatchlist ?? false
|
||||
? t.explore.removeFromWatchlist
|
||||
: t.explore.addToWatchlist,
|
||||
onPressed: () => unawaited(_toggleWatchlist()),
|
||||
),
|
||||
if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null)
|
||||
FocusableAction(
|
||||
debugLabel: 'catalog_request',
|
||||
icon: Symbols.download_rounded,
|
||||
tooltip: t.seerr.request,
|
||||
onPressed: () => unawaited(
|
||||
showSeerrRequestSheet(
|
||||
hostContext,
|
||||
source: seerr,
|
||||
kind: item.kind,
|
||||
tmdbId: tmdbId,
|
||||
title: item.title,
|
||||
],
|
||||
if (item.genres?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.genres!.join(' • '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_hasActions)
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
onNavigateDown: _focusSectionBelowActions,
|
||||
actions: [
|
||||
if (_watchlistSource != null)
|
||||
FocusableAction(
|
||||
// Stable identities so the focused binding survives the
|
||||
// list-shape changes of async enrichment (watchlist/request
|
||||
// sources and trailer URL arrive at different times).
|
||||
debugLabel: 'catalog_watchlist',
|
||||
icon: onWatchlist ?? false
|
||||
? Symbols.bookmark_added_rounded
|
||||
: Symbols.bookmark_add_rounded,
|
||||
tooltip: onWatchlist ?? false
|
||||
? t.explore.removeFromWatchlist
|
||||
: t.explore.addToWatchlist,
|
||||
onPressed: () => unawaited(_toggleWatchlist()),
|
||||
),
|
||||
if (_requestSource case final SeerrCatalogSource seerr
|
||||
when tmdbId != null)
|
||||
FocusableAction(
|
||||
debugLabel: 'catalog_request',
|
||||
icon: Symbols.download_rounded,
|
||||
tooltip: t.seerr.request,
|
||||
onPressed: () => unawaited(
|
||||
showSeerrRequestSheet(
|
||||
hostContext,
|
||||
source: seerr,
|
||||
kind: item.kind,
|
||||
tmdbId: tmdbId,
|
||||
title: item.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
|
||||
FocusableAction(
|
||||
debugLabel: 'catalog_trailer',
|
||||
icon: Symbols.play_circle_rounded,
|
||||
tooltip: t.explore.detail.watchTrailer,
|
||||
onPressed: () => unawaited(_openExternalUrl(trailer)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
|
||||
FocusableAction(
|
||||
debugLabel: 'catalog_trailer',
|
||||
icon: Symbols.play_circle_rounded,
|
||||
tooltip: t.explore.detail.watchTrailer,
|
||||
onPressed: () => unawaited(_openExternalUrl(trailer)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
|
||||
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
CollapsibleText(
|
||||
text: overview,
|
||||
maxLines: isMobile ? 6 : 4,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
focusNode: _overviewFocusNode,
|
||||
skipTraversal: false,
|
||||
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
|
||||
onNavigateDown: _focusSectionBelowOverview,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
|
||||
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
|
||||
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildBackgroundSection(theme, background, isMobile: isMobile),
|
||||
],
|
||||
if (_buildFactsSection(theme) case final Widget facts) ...[
|
||||
const SizedBox(height: 24),
|
||||
facts,
|
||||
],
|
||||
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
|
||||
const SizedBox(height: 24),
|
||||
recommenders,
|
||||
],
|
||||
if (_buildRatingsSection(theme) case final Widget ratings) ...[
|
||||
const SizedBox(height: 24),
|
||||
ratings,
|
||||
],
|
||||
if (_buildScheduleSection(theme) case final Widget schedule) ...[
|
||||
const SizedBox(height: 24),
|
||||
schedule,
|
||||
],
|
||||
if (_buildCrewSection(theme) case final Widget crew) ...[
|
||||
const SizedBox(height: 24),
|
||||
crew,
|
||||
],
|
||||
if (_buildTagsSection(theme) case final Widget tags) ...[
|
||||
const SizedBox(height: 24),
|
||||
tags,
|
||||
],
|
||||
if (_streamingLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
|
||||
],
|
||||
if (_otherLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
CollapsibleText(
|
||||
text: overview,
|
||||
maxLines: isMobile ? 6 : 4,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
focusNode: _overviewFocusNode,
|
||||
skipTraversal: false,
|
||||
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
|
||||
onNavigateDown: _focusSectionBelowOverview,
|
||||
),
|
||||
_buildLibrarySection(theme),
|
||||
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
_buildCastSection(theme, cast),
|
||||
],
|
||||
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
|
||||
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildRelatedSection(related),
|
||||
],
|
||||
],
|
||||
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildBackgroundSection(theme, background, isMobile: isMobile),
|
||||
],
|
||||
if (_buildFactsSection(theme) case final Widget facts) ...[
|
||||
const SizedBox(height: 24),
|
||||
facts,
|
||||
],
|
||||
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
|
||||
const SizedBox(height: 24),
|
||||
recommenders,
|
||||
],
|
||||
if (_buildRatingsSection(theme) case final Widget ratings) ...[
|
||||
const SizedBox(height: 24),
|
||||
ratings,
|
||||
],
|
||||
if (_buildScheduleSection(theme) case final Widget schedule) ...[
|
||||
const SizedBox(height: 24),
|
||||
schedule,
|
||||
],
|
||||
if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew],
|
||||
if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags],
|
||||
if (_streamingLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
|
||||
],
|
||||
if (_otherLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
_buildLibrarySection(theme),
|
||||
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
_buildCastSection(theme, cast),
|
||||
],
|
||||
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
|
||||
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildRelatedSection(related),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+119
-102
@@ -108,6 +108,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
bool _initialLoadComplete = false;
|
||||
bool _pendingTvBrowseRailFocus = false;
|
||||
|
||||
/// Primary focus when the rail claim was armed; see [_railClaimAbandoned].
|
||||
FocusNode? _railClaimFocusOrigin;
|
||||
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
||||
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||
@@ -255,6 +258,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
_pendingTvBrowseRailFocus = true;
|
||||
_railClaimFocusOrigin = FocusManager.instance.primaryFocus;
|
||||
if (immediate && _tvBrowseHubs.isNotEmpty) {
|
||||
final rail = _tvBrowseRailKey.currentState;
|
||||
if (rail != null) {
|
||||
@@ -266,7 +270,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false) || _focusHasLeftScreen) {
|
||||
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
return;
|
||||
}
|
||||
@@ -280,12 +284,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
/// A rail-focus request stays armed while the rail has no hubs to focus
|
||||
/// (empty first load), so hubs landing later still receive it. It must not
|
||||
/// outlive the user's own navigation: once focus sits on a control off this
|
||||
/// screen (a sidebar item), hubs arriving minutes later would yank the
|
||||
/// remote back. A bare scope — MainScreen's content scope before any child
|
||||
/// has focus — is "nowhere yet", not a destination, and keeps the request.
|
||||
bool get _focusHasLeftScreen {
|
||||
/// outlive the user's own navigation: hubs arriving minutes later would
|
||||
/// yank the remote off a sidebar item the user has since moved to.
|
||||
///
|
||||
/// Where focus *sits* cannot tell those apart — MainScreen hands a tab over
|
||||
/// while focus is still on the sidebar item that selected it, and that
|
||||
/// request is as live as one made from a bare scope. What distinguishes a
|
||||
/// stale claim is that focus *moved* after the request was armed and now
|
||||
/// rests on a control off this screen. A bare scope — MainScreen's content
|
||||
/// scope before any child has focus — is "nowhere yet", not a destination.
|
||||
bool get _railClaimAbandoned {
|
||||
final node = FocusManager.instance.primaryFocus;
|
||||
if (identical(node, _railClaimFocusOrigin)) return false;
|
||||
final focusContext = node?.context;
|
||||
if (node == null || node is FocusScopeNode || focusContext == null) return false;
|
||||
return !identical(focusContext.findAncestorStateOfType<_DiscoverScreenState>(), this);
|
||||
@@ -293,7 +303,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
void _applyPendingTvBrowseRailFocus() {
|
||||
if (!_pendingTvBrowseRailFocus) return;
|
||||
if (_focusHasLeftScreen) {
|
||||
if (_railClaimAbandoned) {
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
return;
|
||||
}
|
||||
@@ -1403,112 +1413,119 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
: isLargeScreen
|
||||
? 200
|
||||
: 0,
|
||||
child: Padding(
|
||||
padding: .symmetric(
|
||||
horizontal: isTv
|
||||
? TvLayoutConstants.horizontalInset
|
||||
: isLargeScreen
|
||||
? 40
|
||||
: 24,
|
||||
),
|
||||
child: Align(
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Show logo, falling back to the name/title
|
||||
ClearLogoImage(
|
||||
client: heroClient,
|
||||
logoPath: heroItem.clearLogoPath,
|
||||
width: heroLogoWidth,
|
||||
height: heroLogoHeight,
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
// The hero scrim washes artwork toward the scaffold
|
||||
// background; light themes recolor light-toned logos.
|
||||
logoToneTarget: logoToneTargetFor(
|
||||
surface: theme.scaffoldBackgroundColor,
|
||||
foreground: colorScheme.onSurface,
|
||||
),
|
||||
fallbackBuilder: (context) => FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
|
||||
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null) heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: isTv ? 18 : 14,
|
||||
fontWeight: .w600,
|
||||
// Horizontal-only SafeArea: the PageView artwork behind stays
|
||||
// full-bleed; the foreground text/buttons clear the landscape
|
||||
// notch. Vertical placement is handled by `bottom` above.
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: .symmetric(
|
||||
horizontal: isTv
|
||||
? TvLayoutConstants.horizontalInset
|
||||
: isLargeScreen
|
||||
? 40
|
||||
: 24,
|
||||
),
|
||||
child: Align(
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Show logo, falling back to the name/title
|
||||
ClearLogoImage(
|
||||
client: heroClient,
|
||||
logoPath: heroItem.clearLogoPath,
|
||||
width: heroLogoWidth,
|
||||
height: heroLogoHeight,
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
// The hero scrim washes artwork toward the scaffold
|
||||
// background; light themes recolor light-toned logos.
|
||||
logoToneTarget: logoToneTargetFor(
|
||||
surface: theme.scaffoldBackgroundColor,
|
||||
foreground: colorScheme.onSurface,
|
||||
),
|
||||
fallbackBuilder: (context) => FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
),
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
|
||||
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null) heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: isTv ? 18 : 14,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
maxLines: isTv ? 3 : 2,
|
||||
overflow: .ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
text: TextSpan(
|
||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
maxLines: isTv ? 3 : 2,
|
||||
overflow: .ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 18 : 14,
|
||||
height: isTv ? 1.45 : 1.4,
|
||||
),
|
||||
children: [
|
||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||
TextSpan(
|
||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: TextStyle(fontWeight: .bold, color: colorScheme.onSurface),
|
||||
),
|
||||
TextSpan(
|
||||
text: heroItem.summary?.isNotEmpty == true
|
||||
? heroItem.summary!
|
||||
: t.messages.noDescriptionAvailable,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (shouldHideSpoiler &&
|
||||
isEpisode &&
|
||||
heroItem.parentIndex != null &&
|
||||
heroItem.index != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 18 : 14,
|
||||
height: isTv ? 1.45 : 1.4,
|
||||
),
|
||||
children: [
|
||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||
TextSpan(
|
||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: TextStyle(fontWeight: .bold, color: colorScheme.onSurface),
|
||||
),
|
||||
TextSpan(
|
||||
text: heroItem.summary?.isNotEmpty == true
|
||||
? heroItem.summary!
|
||||
: t.messages.noDescriptionAvailable,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (shouldHideSpoiler &&
|
||||
isEpisode &&
|
||||
heroItem.parentIndex != null &&
|
||||
heroItem.index != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 18 : 14,
|
||||
height: isTv ? 1.45 : 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,8 +26,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
// Action bar key for accessing focus nodes
|
||||
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
|
||||
// Grid item focus
|
||||
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
|
||||
// The first item shares grid ownership and can move to another index.
|
||||
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: 'detail_first_item');
|
||||
|
||||
// App bar focus state
|
||||
bool isAppBarFocused = false;
|
||||
@@ -44,7 +44,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
/// Dispose focus-related resources. Call this from your dispose() method.
|
||||
void disposeFocusResources() {
|
||||
scrollController.dispose();
|
||||
firstItemFocusNode.dispose();
|
||||
disposeGridFocusNodes();
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
_focusNodeForIndex(targetIndex).requestFocus();
|
||||
}
|
||||
|
||||
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item');
|
||||
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(
|
||||
index,
|
||||
firstItemFocusNode,
|
||||
prefix: 'hub_detail_item',
|
||||
itemIdentity: _filteredItems[index].globalKey,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -230,6 +235,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
});
|
||||
}
|
||||
});
|
||||
_remapFocusToFocusedItem();
|
||||
}
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
@@ -444,6 +450,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
if (hasFocus && isLastRow) _requestNextHubPage();
|
||||
}
|
||||
|
||||
/// Reconcile all realized items, including references captured under a cover.
|
||||
void _remapFocusToFocusedItem() {
|
||||
final indices = <String, int>{for (var i = 0; i < _filteredItems.length; i++) _filteredItems[i].globalKey: i};
|
||||
reconcileGridFocusNodes(indices);
|
||||
}
|
||||
|
||||
void _handleContinuationStateChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
@@ -556,6 +568,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
return MediaCardSliverLayout(
|
||||
viewMode: viewMode,
|
||||
itemCount: _filteredItems.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final id = (key as ValueKey<String>).value;
|
||||
final index = _filteredItems.indexWhere((item) => item.globalKey == id);
|
||||
return index < 0 ? null : index;
|
||||
},
|
||||
density: libraryDensity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
useWideAspectRatio: useWideLayout,
|
||||
@@ -567,6 +584,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
// Keyed by item, not by slot: a re-sort must move
|
||||
// the element with its item instead of silently
|
||||
// updating it with a different one. Aggregated
|
||||
// hubs mix servers, so the id alone can collide.
|
||||
key: Key(item.globalKey),
|
||||
focusNode: focusNode,
|
||||
item: item,
|
||||
disableScale: position.disableScale,
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../mixins/context_menu_tap_mixin.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../widgets/settings_builder.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/focus_utils.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../../widgets/media_context_menu.dart';
|
||||
@@ -161,12 +162,8 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
|
||||
if (selected == null) {
|
||||
// Dismissed — restore focus to the row. Play/Shuffle hand off to the
|
||||
// player instead (mirrors MediaContextMenu's didNavigate handling).
|
||||
if (previousFocus != null && previousFocus.canRequestFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (previousFocus.canRequestFocus) {
|
||||
previousFocus.requestFocus();
|
||||
}
|
||||
});
|
||||
if (previousFocus != null) {
|
||||
FocusUtils.restoreFocusAfterBuild(this, previousFocus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,10 @@ class FolderTreeView extends StatefulWidget {
|
||||
|
||||
/// Public state so parents can trigger a refresh via GlobalKey.
|
||||
class FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// Reload the root folders. Exposed for parent-driven pull-to-refresh.
|
||||
Future<void> refresh() => _loadRootFolders();
|
||||
/// Reload the root folders. Exposed for parent-driven refreshes; resolves
|
||||
/// `true` only when the fresh root listing was applied under the current
|
||||
/// load epoch (not superseded, unmounted, or failed).
|
||||
Future<bool> refresh() => _loadRootFolders();
|
||||
|
||||
/// Folders/items returned by the backend's folder API and mapped to neutral
|
||||
/// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key'];
|
||||
@@ -90,7 +92,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
return epoch;
|
||||
}
|
||||
|
||||
Future<void> _loadRootFolders() async {
|
||||
Future<bool> _loadRootFolders() async {
|
||||
final epoch = _supersedeInFlightLoads();
|
||||
setState(() {
|
||||
_isLoadingRoot = true;
|
||||
@@ -110,7 +112,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted || epoch != _loadEpoch) return;
|
||||
if (!mounted || epoch != _loadEpoch) return false;
|
||||
|
||||
setState(() {
|
||||
_rootFolders = folders;
|
||||
@@ -118,14 +120,16 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
});
|
||||
|
||||
appLogger.d('Loaded ${folders.length} root folders');
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted || epoch != _loadEpoch) return;
|
||||
if (!mounted || epoch != _loadEpoch) return false;
|
||||
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders);
|
||||
setState(() {
|
||||
_errorMessage = message;
|
||||
_isLoadingRoot = false;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../focus/input_mode_tracker.dart';
|
||||
@@ -93,13 +94,22 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
late final RefreshPacer _livePacer;
|
||||
bool _inPlaceReload = false;
|
||||
|
||||
/// The effective [TickerMode] notifier this tab listens to for
|
||||
/// hidden→visible transitions (a pushed opaque route popping). Rebound
|
||||
/// when the ancestor changes; `null` until first bound.
|
||||
ValueListenable<TickerModeData>? _tickerModeNotifier;
|
||||
|
||||
/// The library content epoch (see [LibrariesProvider.libraryContentEpoch])
|
||||
/// this tab's data was loaded under.
|
||||
/// this tab's data was committed under.
|
||||
int _loadedLibraryContentEpoch = 0;
|
||||
|
||||
/// The provider epoch captured when the current load started, so a push
|
||||
/// landing mid-fetch is not marked consumed by data that predates it.
|
||||
int _libraryContentEpochAtLoadStart = 0;
|
||||
/// The epoch snapshot of the load currently in flight, or `null` when no
|
||||
/// load is pending. Its only job is dedupe: an activation or visibility
|
||||
/// flip that lands mid-fetch must not start a second reload for the same
|
||||
/// push. It is never committed — only a successful commit moves
|
||||
/// [_loadedLibraryContentEpoch], so a failed load leaves the library stale
|
||||
/// and the next activation retries.
|
||||
int? _inFlightLibraryContentEpoch;
|
||||
|
||||
// Focus management
|
||||
bool _hasLoadedData = false;
|
||||
@@ -126,10 +136,11 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
@protected
|
||||
int get libraryLoadGeneration => _loadGeneration;
|
||||
|
||||
/// Start a full load: bump the generation that gates late commits and
|
||||
/// snapshot the epoch this operation will credit on success.
|
||||
@protected
|
||||
int beginLibraryLoad() {
|
||||
snapshotLibraryContentEpoch();
|
||||
return ++_loadGeneration;
|
||||
({int generation, int epoch}) beginLibraryLoad() {
|
||||
return (generation: ++_loadGeneration, epoch: snapshotLibraryContentEpoch());
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -170,21 +181,63 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
_liveLibrarySubscription = LibraryContentNotifier().stream.listen(_onLiveLibraryChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_updateTickerModeNotifier();
|
||||
}
|
||||
|
||||
@override
|
||||
void activate() {
|
||||
super.activate();
|
||||
// Reparented: the TickerMode ancestor may have changed.
|
||||
_updateTickerModeNotifier();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
invalidateLibraryLoad();
|
||||
_refreshSubscription?.cancel();
|
||||
_liveLibrarySubscription?.cancel();
|
||||
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
|
||||
_tickerModeNotifier = null;
|
||||
_livePacer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Mirrors [TickerProviderStateMixin]: [TickerMode.getValuesNotifier] reads
|
||||
/// the ancestor without registering a dependency, so rebind on the
|
||||
/// lifecycle hooks where the ancestor can change.
|
||||
void _updateTickerModeNotifier() {
|
||||
final notifier = TickerMode.getValuesNotifier(context);
|
||||
if (identical(notifier, _tickerModeNotifier)) return;
|
||||
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
|
||||
_tickerModeNotifier = notifier..addListener(_onSurfaceVisibilityChanged);
|
||||
}
|
||||
|
||||
/// A push that landed while this tab sat under an opaque route (or behind
|
||||
/// another main tab) was dropped by [_onLiveLibraryChange], but the
|
||||
/// provider still marked the epoch. Nothing else observes the route pop —
|
||||
/// `isActive` never flips — so replay it here through the same paced
|
||||
/// in-place path a live event takes. Main-tab activation is already in
|
||||
/// hand: [LibrariesScreen.onTabShown] starts its reload synchronously
|
||||
/// before the frame that flips [TickerMode], and that load's in-flight
|
||||
/// snapshot keeps the epoch from reading stale here.
|
||||
void _onSurfaceVisibilityChanged() {
|
||||
if (!mounted || !_isLiveSurfaceVisible) return;
|
||||
if (!_isLibraryContentStale) return;
|
||||
_livePacer.schedule();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(W oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
||||
invalidateLibraryLoad();
|
||||
// The previous library's credit must not vouch for this one's data.
|
||||
_loadedLibraryContentEpoch = 0;
|
||||
_inFlightLibraryContentEpoch = null;
|
||||
// Reset focus state for new library
|
||||
hasFocused = false;
|
||||
_hasFocusedChromeFallback = false;
|
||||
@@ -215,42 +268,61 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the provider's current epoch at load start. [beginLibraryLoad]
|
||||
/// does this for transaction-based tabs; paginated pipelines that bypass it
|
||||
/// call this at the top of their load instead.
|
||||
/// Capture the provider's current epoch at the start of a load and mark it
|
||||
/// in flight. [beginLibraryLoad] does this for transaction-based tabs;
|
||||
/// paginated pipelines that bypass it call this at the top of their load
|
||||
/// and hand the returned value to [recordLibraryContentEpoch] on commit.
|
||||
@protected
|
||||
void snapshotLibraryContentEpoch() {
|
||||
final provider = _librariesProviderOrNull();
|
||||
if (provider != null) {
|
||||
_libraryContentEpochAtLoadStart = provider.libraryContentEpoch(widget.library.globalKey);
|
||||
}
|
||||
int snapshotLibraryContentEpoch() {
|
||||
final epoch = _providerLibraryContentEpoch() ?? 0;
|
||||
_inFlightLibraryContentEpoch = epoch;
|
||||
return epoch;
|
||||
}
|
||||
|
||||
/// Mark the load-start epoch snapshot consumed alongside a successful
|
||||
/// commit, so [refreshIfLibraryContentStale] can tell whether a server push
|
||||
/// has marked the library since. Deliberately the snapshot, not the current
|
||||
/// epoch: a push that landed while the fetch was in flight stays stale.
|
||||
/// Subclasses with their own load pipeline call this after committing
|
||||
/// fresh data.
|
||||
/// Commit [epoch] — the snapshot the committing operation took at its own
|
||||
/// start — so [refreshIfLibraryContentStale] can tell whether a server
|
||||
/// push has marked the library since. Deliberately that snapshot, not the
|
||||
/// current epoch: a push that landed while the fetch was in flight stays
|
||||
/// stale. Only call after fresh data actually landed on screen.
|
||||
@protected
|
||||
void recordLibraryContentEpoch() {
|
||||
_loadedLibraryContentEpoch = _libraryContentEpochAtLoadStart;
|
||||
void recordLibraryContentEpoch(int epoch) {
|
||||
_loadedLibraryContentEpoch = epoch;
|
||||
_inFlightLibraryContentEpoch = null;
|
||||
// A committed load is as good as a live pass: defer a push event landing
|
||||
// moments later to the cooldown's trailing edge.
|
||||
_livePacer.notePass();
|
||||
}
|
||||
|
||||
/// Settle a load that snapshotted the epoch but committed nothing (failure,
|
||||
/// or a fetch whose result is not what this tab renders). Nothing is
|
||||
/// credited; clearing the in-flight snapshot lets the next activation
|
||||
/// retry. Superseded loads must not call this — the newer load owns the
|
||||
/// snapshot.
|
||||
@protected
|
||||
void releaseLibraryContentEpoch() {
|
||||
_inFlightLibraryContentEpoch = null;
|
||||
}
|
||||
|
||||
/// The provider's current epoch for this library, or `null` without a
|
||||
/// [LibrariesProvider] (nothing marks staleness then).
|
||||
int? _providerLibraryContentEpoch() {
|
||||
return _librariesProviderOrNull()?.libraryContentEpoch(widget.library.globalKey);
|
||||
}
|
||||
|
||||
/// Whether a server push marked this library's content since the last
|
||||
/// committed load, and no load started since is already fetching it.
|
||||
bool get _isLibraryContentStale {
|
||||
final epoch = _providerLibraryContentEpoch();
|
||||
return epoch != null && epoch != _loadedLibraryContentEpoch && epoch != _inFlightLibraryContentEpoch;
|
||||
}
|
||||
|
||||
/// Reload once when a server push marked this library's content stale since
|
||||
/// the last load (#1646). Called when the tab is next shown — never live —
|
||||
/// so scroll position and focus are undisturbed while the user is on it.
|
||||
/// The epoch is credited by the load's own commit, never here: a reload
|
||||
/// that fails must leave the library stale so the next activation retries.
|
||||
void refreshIfLibraryContentStale() {
|
||||
if (!mounted) return;
|
||||
final provider = _librariesProviderOrNull();
|
||||
if (provider == null) return;
|
||||
final epoch = provider.libraryContentEpoch(widget.library.globalKey);
|
||||
if (epoch == _loadedLibraryContentEpoch) return;
|
||||
_loadedLibraryContentEpoch = epoch;
|
||||
_libraryContentEpochAtLoadStart = epoch;
|
||||
if (!mounted || !_isLibraryContentStale) return;
|
||||
loadItems();
|
||||
}
|
||||
|
||||
@@ -283,8 +355,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
|
||||
void _onLiveLibraryChange(LibraryChangeEvent event) {
|
||||
if (!mounted || !event.hasChanges || !_eventTargetsLibrary(event)) return;
|
||||
// Only the active tab live-updates; hidden siblings reload on activation
|
||||
// via [refreshIfLibraryContentStale] instead of fetching invisibly.
|
||||
// Only the visible tab live-updates. Inactive siblings reload on
|
||||
// activation via [refreshIfLibraryContentStale]; an active tab under a
|
||||
// route or another main tab replays the drop when [TickerMode] re-enables
|
||||
// it ([_onSurfaceVisibilityChanged]) — never fetch invisibly.
|
||||
if (!_isLiveSurfaceVisible) return;
|
||||
_livePacer.schedule();
|
||||
}
|
||||
@@ -371,12 +445,13 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
|
||||
/// Post-load bookkeeping for tabs that replace [loadItems] with their own
|
||||
/// (paginated) fetch: mark the tab loaded, take focus if it's due, and let
|
||||
/// the parent know once the frame carrying the items is in.
|
||||
/// (paginated) fetch: mark the tab loaded, commit [libraryContentEpoch]
|
||||
/// (the load's own [snapshotLibraryContentEpoch]), take focus if it's due,
|
||||
/// and let the parent know once the frame carrying the items is in.
|
||||
@protected
|
||||
void markItemsLoaded() {
|
||||
void markItemsLoaded(int libraryContentEpoch) {
|
||||
_hasLoadedData = true;
|
||||
recordLibraryContentEpoch();
|
||||
recordLibraryContentEpoch(libraryContentEpoch);
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -417,9 +492,9 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
@protected
|
||||
Future<void> runLoadTransaction(Future<List<T>> Function() fetch) async {
|
||||
if (!mounted) return;
|
||||
final inPlace = _inPlaceReload && _items.isNotEmpty;
|
||||
final inPlace = _inPlaceReload && hasFocusableContent;
|
||||
_inPlaceReload = false;
|
||||
final loadGeneration = beginLibraryLoad();
|
||||
final (generation: loadGeneration, :epoch) = beginLibraryLoad();
|
||||
final libraryGlobalKey = widget.library.globalKey;
|
||||
|
||||
if (!inPlace) {
|
||||
@@ -433,17 +508,27 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
try {
|
||||
final loadedItems = await fetch();
|
||||
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
||||
final focusedBeforeCommit = FocusManager.instance.primaryFocus;
|
||||
final contentOwnedFocus =
|
||||
focusedBeforeCommit?.context?.findAncestorStateOfType<BaseLibraryTabState<T, W>>() == this;
|
||||
|
||||
setState(() {
|
||||
_items = loadedItems;
|
||||
_isLoading = false;
|
||||
_hasLoadedData = true;
|
||||
});
|
||||
recordLibraryContentEpoch();
|
||||
tryFocus();
|
||||
recordLibraryContentEpoch(epoch);
|
||||
// A populated live pass is not a tab-entry focus handoff. In particular,
|
||||
// its completion must not reclaim focus from chrome or a covering route.
|
||||
if (!inPlace) tryFocus();
|
||||
if (inPlace && !hasFocusableContent && contentOwnedFocus && _isLiveSurfaceVisible) {
|
||||
// Only an emptied, actively focused surface hands back to its chrome.
|
||||
// A menu, route, or sidebar that already owns focus remains untouched.
|
||||
focusEmptyState();
|
||||
}
|
||||
|
||||
final onDataLoaded = widget.onDataLoaded;
|
||||
if (onDataLoaded != null) {
|
||||
if (!inPlace && onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
|
||||
onDataLoaded();
|
||||
@@ -452,6 +537,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
||||
// Nothing landed: the library stays stale for the next activation.
|
||||
releaseLibraryContentEpoch();
|
||||
if (inPlace) {
|
||||
// Best-effort background refresh: keep the visible content.
|
||||
appLogger.d('Live library refresh failed for ${widget.library.globalKey}', error: e);
|
||||
|
||||
@@ -100,8 +100,8 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
|
||||
class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrowseTab>
|
||||
with
|
||||
ItemUpdatable,
|
||||
LibraryTabFocusMixin,
|
||||
GridFocusNodeMixin,
|
||||
LibraryTabFocusMixin,
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
DeletionMirrorsWatchState,
|
||||
@@ -160,6 +160,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (matchEntry != null) {
|
||||
setState(() {
|
||||
removeLoadedItemAndShift(matchEntry.key);
|
||||
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +176,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (newLeafCount <= 0) {
|
||||
setState(() {
|
||||
removeLoadedItemAndShift(parentEntry.key);
|
||||
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -207,7 +209,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// place (Plex Web's `repopulateRange`) so new items materialize at their
|
||||
/// sorted positions and metadata updates land, then keep the first visible
|
||||
/// item stationary by compensating the scroll offset for any index shift
|
||||
/// the merge caused. Folder grouping browses a tree, not the flat index
|
||||
/// the merge caused, and carry the D-pad highlight to wherever the focused
|
||||
/// item moved. Folder grouping browses a tree, not the flat index
|
||||
/// space — the activation staleness path owns it there. An error or empty
|
||||
/// grid falls back to the clearing reload: nothing visible to preserve,
|
||||
/// and it is the only way a first item can appear live.
|
||||
@@ -219,7 +222,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// The first visible slot may be an unloaded skeleton after a fast jump;
|
||||
// anchor on it only when its item is known.
|
||||
final anchorId = anchorIndex == null ? null : loadedItems[anchorIndex]?.id;
|
||||
snapshotLibraryContentEpoch();
|
||||
final epoch = snapshotLibraryContentEpoch();
|
||||
// Bound the refetch: after an alpha jump the map holds disjoint clusters
|
||||
// whose naive span is nearly the whole library. Keep per-index caches in
|
||||
// lockstep with the dropped entries.
|
||||
@@ -234,11 +237,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
maxSpan: maxSpan,
|
||||
windowCenter: anchorIndex,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
recordLibraryContentEpoch();
|
||||
if (!mounted) return;
|
||||
if (result == null) {
|
||||
// Failed or superseded: nothing landed, so nothing is credited.
|
||||
releaseLibraryContentEpoch();
|
||||
return;
|
||||
}
|
||||
recordLibraryContentEpoch(epoch);
|
||||
// Alpha-bar bucket counts shifted with the content; refresh is cheap and
|
||||
// best-effort.
|
||||
unawaited(_loadFirstCharacters());
|
||||
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
|
||||
|
||||
final oldIndex = result.anchorOldIndex;
|
||||
final newIndex = result.anchorNewIndex;
|
||||
@@ -256,6 +265,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
pos.jumpTo((pos.pixels + rowDelta * _scrollMetrics.rowHeight).clamp(0.0, pos.maxScrollExtent));
|
||||
}
|
||||
|
||||
/// Index currently holding the item with [id], or null when [id] is null or
|
||||
/// the item is no longer loaded.
|
||||
int? _loadedIndexOfId(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.id == id) return entry.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Browse-specific state (not in base class)
|
||||
List<MediaFilter> _filters = [];
|
||||
List<MediaSort> _sortOptions = [];
|
||||
@@ -289,7 +308,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// never outlive the focus nodes its cached cards capture.
|
||||
static const int _focusNodeKeepCount = 200;
|
||||
double _effectiveTopPadding = _gridTopPadding;
|
||||
final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item');
|
||||
double? _measuredListRowHeight;
|
||||
int? _listMetricsDensity;
|
||||
bool? _listMetricsUsesWideRatio;
|
||||
@@ -346,6 +364,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
oldWidget.library.serverId != widget.library.serverId ||
|
||||
oldWidget.library.isShared != widget.library.isShared) {
|
||||
_alphaStrategy = _createAlphaStrategy();
|
||||
cleanupGridFocusNodes(0);
|
||||
_cardMemo.clear();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.canGroupByFolders != widget.canGroupByFolders) {
|
||||
@@ -564,7 +584,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (!mounted) return;
|
||||
final library = widget.library;
|
||||
final libraryGlobalKey = library.globalKey;
|
||||
final generation = beginLibraryLoad();
|
||||
final (:generation, :epoch) = beginLibraryLoad();
|
||||
final firstCharactersGeneration = ++_firstCharactersRequestId;
|
||||
|
||||
_resetForFullReload();
|
||||
@@ -642,11 +662,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
// Load items and first characters in parallel.
|
||||
await Future.wait([
|
||||
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey),
|
||||
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey, epoch: epoch),
|
||||
_loadFirstCharacters(requestId: firstCharactersGeneration),
|
||||
]);
|
||||
// Folder grouping renders the tree, not the flat page: a full reload
|
||||
// (activation staleness, library change) must refetch what is shown.
|
||||
if (_selectedGrouping == 'folders' && isCurrentLibraryLoad(generation, libraryGlobalKey)) {
|
||||
await _refreshFolderTree();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
releaseLibraryContentEpoch();
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
setState(() {
|
||||
@@ -656,6 +682,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the mounted [FolderTreeView] and credit the epoch only when its
|
||||
/// root listing actually landed. A tree that is not mounted yet loads
|
||||
/// itself on mount; nothing is credited then, so a push that predates the
|
||||
/// mount is still owed to the next activation.
|
||||
Future<void> _refreshFolderTree() async {
|
||||
final tree = _folderTreeKey.currentState;
|
||||
if (tree == null) return;
|
||||
final generation = libraryLoadGeneration;
|
||||
final libraryGlobalKey = widget.library.globalKey;
|
||||
final epoch = snapshotLibraryContentEpoch();
|
||||
final refreshed = await tree.refresh();
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
if (refreshed) {
|
||||
recordLibraryContentEpoch(epoch);
|
||||
} else {
|
||||
releaseLibraryContentEpoch();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMediaBrowserFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
|
||||
if (client == null) return;
|
||||
@@ -749,10 +794,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return filterParams;
|
||||
}
|
||||
|
||||
Future<void> _loadItems({bool preserveFocus = false, int? loadGeneration, String? libraryGlobalKey}) async {
|
||||
/// Fetch the first flat page. [epoch] is the snapshot of the full load that
|
||||
/// owns this fetch; self-started reloads (filter, sort, grouping, alpha
|
||||
/// prefix) snapshot at their own start.
|
||||
Future<void> _loadItems({
|
||||
bool preserveFocus = false,
|
||||
int? loadGeneration,
|
||||
String? libraryGlobalKey,
|
||||
int? epoch,
|
||||
}) async {
|
||||
final generation = loadGeneration ?? libraryLoadGeneration;
|
||||
final acceptedLibraryGlobalKey = libraryGlobalKey ?? widget.library.globalKey;
|
||||
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
|
||||
final contentEpoch = epoch ?? snapshotLibraryContentEpoch();
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
items = [];
|
||||
@@ -774,9 +828,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
|
||||
hasLoadedData = true;
|
||||
// Consume the load-start epoch snapshot (and credit the live pacer) so
|
||||
// a fresh full load isn't re-marked stale on the next activation.
|
||||
recordLibraryContentEpoch();
|
||||
// Credit the operation's own snapshot (and the live pacer) so a fresh
|
||||
// full load isn't re-marked stale on the next activation. Folder
|
||||
// grouping doesn't render this page — [_refreshFolderTree] credits
|
||||
// the tree's reload instead.
|
||||
if (_selectedGrouping == 'folders') {
|
||||
releaseLibraryContentEpoch();
|
||||
} else {
|
||||
recordLibraryContentEpoch(contentEpoch);
|
||||
}
|
||||
if (!preserveFocus) {
|
||||
tryFocus();
|
||||
}
|
||||
@@ -792,6 +852,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
|
||||
releaseLibraryContentEpoch();
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
|
||||
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
|
||||
setState(() {
|
||||
@@ -1377,15 +1438,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
|
||||
|
||||
// Defer to a post-frame so the focus node has a chance to attach if the
|
||||
// grid item is being built/rebuilt in the same frame.
|
||||
if (target.context != null) {
|
||||
target.requestFocus();
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) target.requestFocus();
|
||||
});
|
||||
}
|
||||
// Flutter retains preattachment requests until the target is reparented.
|
||||
target.requestFocus();
|
||||
}
|
||||
|
||||
/// Navigate from the alpha jump bar to the nearest visible grid item.
|
||||
@@ -1690,34 +1744,42 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// Select the derived letter rather than listening to the raw
|
||||
// index: the index changes every scrolled row, but the bar only
|
||||
// needs a rebuild when the letter itself flips.
|
||||
child: _isPhone(context)
|
||||
? ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: _isScrollActive,
|
||||
builder: (context, scrolling, _) => AlphaScrollHandle(
|
||||
// Horizontal-only SafeArea: on landscape phones the trailing
|
||||
// inset (notch/rounded corner) is not consumed by the nav rail,
|
||||
// so the handle must clear it itself. Vertical insets are
|
||||
// already covered by overlayTopPadding and the parent scaffold.
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: _isPhone(context)
|
||||
? ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: _isScrollActive,
|
||||
builder: (context, scrolling, _) => AlphaScrollHandle(
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: currentLetter,
|
||||
descending: _isTitleSortDescending,
|
||||
isScrolling: scrolling,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => AlphaJumpBar(
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: currentLetter,
|
||||
descending: _isTitleSortDescending,
|
||||
isScrolling: scrolling,
|
||||
focusNode: _alphaJumpBarFocusNode,
|
||||
onNavigateLeft: _navigateToGridNearScroll,
|
||||
onBack: _navigateToGridNearScroll,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListenableSelector<String>(
|
||||
listenable: _currentFirstVisibleIndex,
|
||||
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
|
||||
builder: (context, currentLetter, _) => AlphaJumpBar(
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: currentLetter,
|
||||
descending: _isTitleSortDescending,
|
||||
focusNode: _alphaJumpBarFocusNode,
|
||||
onNavigateLeft: _navigateToGridNearScroll,
|
||||
onBack: _navigateToGridNearScroll,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1727,6 +1789,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
Widget _buildScrollableContent() {
|
||||
final isFolders = _selectedGrouping == 'folders';
|
||||
|
||||
// Horizontal-only SafeArea at the region owner: the nav rail consumes the
|
||||
// leading inset, but the trailing one (landscape notch/cutout) would
|
||||
// otherwise sit under the rightmost grid column and the folder tree.
|
||||
Widget scrollView = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
// Track scroll activity for phone scroll handle and range-load gating
|
||||
@@ -1780,15 +1845,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
),
|
||||
);
|
||||
|
||||
scrollView = SafeArea(top: false, bottom: false, child: scrollView);
|
||||
|
||||
// Folders mode previously had its own RefreshIndicator inside FolderTreeView;
|
||||
// it now lives at this level since FolderTreeView is a sliver.
|
||||
if (isFolders) {
|
||||
scrollView = RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _folderTreeKey.currentState?.refresh();
|
||||
},
|
||||
child: scrollView,
|
||||
);
|
||||
scrollView = RefreshIndicator(onRefresh: _refreshFolderTree, child: scrollView);
|
||||
}
|
||||
|
||||
return scrollView;
|
||||
@@ -2089,15 +2151,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMeasuredFirstListItem(Widget child) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measureFirstListRowHeight());
|
||||
return KeyedSubtree(key: _firstListItemKey, child: child);
|
||||
}
|
||||
|
||||
void _measureFirstListRowHeight() {
|
||||
if (!mounted) return;
|
||||
if (SettingsService.instanceOrNull?.read(SettingsService.viewMode) != ViewMode.list) return;
|
||||
final height = (_firstListItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
|
||||
final height = (gridItemFocusNodes[0]?.context?.findRenderObject() as RenderBox?)?.size.height;
|
||||
if (height == null || height <= 0) return;
|
||||
if ((_measuredListRowHeight ?? 0) == height) return;
|
||||
_measuredListRowHeight = height;
|
||||
@@ -2145,6 +2202,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
final hasAlphaBarReservation = rightPadding > 8.0;
|
||||
return MediaCardSliverLayout(
|
||||
findChildIndexCallback: (key) => _loadedIndexOfId((key as ValueKey<String>).value),
|
||||
viewMode: viewMode,
|
||||
itemCount: itemCount,
|
||||
density: libraryDensity,
|
||||
@@ -2198,7 +2256,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
itemCount: itemCount,
|
||||
),
|
||||
);
|
||||
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
|
||||
if (index == 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measureFirstListRowHeight());
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
return realizeBudgeted(
|
||||
@@ -2240,9 +2301,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
|
||||
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
|
||||
// All other items get managed focus nodes for restoration
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'browse_grid_item');
|
||||
// Index 0 routes through firstItemFocusNode for the base class; all other
|
||||
// items get managed focus nodes for restoration.
|
||||
final focusNode = _cardFocusNode(index);
|
||||
|
||||
// Explicit row navigation. Default directional focus traversal becomes
|
||||
// unreliable while items are mounting/unmounting under fast scrolling and
|
||||
@@ -2290,18 +2351,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
);
|
||||
}
|
||||
|
||||
FocusNode _cardFocusNode(int index) =>
|
||||
focusNodeForIndex(index, firstItemFocusNode, prefix: 'browse_grid_item', itemIdentity: loadedItems[index]?.id);
|
||||
|
||||
/// Move focus to the grid item at [targetIndex] (or its skeleton's row).
|
||||
/// Used by the explicit dpad navigation handlers.
|
||||
void _focusGridItem(int targetIndex) {
|
||||
if (targetIndex < 0 || targetIndex >= totalSize) return;
|
||||
final node = targetIndex == 0 ? firstItemFocusNode : getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
|
||||
if (node.context != null) {
|
||||
node.requestFocus();
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) node.requestFocus();
|
||||
});
|
||||
}
|
||||
final node = _cardFocusNode(targetIndex);
|
||||
node.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
/// Navigation belongs to the rendered hub snapshot, never an in-flight load.
|
||||
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||
HubFocusMemory _hubFocusMemory = HubFocusMemory();
|
||||
@@ -60,6 +60,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
void didUpdateWidget(LibraryRecommendedTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
||||
_hubKeysByIdentity.clear();
|
||||
_hubFocusMemory = HubFocusMemory();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +77,9 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
/// Every item on screen, across all hubs.
|
||||
Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
|
||||
|
||||
@override
|
||||
bool get hasFocusableContent => _visibleItems.isNotEmpty;
|
||||
|
||||
// Deletion mirrors these via DeletionMirrorsWatchState: each visible item
|
||||
// plus its parents, so deleting a season/show also matches the episodes it
|
||||
// contains here.
|
||||
@@ -135,17 +139,16 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
// item in place, so it must not evict anything here.
|
||||
if (event.isDownloadOnly) return;
|
||||
|
||||
// Drop the item and any descendants (season/show deletions take their
|
||||
// episodes with them) from every hub, then resync with the server for
|
||||
// parent leaf counts and replacement on-deck items — same
|
||||
// remove-in-place-then-reload shape as the removedFromContinueWatching
|
||||
// path above.
|
||||
// Eviction is immediate for both origins. Push reconciliation belongs to
|
||||
// the coarse library event and its visibility/playback-aware pacer.
|
||||
_removeItemsFromHubs(
|
||||
hubMatches: (_) => true,
|
||||
itemMatches: (item) =>
|
||||
item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId,
|
||||
);
|
||||
unawaited(loadItems());
|
||||
if (event.origin == DeletionOrigin.local) {
|
||||
unawaited(loadItems());
|
||||
}
|
||||
}
|
||||
|
||||
void _removeItemsFromHubs({
|
||||
@@ -161,6 +164,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
items[i] = hub.copyWith(items: newItems, size: newItems.length);
|
||||
}
|
||||
}
|
||||
items.removeWhere((hub) => hub.items.isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,9 +187,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
Future<void> loadItems() => runLoadTransaction(_loadRecommendedHubs);
|
||||
|
||||
Future<List<MediaHub>> _loadRecommendedHubs() async {
|
||||
// Clear hub keys before loading new hubs to prevent stale references
|
||||
_hubKeys.clear();
|
||||
|
||||
// Backend-aware fetch: Plex hits /hubs/sections, Jellyfin synthesises
|
||||
// Continue Watching + Next Up + Recently Added.
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
@@ -199,6 +200,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
libraryKind: widget.library.kind,
|
||||
),
|
||||
);
|
||||
hubs.removeWhere((hub) => hub.items.isEmpty);
|
||||
|
||||
// Move Continue Watching hub to the front if present
|
||||
final cwIndex = hubs.indexWhere(_isContinueWatchingHub);
|
||||
@@ -210,22 +212,30 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
return hubs;
|
||||
}
|
||||
|
||||
/// Ensure we have enough GlobalKeys for all hubs
|
||||
void _ensureHubKeys(int count) {
|
||||
while (_hubKeys.length < count) {
|
||||
_hubKeys.add(GlobalKey<HubSectionState>());
|
||||
List<GlobalKey<HubSectionState>> _keysForHubs(List<MediaHub> hubs) {
|
||||
final occurrences = <String, int>{};
|
||||
final liveIdentities = <String>{};
|
||||
final keys = <GlobalKey<HubSectionState>>[];
|
||||
for (final hub in hubs) {
|
||||
var identity = '${hub.serverId ?? ''}:${hub.identifier ?? hub.id}';
|
||||
final occurrence = occurrences.update(identity, (n) => n + 1, ifAbsent: () => 0);
|
||||
if (occurrence > 0) identity = '$identity#$occurrence';
|
||||
liveIdentities.add(identity);
|
||||
keys.add(_hubKeysByIdentity.putIfAbsent(identity, GlobalKey<HubSectionState>.new));
|
||||
}
|
||||
_hubKeysByIdentity.removeWhere((identity, _) => !liveIdentities.contains(identity));
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// Handle vertical navigation between hubs
|
||||
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
|
||||
bool _handleVerticalNavigation(List<GlobalKey<HubSectionState>> keys, int hubIndex, bool isUp) {
|
||||
return navigateVerticalHubRows(
|
||||
hubCount: items.length,
|
||||
hubCount: keys.length,
|
||||
hubIndex: hubIndex,
|
||||
isUp: isUp,
|
||||
propagateTopBoundary: true,
|
||||
requestFocus: (targetIndex) {
|
||||
_hubKeys[targetIndex].currentState?.requestFocusFromMemory();
|
||||
keys[targetIndex].currentState?.requestFocusFromMemory();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -250,8 +260,8 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
|
||||
_hubKeys.first.currentState?.requestFocusAt(0);
|
||||
if (items.isNotEmpty) {
|
||||
_keysForHubs(items).first.currentState?.requestFocusAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +275,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
|
||||
@override
|
||||
Widget buildContent(List<MediaHub> items) {
|
||||
_ensureHubKeys(items.length);
|
||||
final hubKeys = _keysForHubs(items);
|
||||
|
||||
if (PlatformDetector.isTV()) {
|
||||
return SettingsBuilder(
|
||||
@@ -283,13 +293,17 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
padding: const EdgeInsets.fromLTRB(0, _focusDecorationPadding, 0, 8),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: items.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final index = hubKeys.indexOf(key as GlobalKey<HubSectionState>);
|
||||
return index < 0 ? null : index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
final isContinueWatching = _isContinueWatchingHub(hub);
|
||||
final usesContinueWatchingAction = _usesContinueWatchingAction(hub);
|
||||
|
||||
return HubSection(
|
||||
key: _hubKeys[index],
|
||||
key: hubKeys[index],
|
||||
hub: hub,
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: hubIconFor(hub),
|
||||
@@ -297,7 +311,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
usesContinueWatchingAction: usesContinueWatchingAction,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null,
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(hubKeys, index, isUp),
|
||||
onBack: widget.onBack,
|
||||
onNavigateUp: index == 0 ? widget.onBack : null,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
|
||||
@@ -28,8 +28,8 @@ import 'base_library_tab.dart';
|
||||
abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibraryTab<T>>
|
||||
extends BaseLibraryTabState<T, W>
|
||||
with
|
||||
LibraryTabFocusMixin<W>,
|
||||
GridFocusNodeMixin<W>,
|
||||
LibraryTabFocusMixin<W>,
|
||||
PaginatedItemLoader<T, W>,
|
||||
StandardPaginatedView<T, W>,
|
||||
SkeletonUpgradeScheduler<W> {
|
||||
@@ -53,30 +53,53 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
|
||||
@override
|
||||
Future<void> loadItems() {
|
||||
// This pipeline bypasses [beginLibraryLoad]; capture the epoch here so
|
||||
// [markItemsLoaded]'s record marks load-start data, not a mid-fetch push.
|
||||
snapshotLibraryContentEpoch();
|
||||
cleanupGridFocusNodes(0);
|
||||
_cardMemo.clear();
|
||||
// This pipeline bypasses [beginLibraryLoad]; snapshot here so the commit
|
||||
// credits load-start data, not a mid-fetch push. A thrown fetch was the
|
||||
// current request (superseded ones report `applied: false` instead), so
|
||||
// the error hook is where a failed load settles without credit.
|
||||
final epoch = snapshotLibraryContentEpoch();
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: pageSize,
|
||||
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||
onLoaded: (_, _) => markItemsLoaded(),
|
||||
errorMessageFor: (error, stackTrace) {
|
||||
releaseLibraryContentEpoch();
|
||||
return localizedLoadErrorMessage(error, stackTrace, context: errorContext);
|
||||
},
|
||||
onLoaded: (_, _) => markItemsLoaded(epoch),
|
||||
);
|
||||
}
|
||||
|
||||
/// Server push while this grid is visible (#1646): refetch the loaded span
|
||||
/// in place so the old cards stay rendered — no spinner, no scroll reset,
|
||||
/// no focus churn. The clearing [loadItems] path is reserved for surfaces
|
||||
/// no focus churn. Item-owned nodes retain active and covered restoration
|
||||
/// focus across the merge. The clearing [loadItems] path is reserved for surfaces
|
||||
/// with nothing visible to preserve (error or empty states, where it is
|
||||
/// also the only way a first item can appear live).
|
||||
@override
|
||||
Future<void> performLiveLibraryRefresh() async {
|
||||
if (isLoading) return;
|
||||
if (!hasLoadedData || loadedItems.isEmpty || totalSize == 0) return loadItems();
|
||||
snapshotLibraryContentEpoch();
|
||||
final epoch = snapshotLibraryContentEpoch();
|
||||
final result = await repopulateLoadedRange(idOf: idOf);
|
||||
if (result == null || !mounted) return;
|
||||
recordLibraryContentEpoch();
|
||||
if (!mounted) return;
|
||||
if (result == null) {
|
||||
// Failed or superseded: nothing landed, so nothing is credited.
|
||||
releaseLibraryContentEpoch();
|
||||
return;
|
||||
}
|
||||
recordLibraryContentEpoch(epoch);
|
||||
setState(() => items = loadedItems.values.toList());
|
||||
reconcileGridFocusNodes({for (final entry in loadedItems.entries) idOf(entry.value): entry.key});
|
||||
}
|
||||
|
||||
/// Index currently holding the item with [id], or null when it is no longer
|
||||
/// loaded.
|
||||
int? _loadedIndexOfId(String id) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (idOf(entry.value) == id) return entry.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -111,6 +134,7 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
viewMode: viewMode,
|
||||
itemCount: totalSize,
|
||||
density: density,
|
||||
findChildIndexCallback: (key) => _loadedIndexOfId((key as ValueKey<String>).value),
|
||||
padding: _effectivePadding,
|
||||
fullBleedImage: useFullCardLayout,
|
||||
shape: shape,
|
||||
@@ -174,10 +198,16 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
onNavigateLeft: navigateLeft,
|
||||
onNavigateRight: navigateRight,
|
||||
onBack: widget.onBack,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
);
|
||||
}
|
||||
|
||||
FocusNode _cardFocusNode(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'paginated_grid_item');
|
||||
FocusNode _cardFocusNode(int index) => focusNodeForIndex(
|
||||
index,
|
||||
firstItemFocusNode,
|
||||
prefix: 'paginated_grid_item',
|
||||
itemIdentity: loadedItems[index] == null ? null : idOf(loadedItems[index]!),
|
||||
);
|
||||
|
||||
/// Move focus to the grid item at [targetIndex]. When the target card is
|
||||
/// not yet mounted (being built this frame, or still an unloaded skeleton),
|
||||
|
||||
@@ -44,6 +44,7 @@ import '../providers/multi_server_provider.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/libraries_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/seerr_account_provider.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../widgets/tv_virtual_keyboard.dart';
|
||||
import '../services/api_cache.dart';
|
||||
@@ -1131,6 +1132,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
// Always consume: the gates latch backgrounding on every lifecycle event.
|
||||
final resumedFromBackground = _profileSelectionResumeGate.consumePromptOn(state);
|
||||
final refreshStaleContent = _contentRefreshResumeGate.consumeRefreshOn(state);
|
||||
// Seerr authority is independent of media-server reachability and stale
|
||||
// content. A grant must recover hidden Request actions on any real resume.
|
||||
if (resumedFromBackground && mounted) {
|
||||
unawaited(context.read<SeerrAccountProvider?>()?.refreshUser());
|
||||
}
|
||||
if (shouldShowProfileSelectionOnResume(
|
||||
resumedFromBackground: resumedFromBackground,
|
||||
isOffline: _isOffline,
|
||||
|
||||
@@ -2,6 +2,12 @@ part of '../media_detail_screen.dart';
|
||||
|
||||
extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
Widget _buildActionButtons(MediaItem metadata) {
|
||||
// Tie asynchronous playback prompts to the actionable subtree, not the
|
||||
// route's State: a deleted first-route detail intentionally stays mounted.
|
||||
return Builder(builder: (context) => _buildAvailableActionButtons(context, metadata));
|
||||
}
|
||||
|
||||
Widget _buildAvailableActionButtons(BuildContext context, MediaItem metadata) {
|
||||
final isTv = PlatformDetector.isTV();
|
||||
final tvScale = TvLayoutConstants.scaleOf(context);
|
||||
final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0;
|
||||
@@ -42,6 +48,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: null;
|
||||
|
||||
Future<void> onPlayPressed() async {
|
||||
if (!_canUseDetail) return;
|
||||
// For TV shows, play the episode the hero describes (focused on TV,
|
||||
// otherwise on-deck); with neither, the first episode of the first season.
|
||||
if (metadata.isShow) {
|
||||
@@ -53,6 +60,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
metadata: episode,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _refreshWatchState,
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
} else {
|
||||
// No on deck episode, fetch first episode of first season
|
||||
@@ -66,6 +74,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
metadata: _episodes.first,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _refreshWatchState,
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
} else {
|
||||
await _playFirstEpisode();
|
||||
@@ -78,11 +87,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
metadata: metadata,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _refreshWatchState,
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onPlayVersionPressed() async {
|
||||
if (!_canUseDetail) return;
|
||||
final didNavigate = await promptAndPlayVersion(context, metadata);
|
||||
// Same post-playback refresh plain Play gets from
|
||||
// navigateToVideoPlayerWithRefresh; the split segment is online-only.
|
||||
@@ -142,7 +153,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// ⋮ menu item so the trailer stays reachable when the row hides its button.
|
||||
final VoidCallback? onPlayTrailer = primaryTrailer == null
|
||||
? null
|
||||
: () => unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer));
|
||||
: () =>
|
||||
unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer, isLaunchCurrent: () => _canUseDetail));
|
||||
|
||||
final gap = isTv ? 8.0 * tvScale : 12.0;
|
||||
|
||||
@@ -271,7 +283,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final downloadAction = !widget.isOffline && !PlatformDetector.isAppleTV()
|
||||
? FocusableAction(
|
||||
debugLabel: 'detail_download',
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
builder: (context, state) =>
|
||||
_buildDownloadButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
|
||||
)
|
||||
@@ -433,6 +445,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
|
||||
Future<void> _handleWatchlistTogglePressed(MediaItem metadata) async {
|
||||
if (!_canUseDetail) return;
|
||||
final candidates = _watchlistCandidates;
|
||||
if (candidates.isEmpty || _watchlistMutationInFlight) return;
|
||||
// Parity with the disabled pointer button: while every membership is
|
||||
@@ -460,11 +473,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size,
|
||||
focusFirstItem: true,
|
||||
);
|
||||
if (choice == null || !mounted) return;
|
||||
if (choice == null || !_canUseDetail) return;
|
||||
await _toggleWatchlistOn(metadata, choice);
|
||||
}
|
||||
|
||||
Future<void> _toggleWatchlistOn(MediaItem metadata, WatchlistCandidate candidate) async {
|
||||
if (!_canUseDetail) return;
|
||||
final current = candidate.source.isOnWatchlist(metadata.kind, candidate.ids);
|
||||
if (current == null || _watchlistMutationInFlight) return;
|
||||
_watchlistMutationInFlight = true;
|
||||
@@ -480,10 +494,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
|
||||
Future<void> _handleWatchedTogglePressed(MediaItem metadata) async {
|
||||
if (!_canUseDetail) return;
|
||||
try {
|
||||
final isWatched = metadata.isWatched;
|
||||
final outcome = await WatchActions.setWatched(context, metadata, watched: !isWatched, offline: widget.isOffline);
|
||||
if (!mounted) return;
|
||||
if (!mounted || !_canUseDetail) return;
|
||||
switch (outcome) {
|
||||
case WatchMarkOutcome.queuedOffline:
|
||||
showAppSnackBar(context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline);
|
||||
@@ -532,6 +547,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
child: Builder(
|
||||
builder: (buttonContext) => IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
if (!_canUseDetail) return;
|
||||
final renderBox = buttonContext.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
|
||||
@@ -550,15 +566,17 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
/// restrictions, re-resolve the version, then replace the existing download
|
||||
/// with a fresh queue entry.
|
||||
Future<void> _retryDownload(DownloadProvider downloadProvider, MediaItem metadata, String globalKey) async {
|
||||
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted || !_canUseDetail) return;
|
||||
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !mounted) return;
|
||||
if (versionConfig == null || !_canUseDetail) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (!_canUseDetail) return;
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
@@ -567,7 +585,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDownloadButtonPressed(MediaItem metadata) async {
|
||||
Future<void> _handleDownloadButtonPressed(BuildContext context, MediaItem metadata) async {
|
||||
if (!_canUseDetail || !context.mounted) return;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final globalKey = metadata.globalKey;
|
||||
final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata);
|
||||
@@ -583,7 +602,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
await downloadProvider.resumeDownload(globalKey, client);
|
||||
if (mounted) showAppSnackBar(context, t.downloads.downloadResumed);
|
||||
if (_canUseDetail && context.mounted) showAppSnackBar(context, t.downloads.downloadResumed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,11 +621,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
cancelText: t.common.delete,
|
||||
confirmText: t.common.retry,
|
||||
);
|
||||
if (!_canUseDetail || !context.mounted) return;
|
||||
|
||||
if (!retry && mounted) {
|
||||
if (!retry) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
} else if (retry && mounted) {
|
||||
if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
} else {
|
||||
await _retryDownload(downloadProvider, metadata, globalKey);
|
||||
}
|
||||
return;
|
||||
@@ -617,16 +637,16 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
await _showSyncRuleActions(context, downloadProvider, metadata, ruleKey: ruleKey, downloadGlobalKey: globalKey);
|
||||
return;
|
||||
}
|
||||
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return;
|
||||
if (!await confirmBackgroundDownloadRestrictions(context) || !_canUseDetail || !context.mounted) return;
|
||||
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !mounted) return;
|
||||
if (versionConfig == null || !_canUseDetail || !context.mounted) return;
|
||||
|
||||
final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig);
|
||||
if (mounted) {
|
||||
if (_canUseDetail && context.mounted) {
|
||||
final message = count > 0 ? t.downloads.episodesQueued(count: count) : t.downloads.allEpisodesAlreadyDownloaded;
|
||||
showAppSnackBar(context, message);
|
||||
}
|
||||
@@ -646,9 +666,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
if (confirmed && mounted) {
|
||||
if (confirmed && _canUseDetail && context.mounted) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +761,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// State 4: Paused (can resume)
|
||||
if (progress?.status == DownloadStatus.paused) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1),
|
||||
tooltip: t.downloads.resumeDownload,
|
||||
iconSize: iconSize,
|
||||
@@ -752,7 +772,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// State 5: Failed (can retry)
|
||||
if (progress?.status == DownloadStatus.failed) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: const AppIcon(Symbols.error_outline_rounded, fill: 1),
|
||||
tooltip: t.downloads.retryDownload,
|
||||
iconSize: iconSize,
|
||||
@@ -763,7 +783,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// State 6: Cancelled (can delete or retry)
|
||||
if (progress?.status == DownloadStatus.cancelled) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
tooltip: t.downloads.cancelledDownload,
|
||||
iconSize: iconSize,
|
||||
@@ -788,7 +808,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: t.downloads.keepSynced;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
tooltip: tooltip,
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
iconSize: iconSize,
|
||||
@@ -801,7 +821,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: t.downloads.partialDownloadClickToComplete;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
tooltip: tooltip,
|
||||
icon: const AppIcon(Symbols.downloading_rounded, fill: 1),
|
||||
iconSize: iconSize,
|
||||
@@ -818,7 +838,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final syncRule = downloadProvider.getSyncRule(ruleKey);
|
||||
final isEnabled = syncRule?.enabled ?? true;
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'),
|
||||
iconSize: iconSize,
|
||||
@@ -830,7 +850,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final canDownloadMore = metadata.isShow || metadata.isSeason;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload,
|
||||
iconSize: iconSize,
|
||||
@@ -840,7 +860,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
|
||||
// State 9: Not downloaded (default - can download)
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
tooltip: t.downloads.downloadNow,
|
||||
iconSize: iconSize,
|
||||
|
||||
@@ -10,6 +10,7 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
|
||||
/// episode for a season, the item itself otherwise. Null while a show has
|
||||
/// not resolved any episode yet.
|
||||
MediaItem? _playbackTargetItem(MediaItem metadata) {
|
||||
if (!_canUseDetail) return null;
|
||||
final MediaItem? target;
|
||||
if (metadata.isShow) {
|
||||
target = _showPlayEpisode();
|
||||
@@ -18,53 +19,123 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
|
||||
} else {
|
||||
target = metadata;
|
||||
}
|
||||
if (target == null) return null;
|
||||
return _probedPlaybackItems[target.id] ?? target;
|
||||
return target;
|
||||
}
|
||||
|
||||
/// Plex listings describe a file only by its container summary, so an
|
||||
/// episode reached through the rail has no stream rows until its own item
|
||||
/// is fetched. One request per item, debounced past D-pad scrubbing, cached
|
||||
/// for the screen's lifetime; the result reaches the hero through the
|
||||
/// focused-episode notifier so the rail does not rebuild.
|
||||
void _scheduleTargetProbe(BuildContext context, MediaItem target) {
|
||||
if (widget.isOffline || _probedPlaybackItems.containsKey(target.id)) return;
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
Future<void> _listenForPlaybackVersionChanges() async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!_canUseDetail) return;
|
||||
_playbackVersionPreferences = settings.listenableOf(SettingsService.mediaVersionPreferences);
|
||||
_playbackVersionPreferences!.addListener(_onPlaybackVersionChanged);
|
||||
}
|
||||
|
||||
void _onPlaybackVersionChanged() {
|
||||
if (!_canUseDetail) return;
|
||||
_invalidatePlaybackProbes(refreshItems: false);
|
||||
}
|
||||
|
||||
void _invalidatePlaybackProbes({required bool refreshItems}) {
|
||||
_playbackProbeGeneration++;
|
||||
_playbackProbeTimer?.cancel();
|
||||
_playbackProbeRequests.clear();
|
||||
_playbackSources.clear();
|
||||
if (refreshItems) _probedPlaybackItems.clear();
|
||||
_playbackStatusRevision.value++;
|
||||
}
|
||||
|
||||
/// Fetch metadata once, then resolve the saved selection independently.
|
||||
/// Rendering never starts PlaybackInfo or a transcode. Offline only reads
|
||||
/// the completed download's identity, never a server or track probe.
|
||||
void _scheduleTargetProbe(BuildContext context, MediaItem target) {
|
||||
if (!_canUseDetail) return;
|
||||
final key = target.globalKey;
|
||||
if (_playbackSources.containsKey(key) || _playbackProbeRequests.containsKey(key)) return;
|
||||
final client = widget.isOffline ? null : _getMediaClientForMetadata(context);
|
||||
if (!widget.isOffline && client == null) return;
|
||||
final downloads = widget.isOffline ? context.read<DownloadProvider>() : null;
|
||||
_playbackProbeTimer?.cancel();
|
||||
final generation = _playbackProbeGeneration;
|
||||
_playbackProbeTimer = Timer(const Duration(milliseconds: 350), () {
|
||||
if (!mounted || _probedPlaybackItems.containsKey(target.id)) return;
|
||||
unawaited(_probeTarget(client, target));
|
||||
if (!mounted || generation != _playbackProbeGeneration || _playbackProbeRequests.containsKey(key)) return;
|
||||
unawaited(_probeTarget(client, downloads, target, generation));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _probeTarget(MediaServerClient client, MediaItem target) async {
|
||||
final MediaItem? fetched;
|
||||
Future<void> _probeTarget(
|
||||
MediaServerClient? client,
|
||||
DownloadProvider? downloads,
|
||||
MediaItem target,
|
||||
int generation,
|
||||
) async {
|
||||
final key = target.globalKey;
|
||||
final request = Object();
|
||||
_playbackProbeRequests[key] = request;
|
||||
bool current() => _canUseDetail && generation == _playbackProbeGeneration && _playbackProbeRequests[key] == request;
|
||||
try {
|
||||
fetched = await client.fetchItem(target.id);
|
||||
MediaItem item;
|
||||
MediaSourceInfo? source;
|
||||
int? mediaIndex;
|
||||
if (downloads != null) {
|
||||
final downloaded = await downloads.getCompletedDownload(key);
|
||||
if (!current()) return;
|
||||
item = target;
|
||||
if (downloaded != null) {
|
||||
final versions = item.mediaVersions;
|
||||
final byId = downloaded.mediaSourceId == null
|
||||
? -1
|
||||
: versions?.indexWhere((version) => version.id == downloaded.mediaSourceId) ?? -1;
|
||||
mediaIndex = byId >= 0 ? byId : downloaded.mediaIndex;
|
||||
if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) mediaIndex = null;
|
||||
}
|
||||
} else {
|
||||
final fetched = _probedPlaybackItems[key] ?? await client!.fetchItem(target.id);
|
||||
if (!current() || fetched == null) return;
|
||||
item = fetched.copyWith(
|
||||
serverId: target.serverId ?? fetched.serverId,
|
||||
serverName: target.serverName ?? fetched.serverName,
|
||||
);
|
||||
_probedPlaybackItems[key] = item;
|
||||
final selection = await resolveSavedMediaVersionFor(item);
|
||||
if (!current()) return;
|
||||
source = await client!.fetchCachedMediaSourceInfo(
|
||||
item.id,
|
||||
mediaIndex: selection?.index ?? 0,
|
||||
mediaSourceId: selection?.sourceId,
|
||||
preferredVersionSignature: selection?.signature,
|
||||
);
|
||||
if (!current() || source == null) return;
|
||||
mediaIndex = source.mediaIndex ?? selection?.index ?? 0;
|
||||
}
|
||||
if (!current()) return;
|
||||
_playbackSources[key] = (item: item, source: source, mediaIndex: mediaIndex);
|
||||
// Only the status repaints: do not rebuild the rail or replace its
|
||||
// focused item with a cached probe snapshot.
|
||||
_playbackStatusRevision.value++;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.d('Playback track probe failed for ${target.id}', error: e, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
if (!mounted || fetched == null) return;
|
||||
final probed = fetched.copyWith(
|
||||
serverId: target.serverId ?? fetched.serverId,
|
||||
serverName: target.serverName ?? fetched.serverName,
|
||||
);
|
||||
// A summary-only answer is not worth caching: the next focus retries.
|
||||
if (previewPlaybackTracks(probed) == null) return;
|
||||
_probedPlaybackItems[target.id] = probed;
|
||||
if (_tvDetailFocusedEpisode.value?.id == target.id) {
|
||||
_tvDetailFocusedEpisode.value = probed;
|
||||
} else {
|
||||
setStateIfMounted(() {});
|
||||
} finally {
|
||||
if (_playbackProbeRequests[key] == request) _playbackProbeRequests.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Null when there is nothing to say: no version at all, or a container
|
||||
/// summary without a resolution.
|
||||
Widget? _buildPlaybackTracksStatus(
|
||||
/// The listener stays mounted while a source is resolving, without adding
|
||||
/// a semantic or focus target when there is nothing to predict.
|
||||
Widget _buildPlaybackTracksStatus(
|
||||
BuildContext context,
|
||||
MediaItem metadata, {
|
||||
required bool isTv,
|
||||
required double tvScale,
|
||||
required double maxWidth,
|
||||
}) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _playbackStatusRevision,
|
||||
builder: (context, _, _) =>
|
||||
_playbackTracksStatus(context, metadata, isTv: isTv, tvScale: tvScale, maxWidth: maxWidth) ??
|
||||
const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _playbackTracksStatus(
|
||||
BuildContext context,
|
||||
MediaItem metadata, {
|
||||
required bool isTv,
|
||||
@@ -73,16 +144,30 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
|
||||
}) {
|
||||
final target = _playbackTargetItem(metadata);
|
||||
if (target == null) return null;
|
||||
if (_playbackStatusTarget != target.globalKey) {
|
||||
_playbackStatusTarget = target.globalKey;
|
||||
final cached = _playbackSources[target.globalKey]?.source;
|
||||
if (!widget.isOffline && cached != null && !mediaSourceHasTrackRows(cached)) {
|
||||
_playbackSources.remove(target.globalKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Profile-scoped in the app; nullable so a bare detail screen (widget
|
||||
// tests) still previews with the ladder's non-profile tiers.
|
||||
final preview = previewPlaybackTracks(
|
||||
target,
|
||||
profile: context.read<AccountPreferencesController?>()?.activePreferences,
|
||||
);
|
||||
final videoLabels = buildMediaVideoLabels(target);
|
||||
final probed = _playbackSources[target.globalKey];
|
||||
final source = probed?.source;
|
||||
final preview = source == null
|
||||
? null
|
||||
: previewPlaybackTracks(
|
||||
probed!.item,
|
||||
source,
|
||||
profile: context.watch<AccountPreferencesController?>()?.activePreferences,
|
||||
);
|
||||
final videoLabels = probed?.mediaIndex == null
|
||||
? const <String>[]
|
||||
: buildMediaVideoLabels(probed!.item, versionIndex: probed.mediaIndex!, partIndex: source?.partIndex);
|
||||
if (probed == null) _scheduleTargetProbe(context, target);
|
||||
if (preview == null && videoLabels.isEmpty) return null;
|
||||
if (preview == null) _scheduleTargetProbe(context, target);
|
||||
|
||||
final audioLabel = preview?.audio?.label;
|
||||
final subtitleLabel = preview?.subtitle?.label;
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../focus/input_mode_tracker.dart';
|
||||
import '../widgets/cast_member_strip.dart';
|
||||
import '../widgets/focus_builders.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/library_change_event.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/plex_season_display.dart';
|
||||
@@ -34,6 +35,7 @@ import '../media/media_item.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../media/media_role.dart';
|
||||
import '../media/paged_media_list_state.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
@@ -86,6 +88,7 @@ import '../mixins/mounted_set_state_mixin.dart';
|
||||
import '../mixins/server_bound_media_mixin.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/library_content_notifier.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/tone_mapped_logo_image.dart';
|
||||
import '../widgets/episode_card.dart';
|
||||
@@ -315,13 +318,7 @@ PageRoute<bool> mediaDetailRoute({
|
||||
}
|
||||
|
||||
class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
with
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
DeletionMirrorsWatchState,
|
||||
MountedSetStateMixin,
|
||||
ServerBoundMediaMixin,
|
||||
RouteAware {
|
||||
with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware {
|
||||
/// Public input alias — used as the live source of truth until the detail
|
||||
/// fetch returns. Holds backend-neutral [MediaItem] data.
|
||||
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
|
||||
@@ -336,6 +333,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
MediaItem? _fullMetadata;
|
||||
MediaItem? _onDeckEpisode;
|
||||
bool _isLoadingMetadata = true;
|
||||
bool _isDeleted = false;
|
||||
StreamSubscription<LibraryChangeEvent>? _libraryContentSubscription;
|
||||
|
||||
bool get _canUseDetail => mounted && !_isDeleted;
|
||||
List<MediaItem>? _extras;
|
||||
List<MediaHub> _relatedHubs = [];
|
||||
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
|
||||
@@ -362,10 +363,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final ValueNotifier<MediaItem?> _tvDetailFocusedEpisode = ValueNotifier(null);
|
||||
bool _tvDetailActionRowHasFocus = false;
|
||||
|
||||
// Full items fetched for the action row's track status when a listing gave
|
||||
// only the container summary; keyed by item id so the line upgrades after
|
||||
// the fetch.
|
||||
// Full item snapshots are reusable across preference changes; resolved
|
||||
// sources are not. A generation retires probes after refresh or selection.
|
||||
final Map<String, MediaItem> _probedPlaybackItems = {};
|
||||
final Map<String, ({MediaItem item, MediaSourceInfo? source, int? mediaIndex})> _playbackSources = {};
|
||||
final Map<String, Object> _playbackProbeRequests = {};
|
||||
int _playbackProbeGeneration = 0;
|
||||
final ValueNotifier<int> _playbackStatusRevision = ValueNotifier(0);
|
||||
String? _playbackStatusTarget;
|
||||
Listenable? _playbackVersionPreferences;
|
||||
Timer? _playbackProbeTimer;
|
||||
|
||||
// Watchlist action (external catalog sources: Trakt, MAL). External ids
|
||||
@@ -456,9 +462,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
@override
|
||||
bool get isServerBoundOffline => widget.isOffline;
|
||||
|
||||
// WatchStateAware: watch the show/movie and all season/episode ratingKeys.
|
||||
// DeletionMirrorsWatchState reuses these three getters for deletion events —
|
||||
// the same items are on screen either way.
|
||||
// Watch state follows displayed items; deletion also follows the detail's
|
||||
// known ancestors, even before any children have loaded.
|
||||
@override
|
||||
Set<String>? get watchedIds {
|
||||
final keys = <String>{_metadata.id};
|
||||
@@ -489,8 +494,39 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return keys;
|
||||
}
|
||||
|
||||
Set<String> get _terminalDeletionIds => {
|
||||
widget.metadata.id,
|
||||
...widget.metadata.parentChain.where((id) => id.isNotEmpty),
|
||||
_metadata.id,
|
||||
..._metadata.parentChain.where((id) => id.isNotEmpty),
|
||||
};
|
||||
|
||||
@override
|
||||
String? get deletionServerId => serverBoundServerId;
|
||||
|
||||
@override
|
||||
Set<String> get deletionIds => {...?watchedIds, ..._terminalDeletionIds};
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys {
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
return {
|
||||
...?watchedGlobalKeys,
|
||||
for (final id in _terminalDeletionIds) toServerBoundGlobalKey(id, serverId: ServerId(serverId)),
|
||||
};
|
||||
}
|
||||
|
||||
void _onLibraryContentChanged(LibraryChangeEvent event) {
|
||||
if (!_canUseDetail || widget.isOffline || event.serverId != serverBoundServerId) return;
|
||||
// Exact ids are authoritative even when the producer omits its per-item
|
||||
// fanout. Advisory flags alone do not prove this detail was removed.
|
||||
if (_terminalDeletionIds.any(event.removedItemIds.contains)) _markDetailDeleted();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (!_canUseDetail) return;
|
||||
_watchStateChanged = true;
|
||||
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) return;
|
||||
|
||||
@@ -582,6 +618,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _refreshItemInPlace(MediaItem source) async {
|
||||
if (!_canUseDetail) return;
|
||||
final serverId = source.serverId;
|
||||
if (serverId == null) return;
|
||||
final client = context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
@@ -589,7 +626,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
try {
|
||||
final refreshed = await client.fetchItem(source.id);
|
||||
if (refreshed == null || !mounted) return;
|
||||
if (refreshed == null || !_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_patchItemEverywhere(source.globalKey, refreshed);
|
||||
});
|
||||
@@ -600,10 +637,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
void onDeletionEvent(DeletionEvent event) {
|
||||
if (!_canUseDetail) return;
|
||||
// Download-only deletions should only remove items when viewing offline content
|
||||
if (event.isDownloadOnly && !widget.isOffline) return;
|
||||
if (!event.isDownloadOnly && widget.isOffline) return;
|
||||
|
||||
if (_terminalDeletionIds.contains(event.itemId)) {
|
||||
_markDetailDeleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the episode from any visible/cached list. This fires whether we're
|
||||
// showing a flattened episode list or a season-tabs view of a show.
|
||||
final epIndex = _episodes.indexWhere((e) => e.id == event.itemId);
|
||||
@@ -621,7 +664,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
if (epIndex != -1 && _showEpisodesDirectly) {
|
||||
if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
_markDetailDeleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -635,7 +678,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// If the show has no more seasons, navigate back up to the library
|
||||
if (_seasons.isEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
_markDetailDeleted();
|
||||
return;
|
||||
}
|
||||
_refreshWatchState();
|
||||
@@ -658,7 +701,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// Otherwise we have no more seasons, so navigate up
|
||||
if (_seasons.isEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
_markDetailDeleted();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -673,8 +716,44 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _markDetailDeleted() {
|
||||
if (!_canUseDetail) return;
|
||||
setState(() {
|
||||
_isDeleted = true;
|
||||
// Retire in-flight pages and probes before navigation. A first route
|
||||
// remains mounted, so mounted checks alone cannot fence late results.
|
||||
_episodesLoadGeneration++;
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
});
|
||||
if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete();
|
||||
_closeDeletedDetail();
|
||||
}
|
||||
|
||||
/// The detail's content is gone; leave via the detail's OWN route. A blind
|
||||
/// `Navigator.pop` takes whatever is topmost on the profile navigator — the
|
||||
/// download progress dialog, a player, a hostless sheet fallback, another
|
||||
/// detail pushed on top — and leaves this empty screen behind.
|
||||
///
|
||||
/// The terminal transition handles duplicate delivery; `isActive` also
|
||||
/// avoids removing a route already leaving for another reason. `isFirst`
|
||||
/// retains the unavailable state instead of stranding a blank navigator.
|
||||
void _closeDeletedDetail() {
|
||||
final route = _route ?? ModalRoute.of(context);
|
||||
if (route == null || route.isFirst || !route.isActive) return;
|
||||
final navigator = Navigator.of(context);
|
||||
if (route.isCurrent) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
// Completes the push future with null, same as the pop above.
|
||||
navigator.removeRoute(route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight refresh for watch state changes - no loader, preserves scroll
|
||||
Future<void> _refreshWatchState() async {
|
||||
if (!_canUseDetail) return;
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
if (widget.isOffline) return;
|
||||
// Backend-neutral. Plex bundles metadata + on-deck in one round-trip
|
||||
// (`?includeOnDeck=1`); Jellyfin's [fetchItemWithOnDeck] returns
|
||||
// onDeckEpisode=null and on-deck repopulates from cached lists on
|
||||
@@ -687,6 +766,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
try {
|
||||
final result = await mediaClient.fetchItemWithOnDeck(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
final metadata = result.item;
|
||||
final onDeckEpisode = result.onDeckEpisode;
|
||||
if (metadata != null) {
|
||||
@@ -701,6 +781,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
refreshedMetadata,
|
||||
);
|
||||
setStateIfMounted(() {
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
_fullMetadata = refreshedMetadata;
|
||||
_onDeckEpisode = refreshedOnDeck;
|
||||
});
|
||||
@@ -728,7 +809,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_overviewFocusNode = FocusNode(debugLabel: 'overview');
|
||||
_tvDetailInfoFocusNode = FocusNode(debugLabel: 'tv_detail_info');
|
||||
_infoRowsFocusNode = FocusNode(debugLabel: 'info_rows');
|
||||
_libraryContentSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged);
|
||||
_loadFullMetadata();
|
||||
unawaited(_listenForPlaybackVersionChanges());
|
||||
_initWatchlistState();
|
||||
}
|
||||
|
||||
@@ -757,7 +840,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_metadata,
|
||||
client: _getMediaClientForMetadata(context),
|
||||
);
|
||||
if (!mounted || candidates.isEmpty) return;
|
||||
if (!_canUseDetail || candidates.isEmpty) return;
|
||||
setState(() => _watchlistCandidates = candidates);
|
||||
} catch (e) {
|
||||
appLogger.d('Watchlist external-id resolution failed', error: e);
|
||||
@@ -791,6 +874,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
setStateIfMounted(() {});
|
||||
_suppressBackAfterPop = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -857,14 +942,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
_tvDetailRevealScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
setState(() {
|
||||
_tvDetailStableRailHeight = _tvDetailPendingRailHeight ?? railHeight;
|
||||
_tvDetailRevealScheduled = false;
|
||||
_tvDetailRevealed = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
if (focusPrimaryAction) {
|
||||
_playButtonFocusNode.requestFocus();
|
||||
} else {
|
||||
@@ -899,6 +984,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_libraryContentSubscription?.cancel();
|
||||
for (final source in _watchlistListenedSources) {
|
||||
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
|
||||
}
|
||||
@@ -907,6 +993,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_scrollOffset.dispose();
|
||||
_tvDetailFocusedEpisode.dispose();
|
||||
_playbackProbeTimer?.cancel();
|
||||
_playbackVersionPreferences?.removeListener(_onPlaybackVersionChanged);
|
||||
_playbackStatusRevision.dispose();
|
||||
_extrasScrollController.dispose();
|
||||
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
|
||||
_extrasFocusNode.dispose();
|
||||
@@ -1390,7 +1478,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
],
|
||||
);
|
||||
|
||||
if (selected == null || !context.mounted) return;
|
||||
if (selected == null || !_canUseDetail || !context.mounted) return;
|
||||
|
||||
switch (selected) {
|
||||
case _SyncRuleAction.edit:
|
||||
@@ -1433,6 +1521,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
if (!_canUseDetail) return;
|
||||
_invalidatePlaybackProbes(refreshItems: true);
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
_hasLoadedExtras = false;
|
||||
@@ -1445,7 +1535,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final cachedMetadata = serverId == null
|
||||
? null
|
||||
: await context.read<DownloadProvider>().lookupOfflineMetadata(serverId, _metadata.id);
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
setState(() {
|
||||
_fullMetadata = cachedMetadata ?? _metadata;
|
||||
_isLoadingMetadata = false;
|
||||
@@ -1531,12 +1621,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final result = await client.fetchItemWithOnDeck(
|
||||
_metadata.id,
|
||||
onItemReady: (item) {
|
||||
if (mounted) publish(item);
|
||||
if (_canUseDetail) publish(item);
|
||||
},
|
||||
);
|
||||
final metadata = result.item;
|
||||
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true);
|
||||
|
||||
if (base.isShow) {
|
||||
@@ -1553,7 +1643,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
unawaited(_loadRelatedHubs());
|
||||
} catch (e) {
|
||||
// Fallback to passed metadata on error
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
setState(() {
|
||||
_fullMetadata = _metadata;
|
||||
_isLoadingMetadata = false;
|
||||
@@ -1572,6 +1662,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadSeasons() async {
|
||||
if (!_canUseDetail) return;
|
||||
_seasonsCompleter = Completer<void>();
|
||||
setStateIfMounted(() {
|
||||
_isLoadingSeasons = true;
|
||||
@@ -1605,6 +1696,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
: Future.value(<String, dynamic>{});
|
||||
|
||||
final results = await Future.wait([seasonsFuture, prefsFuture]);
|
||||
if (!_canUseDetail) return;
|
||||
final seasons = results.first as List<MediaItem>;
|
||||
final prefs = results[1] as Map<String, dynamic>;
|
||||
|
||||
@@ -1661,6 +1753,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Seasons load failed', error: e, stackTrace: st);
|
||||
if (!_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_isLoadingSeasons = false;
|
||||
_seasonsLoadFailed = true;
|
||||
@@ -1675,6 +1768,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
/// Load seasons from downloaded episodes (offline mode)
|
||||
void _loadSeasonsFromDownloads() {
|
||||
if (!_canUseDetail) return;
|
||||
_seasonsCompleter = Completer<void>();
|
||||
setState(() {
|
||||
_isLoadingSeasons = true;
|
||||
@@ -1759,6 +1853,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
/// Load episodes from downloaded content for a season
|
||||
void _loadEpisodesFromDownloads() {
|
||||
if (!_canUseDetail) return;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final seasonEpisodes = _downloadedEpisodesForSeason(downloadProvider, _metadata.parentId ?? '', _metadata.index);
|
||||
|
||||
@@ -1796,6 +1891,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward
|
||||
/// the end, so a 1000+ episode season never blocks on one giant request.
|
||||
Future<void> _fetchSeasonEpisodes(int seasonIndex) async {
|
||||
if (!_canUseDetail) return;
|
||||
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
|
||||
final season = _seasons[seasonIndex];
|
||||
final seasonId = season.id;
|
||||
@@ -1896,6 +1992,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _prefetchSeasonEpisodeFirstPage(int seasonIndex) async {
|
||||
if (!_canUseDetail) return;
|
||||
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
|
||||
final season = _seasons[seasonIndex];
|
||||
final seasonId = season.id;
|
||||
@@ -1912,7 +2009,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
start: 0,
|
||||
size: _episodesPageSize,
|
||||
);
|
||||
if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) {
|
||||
if (!_canUseDetail ||
|
||||
_showEpisodesDirectly ||
|
||||
seasonIndex >= _seasons.length ||
|
||||
_seasons[seasonIndex].id != seasonId) {
|
||||
return;
|
||||
}
|
||||
setStateIfMounted(() {
|
||||
@@ -1926,7 +2026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
|
||||
} catch (e, st) {
|
||||
appLogger.d('TV adjacent season episode prefetch failed', error: e, stackTrace: st);
|
||||
if (mounted && _isSelectedSeason(seasonIndex, seasonId)) {
|
||||
if (_canUseDetail && _isSelectedSeason(seasonIndex, seasonId)) {
|
||||
setStateIfMounted(() {
|
||||
_seasonEpisodePager.failFirstPage(seasonId);
|
||||
});
|
||||
@@ -1939,6 +2039,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// Load the next page of the selected season's episodes and append it.
|
||||
/// No-op when nothing more remains, offline, or a page is already in flight.
|
||||
Future<void> _loadMoreSeasonEpisodes() async {
|
||||
if (!_canUseDetail) return;
|
||||
final seasonIndex = _selectedSeasonIndex;
|
||||
if (widget.isOffline || seasonIndex < 0 || seasonIndex >= _seasons.length) return;
|
||||
final season = _seasons[seasonIndex];
|
||||
@@ -2026,7 +2127,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
/// Load extras (trailers, featurettes, behind-the-scenes, etc.).
|
||||
Future<void> _loadExtras() async {
|
||||
if (!_canUseDetail) return;
|
||||
void markLoaded() {
|
||||
if (!_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_hasLoadedExtras = true;
|
||||
});
|
||||
@@ -2052,6 +2155,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
final extras = await client.fetchExtras(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
// Preserve serverId for each extra (needed for multi-server setups).
|
||||
final extrasWithServerId = extras
|
||||
@@ -2077,7 +2181,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// Backend-neutral — both Plex and Jellyfin implement
|
||||
/// [MediaServerClient.fetchRelatedHubs].
|
||||
Future<void> _loadRelatedHubs() async {
|
||||
if (!_canUseDetail) return;
|
||||
void markLoaded() {
|
||||
if (!_canUseDetail) return;
|
||||
setStateIfMounted(() {
|
||||
_hasLoadedRelatedHubs = true;
|
||||
});
|
||||
@@ -2102,6 +2208,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
try {
|
||||
final relatedHubs = await client.fetchRelatedHubs(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
setStateIfMounted(() {
|
||||
_relatedHubs = relatedHubs;
|
||||
@@ -2207,7 +2314,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
void _applyInitialMobileFocus(FocusNode node, GlobalKey sectionKey) {
|
||||
_initialDetailFocusApplied = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
node.requestFocus();
|
||||
_scrollSectionIntoView(sectionKey);
|
||||
});
|
||||
@@ -2231,7 +2338,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
_initialEpisodePagingInFlight = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _episodesContainInitialTarget) {
|
||||
if (!_canUseDetail || _episodesContainInitialTarget) {
|
||||
_initialEpisodePagingInFlight = false;
|
||||
return;
|
||||
}
|
||||
@@ -2518,7 +2625,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
isOwnerActive: () => mounted,
|
||||
onShortPress: () {
|
||||
if (_focusedExtraIndex < extras.length) {
|
||||
navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex]);
|
||||
navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex], isLaunchCurrent: () => _canUseDetail);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -2733,6 +2840,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
metadata: episode,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: () => unawaited(_refreshItemInPlace(episode)),
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
},
|
||||
onRefresh: widget.isOffline ? null : _refreshItemInPlace,
|
||||
@@ -2825,6 +2933,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _fetchAllEpisodes() async {
|
||||
if (!_canUseDetail) return;
|
||||
final generation = ++_episodesLoadGeneration;
|
||||
if (_seasons.isEmpty) {
|
||||
setStateIfMounted(() {
|
||||
@@ -2867,6 +2976,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
});
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
|
||||
if (!_canUseDetail || generation != _episodesLoadGeneration) return;
|
||||
setStateIfMounted(() {
|
||||
_allEpisodes = _allEpisodes.failInitialLoad();
|
||||
_hasLoadedEpisodes = true;
|
||||
@@ -2974,6 +3084,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// Load the next page of the flatten/all-episodes list (single-season show or
|
||||
/// season detail) on demand and append it.
|
||||
Future<void> _loadMoreAllEpisodes() async {
|
||||
if (!_canUseDetail) return;
|
||||
if (widget.isOffline || !_allEpisodes.hasMore || _allEpisodes.isLoadingMore) return;
|
||||
final serverId = _metadata.serverId;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
@@ -3050,8 +3161,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
/// Load the next unwatched episode for offline mode (offline OnDeck)
|
||||
Future<void> _loadOfflineOnDeckEpisode() async {
|
||||
if (!_canUseDetail) return;
|
||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
setStateIfMounted(() {
|
||||
_onDeckEpisode = nextEpisode;
|
||||
@@ -3071,7 +3184,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
void _ensureFallbackOnDeckEpisode() {
|
||||
// Reached via unawaited fetch continuations — the screen may be gone by
|
||||
// now, and _freshAll reads providers through State.context.
|
||||
if (!mounted) return;
|
||||
if (!_canUseDetail) return;
|
||||
if (_onDeckEpisode != null) return;
|
||||
final next = firstUnwatchedEpisode(_freshAll(_episodes));
|
||||
if (next == null) return;
|
||||
@@ -3081,6 +3194,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
Future<void> _playFirstEpisode() async {
|
||||
if (!_canUseDetail) return;
|
||||
// Loading seasons and resolving the first episode cost network round
|
||||
// trips; show the shared scoped loading dialog so Play gives immediate
|
||||
// feedback. The offline branch reads local state only and needs none.
|
||||
@@ -3103,7 +3217,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {});
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted || !_canUseDetail) return;
|
||||
|
||||
if (_seasons.isEmpty) {
|
||||
if (mounted) {
|
||||
@@ -3116,7 +3230,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// Get the first episode of the first season.
|
||||
MediaItem? firstEpisode;
|
||||
if (!mounted) return;
|
||||
if (widget.isOffline) {
|
||||
// In offline mode, get episodes from downloads (filtered to this season).
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
@@ -3132,6 +3245,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (!_canUseDetail) return;
|
||||
|
||||
if (firstEpisode == null) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.noEpisodesFound);
|
||||
@@ -3151,17 +3266,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
libraryId: firstEpisode.libraryId ?? _metadata.libraryId,
|
||||
libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle,
|
||||
);
|
||||
if (mounted) {
|
||||
if (mounted && _canUseDetail) {
|
||||
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: episodeWithServerId,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _refreshWatchState,
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (mounted && _canUseDetail) {
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
@@ -3173,6 +3289,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// so Plex uses its server-side `/playQueues` and Jellyfin builds a local
|
||||
/// shuffled queue from `fetchClientSideEpisodeQueue`.
|
||||
Future<void> _handleShufflePlayWithQueue(BuildContext context, MediaItem metadata) async {
|
||||
if (!_canUseDetail) return;
|
||||
if (widget.isOffline) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.mediaMenu.shuffleNotAvailableOffline);
|
||||
@@ -3189,6 +3306,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isDeleted) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: EmptyStateWidget(message: t.messages.mediaUnavailable, icon: Symbols.block_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Session-fresh hero: server snapshot resolved against the watch-state
|
||||
// store (onWatchStateChanged rebuilds on relevant events).
|
||||
final metadata = _fresh(_fullMetadata ?? _metadata);
|
||||
@@ -3561,15 +3685,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
valueListenable: _tvDetailFocusedEpisode,
|
||||
builder: (context, _, _) => Align(
|
||||
alignment: .bottomRight,
|
||||
child:
|
||||
_buildPlaybackTracksStatus(
|
||||
context,
|
||||
metadata,
|
||||
isTv: true,
|
||||
tvScale: detailScale,
|
||||
maxWidth: size.width * 0.40 - spotlightLeft * 2,
|
||||
) ??
|
||||
const SizedBox.shrink(),
|
||||
child: _buildPlaybackTracksStatus(
|
||||
context,
|
||||
metadata,
|
||||
isTv: true,
|
||||
tvScale: detailScale,
|
||||
maxWidth: size.width * 0.40 - spotlightLeft * 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -4222,6 +4344,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
metadata: item,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: () => unawaited(_refreshItemInPlace(item)),
|
||||
isLaunchCurrent: () => _canUseDetail,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -4742,7 +4865,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isFocused,
|
||||
onTap: () => navigateToVideoPlayer(context, metadata: extra),
|
||||
onTap: () =>
|
||||
navigateToVideoPlayer(context, metadata: extra, isLaunchCurrent: () => _canUseDetail),
|
||||
delegateFocusBorder: true,
|
||||
child: MediaCard(
|
||||
key: cardKey,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/account_preferences.dart';
|
||||
import '../../media/account_preferences_target.dart';
|
||||
import '../../media/account_ref.dart';
|
||||
import '../../media/media_server_user_profile.dart';
|
||||
import '../../services/account_preferences_repository.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
@@ -56,7 +57,7 @@ class AccountPreferencesDetailScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPage.slivers(
|
||||
title: Text(target.label),
|
||||
slivers: [AccountPreferencesBody(target: target)],
|
||||
slivers: [AccountPreferencesBody(key: ValueKey(target.ref), target: target)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +76,8 @@ class AccountPreferencesBody extends StatefulWidget {
|
||||
|
||||
class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
|
||||
late final AccountPreferencesRepository _repository;
|
||||
late final StreamSubscription<AccountRef> _repositoryChanges;
|
||||
int _loadGeneration = 0;
|
||||
|
||||
/// Loaded values; null while the first (or a retried) read is outstanding.
|
||||
AccountPreferences? _preferences;
|
||||
@@ -87,20 +90,62 @@ class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = context.read<AccountPreferencesRepository>();
|
||||
_repositoryChanges = _repository.changes.listen(_onRepositoryChanged);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AccountPreferencesBody oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.target.ref == widget.target.ref) return;
|
||||
_preferences = null;
|
||||
_error = null;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_loadGeneration++;
|
||||
unawaited(_repositoryChanges.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onRepositoryChanged(AccountRef ref) {
|
||||
if (!mounted || ref != widget.target.ref) return;
|
||||
final preferences = _repository.cached(ref);
|
||||
if (preferences == null) {
|
||||
setState(() {
|
||||
_preferences = null;
|
||||
_error = null;
|
||||
});
|
||||
unawaited(_load());
|
||||
} else if (!identical(preferences, _preferences) || _error != null) {
|
||||
_loadGeneration++;
|
||||
setState(() {
|
||||
_preferences = preferences;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load({bool forceRefresh = false}) async {
|
||||
final ref = widget.target.ref;
|
||||
final generation = ++_loadGeneration;
|
||||
try {
|
||||
final preferences = await _repository.load(widget.target.ref, forceRefresh: forceRefresh);
|
||||
if (!mounted) return;
|
||||
final preferences = await _repository.load(ref, forceRefresh: forceRefresh);
|
||||
if (!mounted ||
|
||||
widget.target.ref != ref ||
|
||||
generation != _loadGeneration ||
|
||||
!identical(_repository.cached(ref), preferences)) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_preferences = preferences;
|
||||
_error = null;
|
||||
});
|
||||
} on Exception catch (error, stackTrace) {
|
||||
appLogger.e('Account preferences load failed', error: error, stackTrace: stackTrace);
|
||||
if (!mounted) return;
|
||||
if (!mounted || widget.target.ref != ref || generation != _loadGeneration) return;
|
||||
setState(() => _error = error);
|
||||
}
|
||||
}
|
||||
@@ -116,8 +161,15 @@ class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
|
||||
/// Writes one key and adopts the account's state afterwards. Failures
|
||||
/// propagate so the row that started the write reverts and reports them.
|
||||
Future<void> _write(AccountPreferenceKey key, Object? value) async {
|
||||
final updated = await _repository.update(widget.target.ref, AccountPreferencesPatch.of(key, value));
|
||||
if (!mounted) return;
|
||||
final ref = widget.target.ref;
|
||||
final generation = _loadGeneration;
|
||||
final updated = await _repository.update(ref, AccountPreferencesPatch.of(key, value));
|
||||
if (!mounted ||
|
||||
widget.target.ref != ref ||
|
||||
generation != _loadGeneration ||
|
||||
!identical(_repository.cached(ref), updated)) {
|
||||
return;
|
||||
}
|
||||
setState(() => _preferences = updated);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,17 +46,10 @@ class AccountPreferencesScreen extends StatelessWidget {
|
||||
if (targets.length == 1) {
|
||||
return SettingsPage.slivers(
|
||||
title: title,
|
||||
slivers: [AccountPreferencesBody(target: targets.first)],
|
||||
slivers: [AccountPreferencesBody(key: ValueKey(targets.first.ref), target: targets.first)],
|
||||
);
|
||||
}
|
||||
|
||||
// The account the user is browsing with is the one they came here for;
|
||||
// that is what AccountPreferenceTarget.isActiveProfileAccount is for. Both
|
||||
// halves keep the caller's order.
|
||||
final ordered = [
|
||||
...targets.where((target) => target.isActiveProfileAccount),
|
||||
...targets.where((target) => !target.isActiveProfileAccount),
|
||||
];
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SettingsPage(
|
||||
@@ -71,7 +64,7 @@ class AccountPreferencesScreen extends StatelessWidget {
|
||||
),
|
||||
SettingsGroup(
|
||||
children: [
|
||||
for (final target in ordered)
|
||||
for (final target in targets)
|
||||
FocusableListTile(
|
||||
key: ValueKey(target.ref.key),
|
||||
leading: BackendBadge(backend: target.backend, size: 24),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show SelectedContent;
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -31,6 +32,70 @@ import '../../widgets/system_bottom_inset.dart';
|
||||
|
||||
const previousStartupFailureKey = Key('logs-previous-startup-failure');
|
||||
|
||||
/// `SelectionArea` claims plain arrow keys as no-op caret moves, which starves
|
||||
/// `DirectionalFocusIntent` and pins D-pad focus on the app-bar back button.
|
||||
/// Only the collapsing (unshifted) variants are disabled; Shift+Arrow selection
|
||||
/// still reaches the region's own handler via [callingAction].
|
||||
class _CollapsingSelectionMoveAction<T extends DirectionalCaretMovementIntent> extends Action<T> {
|
||||
@override
|
||||
bool isEnabled(T intent) => !intent.collapseSelection && (callingAction?.isEnabled(intent) ?? false);
|
||||
|
||||
@override
|
||||
bool consumesKey(T intent) => callingAction?.consumesKey(intent) ?? false;
|
||||
|
||||
@override
|
||||
Object? invoke(T intent) => callingAction?.invoke(intent);
|
||||
}
|
||||
|
||||
/// `SelectionArea` concatenates adjacent selectables with no separator; each
|
||||
/// log row (and the device header) is its own paragraph, so a copied
|
||||
/// drag-selection ran records together. The scrollable owns the delegate that
|
||||
/// does the concatenation (and the lazy-row/autoscroll bookkeeping), so the
|
||||
/// boundary is added per record: every selected record ends its text with
|
||||
/// `\n`, and [_RecordSelectionDelegate] drops the one dangling after the last
|
||||
/// record so a partial drag copies exactly what was highlighted.
|
||||
class _RecordBoundaryDelegate extends StaticSelectionContainerDelegate {
|
||||
@override
|
||||
SelectedContent? getSelectedContent() {
|
||||
final text = super.getSelectedContent()?.plainText;
|
||||
// A drag ending at offset 0 of a row selects nothing in it: no boundary.
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return SelectedContent(plainText: '$text\n');
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordSelectionDelegate extends StaticSelectionContainerDelegate {
|
||||
@override
|
||||
SelectedContent? getSelectedContent() {
|
||||
final text = super.getSelectedContent()?.plainText;
|
||||
if (text == null) return null;
|
||||
return SelectedContent(plainText: text.endsWith('\n') ? text.substring(0, text.length - 1) : text);
|
||||
}
|
||||
}
|
||||
|
||||
/// One selectable record: the device header or a single log entry.
|
||||
class _LogRecord extends StatefulWidget {
|
||||
const _LogRecord({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_LogRecord> createState() => _LogRecordState();
|
||||
}
|
||||
|
||||
class _LogRecordState extends State<_LogRecord> {
|
||||
final _RecordBoundaryDelegate _boundary = _RecordBoundaryDelegate();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_boundary.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SelectionContainer(delegate: _boundary, child: widget.child);
|
||||
}
|
||||
|
||||
class LogsScreen extends StatefulWidget {
|
||||
const LogsScreen({super.key, this.httpClient, this.deviceInfoPlugin});
|
||||
|
||||
@@ -48,6 +113,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
// skipTraversal: selection is pointer-driven; the region must not become a
|
||||
// D-pad/Tab stop between the action bar and the scrollable body.
|
||||
final FocusNode _selectionFocusNode = FocusNode(skipTraversal: true, debugLabel: 'logs-selection');
|
||||
final _RecordSelectionDelegate _recordSelection = _RecordSelectionDelegate();
|
||||
|
||||
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
||||
|
||||
@@ -113,6 +179,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_selectionFocusNode.dispose();
|
||||
_recordSelection.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -386,73 +453,90 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
child: IosStatusBarTapScrollToTop(
|
||||
controller: _scrollController,
|
||||
child: Scaffold(
|
||||
body: SelectionArea(
|
||||
focusNode: _selectionFocusNode,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
// Chrome is not log content; keep it out of drag-selection.
|
||||
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
|
||||
pinned: true,
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
body: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
ExtendSelectionByCharacterIntent: _CollapsingSelectionMoveAction<ExtendSelectionByCharacterIntent>(),
|
||||
ExtendSelectionVerticallyToAdjacentLineIntent:
|
||||
_CollapsingSelectionMoveAction<ExtendSelectionVerticallyToAdjacentLineIntent>(),
|
||||
},
|
||||
child: SelectionArea(
|
||||
focusNode: _selectionFocusNode,
|
||||
child: SelectionContainer(
|
||||
delegate: _recordSelection,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
// Chrome is not log content; keep it out of drag-selection.
|
||||
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
|
||||
pinned: true,
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.common.refresh,
|
||||
onPressed: _loadLogs,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
FocusableActionBar(
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.common.refresh,
|
||||
onPressed: _loadLogs,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else ...[
|
||||
if (_deviceInfo.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _LogRecord(
|
||||
child: Text.rich(
|
||||
TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12),
|
||||
// One widget per entry so only the visible slice is laid
|
||||
// out. The buffer holds up to 5 MiB of text; as a single
|
||||
// paragraph that was a multi-second frame and hundreds of
|
||||
// MB of glyph data — an OOM kill on phones and TVs.
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) => _LogRecord(
|
||||
child: Text.rich(
|
||||
TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index])),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Only the log body needs it: the empty state already fills
|
||||
// the viewport, so a trailing inset would just add slack.
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else ...[
|
||||
if (_deviceInfo.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans())),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12),
|
||||
// One widget per entry so only the visible slice is laid
|
||||
// out. The buffer holds up to 5 MiB of text; as a single
|
||||
// paragraph that was a multi-second frame and hundreds of
|
||||
// MB of glyph data — an OOM kill on phones and TVs.
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) =>
|
||||
Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index]))),
|
||||
),
|
||||
),
|
||||
// Only the log body needs it: the empty state already fills
|
||||
// the viewport, so a trailing inset would just add slack.
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,15 @@ class _LiveClockOpen {
|
||||
bool canceled = false;
|
||||
}
|
||||
|
||||
/// What the player has reported so far about one MPV source that no clock
|
||||
/// open has claimed yet. The `loadfile` reply that names the source and the
|
||||
/// source's own events travel on independent channels, so readiness or
|
||||
/// failure can land before the open learns its id.
|
||||
class _UnclaimedSourceEvents {
|
||||
Duration? readyPosition;
|
||||
bool failed = false;
|
||||
}
|
||||
|
||||
/// Mutable runtime state for one live TV playback: the current
|
||||
/// [LiveTvPlaybackSession] protocol handle, the timeline heartbeat
|
||||
/// machinery, the capture buffer used for time-shifting, and the
|
||||
@@ -72,10 +81,15 @@ class LiveTvSessionState {
|
||||
int? _latestClockGeneration;
|
||||
int? activeClockSourceId;
|
||||
double? pendingStreamEpoch;
|
||||
final List<_LiveClockOpen> _unboundClockOpens = [];
|
||||
final Map<int, _LiveClockOpen> _clockOpensBySource = {};
|
||||
final Map<int, _LiveClockOpen> _clockOpensByGeneration = {};
|
||||
|
||||
/// Recent source events no open has claimed, keyed by source id in arrival
|
||||
/// order and bounded so opens that never register (live-edge re-opens,
|
||||
/// VOD on the same player) cannot grow it.
|
||||
final Map<int, _UnclaimedSourceEvents> _unclaimedSources = {};
|
||||
static const int _maxUnclaimedSources = 8;
|
||||
|
||||
/// Fallback level for live TV stream errors (mirrors Plex web client
|
||||
/// behavior). 0 = directStream+directStreamAudio, 1 = no directStream,
|
||||
/// 2 = no DS + no DS audio.
|
||||
@@ -91,45 +105,62 @@ class LiveTvSessionState {
|
||||
/// The player route is closed instead of attempting to reuse that session.
|
||||
bool exitOnResume = false;
|
||||
|
||||
/// Register an offset-based MPV open before dispatching `loadfile`.
|
||||
///
|
||||
/// Opens and MPV START_FILE events are ordered. Canceled entries stay in the
|
||||
/// unbound queue until their START_FILE arrives so a late predecessor cannot
|
||||
/// steal the next open's source identity.
|
||||
/// Register an offset-based MPV open before dispatching `loadfile`. The
|
||||
/// returned generation is the handle the caller binds to the source id the
|
||||
/// load reports ([bindClockOpen]); until then the open is unbound and no
|
||||
/// source event can reach it. Every earlier open is superseded.
|
||||
int beginClockOpen(int targetEpoch) {
|
||||
final previousOpens = <_LiveClockOpen>{
|
||||
..._clockOpensByGeneration.values,
|
||||
..._unboundClockOpens,
|
||||
..._clockOpensBySource.values,
|
||||
};
|
||||
final previousOpens = <_LiveClockOpen>{..._clockOpensByGeneration.values, ..._clockOpensBySource.values};
|
||||
for (final open in previousOpens) {
|
||||
open.canceled = true;
|
||||
if (!open.result.isCompleted) open.result.complete(false);
|
||||
}
|
||||
_clockOpensBySource.removeWhere((_, open) => open.canceled);
|
||||
_clockOpensByGeneration.clear();
|
||||
_clockOpensBySource.clear();
|
||||
|
||||
final open = _LiveClockOpen(generation: ++_nextClockGeneration, targetEpoch: targetEpoch);
|
||||
_clockOpensByGeneration[open.generation] = open;
|
||||
_unboundClockOpens.add(open);
|
||||
_latestClockGeneration = open.generation;
|
||||
pendingStreamEpoch = targetEpoch.toDouble();
|
||||
return open.generation;
|
||||
}
|
||||
|
||||
/// Bind the oldest dispatched live open to the source MPV started next.
|
||||
bool bindClockSource(PlayerSourceStarted source) {
|
||||
if (_unboundClockOpens.isEmpty) return false;
|
||||
final open = _unboundClockOpens.removeAt(0);
|
||||
open.sourceId = source.sourceId;
|
||||
if (open.canceled) return false;
|
||||
_clockOpensBySource[source.sourceId] = open;
|
||||
/// Bind [generation] to the MPV source its `loadfile` reply named. Source
|
||||
/// events that already arrived for [sourceId] apply immediately, so a
|
||||
/// readiness or failure report that beat the reply is not lost. Returns
|
||||
/// whether the open is still live and now keyed by the source.
|
||||
bool bindClockOpen(int generation, int sourceId) {
|
||||
final open = _clockOpensByGeneration[generation];
|
||||
if (open == null) return false;
|
||||
open.sourceId = sourceId;
|
||||
if (open.canceled) {
|
||||
_unclaimedSources.remove(sourceId);
|
||||
return false;
|
||||
}
|
||||
_clockOpensBySource[sourceId] = open;
|
||||
final observed = _unclaimedSources.remove(sourceId);
|
||||
if (observed == null) return true;
|
||||
if (observed.failed) {
|
||||
_failClockOpen(open);
|
||||
return false;
|
||||
}
|
||||
final readyPosition = observed.readyPosition;
|
||||
if (readyPosition != null) {
|
||||
calibrateClockSource(PlayerSourceReady(sourceId: sourceId, position: readyPosition));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Calibrate epoch time against the first decoded position of [source].
|
||||
/// Readiness of a source no open has claimed yet is kept for a later
|
||||
/// [bindClockOpen].
|
||||
bool calibrateClockSource(PlayerSourceReady source) {
|
||||
final open = _clockOpensBySource.remove(source.sourceId);
|
||||
if (open == null || open.canceled || open.generation != _latestClockGeneration) return false;
|
||||
if (open == null) {
|
||||
_unclaimedSource(source.sourceId).readyPosition = source.position;
|
||||
return false;
|
||||
}
|
||||
if (open.canceled || open.generation != _latestClockGeneration) return false;
|
||||
|
||||
streamStartEpoch = open.targetEpoch - source.position.inMilliseconds / 1000.0;
|
||||
activeClockSourceId = source.sourceId;
|
||||
@@ -141,7 +172,22 @@ class LiveTvSessionState {
|
||||
|
||||
void failClockSource(PlayerSourceFailed source) {
|
||||
final open = _clockOpensBySource.remove(source.sourceId);
|
||||
if (open != null) _failClockOpen(open);
|
||||
if (open == null) {
|
||||
_unclaimedSource(source.sourceId).failed = true;
|
||||
return;
|
||||
}
|
||||
_failClockOpen(open);
|
||||
}
|
||||
|
||||
_UnclaimedSourceEvents _unclaimedSource(int sourceId) {
|
||||
final existing = _unclaimedSources.remove(sourceId);
|
||||
if (existing != null) {
|
||||
return _unclaimedSources[sourceId] = existing;
|
||||
}
|
||||
if (_unclaimedSources.length >= _maxUnclaimedSources) {
|
||||
_unclaimedSources.remove(_unclaimedSources.keys.first);
|
||||
}
|
||||
return _unclaimedSources[sourceId] = _UnclaimedSourceEvents();
|
||||
}
|
||||
|
||||
void failClockOpen(int generation) {
|
||||
@@ -160,7 +206,6 @@ class LiveTvSessionState {
|
||||
|
||||
void _failClockOpen(_LiveClockOpen open) {
|
||||
open.canceled = true;
|
||||
_unboundClockOpens.remove(open);
|
||||
final sourceId = open.sourceId;
|
||||
if (sourceId != null && identical(_clockOpensBySource[sourceId], open)) {
|
||||
_clockOpensBySource.remove(sourceId);
|
||||
@@ -175,14 +220,13 @@ class LiveTvSessionState {
|
||||
if (!open.result.isCompleted) open.result.complete(false);
|
||||
}
|
||||
|
||||
/// Resolves true once the open's source is calibrated, false when it is
|
||||
/// superseded, failed or timed out. A timed-out open stays registered so a
|
||||
/// late `loadfile` reply or readiness event can still bind and calibrate it.
|
||||
Future<bool> clockOpenResult(int generation) {
|
||||
final open = _clockOpensByGeneration[generation];
|
||||
if (open == null) return Future<bool>.value(false);
|
||||
return open.result.future.whenComplete(() {
|
||||
if (identical(_clockOpensByGeneration[generation], open)) {
|
||||
_clockOpensByGeneration.remove(generation);
|
||||
}
|
||||
});
|
||||
return open.result.future;
|
||||
}
|
||||
|
||||
void cancelClockOpens() {
|
||||
@@ -190,8 +234,8 @@ class LiveTvSessionState {
|
||||
for (final generation in generations) {
|
||||
failClockOpen(generation);
|
||||
}
|
||||
_unboundClockOpens.clear();
|
||||
_clockOpensBySource.clear();
|
||||
_unclaimedSources.clear();
|
||||
_latestClockGeneration = null;
|
||||
pendingStreamEpoch = null;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
onSeekRequested: _seekPlayback,
|
||||
onRateRequested: _setPlaybackRate,
|
||||
onPlayPauseRequested: _handleControlsTransport,
|
||||
onSeekCompleted: _notifyWatchTogetherSeek,
|
||||
onBack: _handleBackButton,
|
||||
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
|
||||
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
|
||||
|
||||
@@ -37,8 +37,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
_unfocusPlayNextPrompt();
|
||||
_dismissStillWatching();
|
||||
|
||||
_notifyWatchTogetherMediaChange(metadata: _episode.next);
|
||||
|
||||
_setPlayerState(() {
|
||||
_episode.isLoadingNext = true;
|
||||
_episode.showPlayNextDialog = false;
|
||||
@@ -54,8 +52,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
if (!_canNavigateMediaItems()) return;
|
||||
if (_episode.previous == null || _episode.isLoadingPrevious) return;
|
||||
|
||||
_notifyWatchTogetherMediaChange(metadata: _episode.previous);
|
||||
|
||||
_setPlayerState(() {
|
||||
_episode.isLoadingPrevious = true;
|
||||
});
|
||||
@@ -86,7 +82,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
await _seekPlayback(target);
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
|
||||
_notifyWatchTogetherSeek(target);
|
||||
_mediaControls.pushPlaybackState();
|
||||
}
|
||||
|
||||
@@ -95,14 +90,16 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
/// 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 {
|
||||
Future<void> _replaceScreenWithPlayer(MediaItem metadata, {WatchPlaybackLease? watchTogetherLease}) async {
|
||||
_isReplacingWithVideo = true; // before any await — dispose can run mid-helper
|
||||
watchTogetherLease ??= _activeWatchTogetherSession()?.capturePlaybackLease(selection: true);
|
||||
try {
|
||||
await navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: metadata,
|
||||
usePushReplacement: true,
|
||||
isOffline: _offlineLibraryMode,
|
||||
watchTogetherLease: watchTogetherLease,
|
||||
);
|
||||
} finally {
|
||||
// Still mounted ⇒ no push happened (external-player branch or a
|
||||
@@ -122,9 +119,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
/// [MediaReloadOutcome.rejected]: no in-place reload ran.
|
||||
Future<MediaReloadOutcome> _navigateToEpisode(MediaItem episodeMetadata) async {
|
||||
_episode.lastReloadFailureReason = null;
|
||||
final watchTogether = _activeWatchTogetherSession();
|
||||
final watchTogetherLease = watchTogether?.capturePlaybackLease(selection: true);
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) {
|
||||
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
|
||||
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata, watchTogetherLease: watchTogetherLease));
|
||||
return MediaReloadOutcome.rejected;
|
||||
}
|
||||
|
||||
@@ -144,6 +143,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
// preferences, both audio and subtitles start at the server-selected
|
||||
// priority (#1717).
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (!mounted || currentPlayer != player || (watchTogetherLease != null && !watchTogetherLease.isCurrent)) {
|
||||
return MediaReloadOutcome.superseded;
|
||||
}
|
||||
final followServerSelections = settingsService.read(SettingsService.followServerTrackSelections);
|
||||
final committedSubtitleSelection = _playbackSession?.subtitleSelection;
|
||||
final primarySubtitlePreference = followServerSelections
|
||||
@@ -176,6 +178,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
preservedAudioTrack: _sessionAudioPreference,
|
||||
preservedSubtitleTrack: primarySubtitlePreference,
|
||||
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
|
||||
watchTogetherLease: watchTogetherLease,
|
||||
reason: 'episode navigation',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
|
||||
@@ -235,8 +235,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
/// Re-opens the current session's live stream at [streamUrl].
|
||||
///
|
||||
/// Offset-based MPV opens register their requested absolute [targetEpoch]
|
||||
/// before `loadfile`. When [awaitClock] is true, success means the new
|
||||
/// source's first rendered player position has been mapped to that epoch.
|
||||
/// before `loadfile` and bind that registration to the source id the load
|
||||
/// reports, so only that source's events can calibrate it. When [awaitClock]
|
||||
/// is true, success means the new source's first rendered player position
|
||||
/// has been mapped to that epoch.
|
||||
Future<bool> _openLiveStream(
|
||||
Player player,
|
||||
String streamUrl, {
|
||||
@@ -246,21 +248,32 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
bool applyOptions = true,
|
||||
}) async {
|
||||
_live.streamGeneration++;
|
||||
final clockGeneration = targetEpoch != null && player is PlayerNative ? _live.beginClockOpen(targetEpoch) : null;
|
||||
final clockResult = clockGeneration == null ? null : _live.clockOpenResult(clockGeneration);
|
||||
try {
|
||||
final media = Media(streamUrl, headers: const {'Accept-Language': 'en'});
|
||||
final playNow = play ?? automotivePlaybackAllowedNow();
|
||||
if (targetEpoch == null || player is! PlayerNative) {
|
||||
if (applyOptions) await _setLiveStreamOptions(player);
|
||||
await player.open(
|
||||
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||
play: play ?? automotivePlaybackAllowedNow(),
|
||||
isLive: true,
|
||||
);
|
||||
} catch (_) {
|
||||
if (clockGeneration != null) _live.failClockOpen(clockGeneration);
|
||||
rethrow;
|
||||
await player.open(media, play: playNow, isLive: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clockResult == null || clockGeneration == null) return true;
|
||||
final clockGeneration = _live.beginClockOpen(targetEpoch);
|
||||
final clockResult = _live.clockOpenResult(clockGeneration);
|
||||
final int? sourceId;
|
||||
try {
|
||||
if (applyOptions) await _setLiveStreamOptions(player);
|
||||
sourceId = await player.open(media, play: playNow, isLive: true);
|
||||
} catch (_) {
|
||||
_live.failClockOpen(clockGeneration);
|
||||
rethrow;
|
||||
}
|
||||
if (sourceId == null) {
|
||||
// The load never reached mpv (core unavailable), so no source will ever
|
||||
// report for this open.
|
||||
_live.failClockOpen(clockGeneration);
|
||||
return false;
|
||||
}
|
||||
_live.bindClockOpen(clockGeneration, sourceId);
|
||||
|
||||
if (!awaitClock) {
|
||||
unawaited(clockResult);
|
||||
return true;
|
||||
|
||||
@@ -551,6 +551,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
showMessage: (message, {duration}) {
|
||||
if (mounted) showAppSnackBar(context, message, duration: duration);
|
||||
},
|
||||
playbackRateOwnedExternally: _watchTogetherOwnsPlaybackRate,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -357,6 +357,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
bool useCurrentAudioStreamSelection = true,
|
||||
bool showErrorUi = true,
|
||||
PlaybackTransitionLease? transitionLease,
|
||||
WatchPlaybackLease? watchTogetherLease,
|
||||
String reason = 'media reload',
|
||||
}) async {
|
||||
if (widget.isLive) {
|
||||
@@ -368,6 +369,9 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
if (mounted) _clearEpisodeLoadingFlags();
|
||||
return MediaReloadOutcome.rejected;
|
||||
}
|
||||
final initialWatchTogether = _activeWatchTogetherSession();
|
||||
final roomLease = watchTogetherLease ?? initialWatchTogether?.capturePlaybackLease();
|
||||
if (roomLease != null && !roomLease.isCurrent) return MediaReloadOutcome.superseded;
|
||||
|
||||
final reloadLease = transitionLease == null
|
||||
? _transitionGate.tryAcquire(PlaybackTransition.reloadingMedia)
|
||||
@@ -386,12 +390,16 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
try {
|
||||
final currentPlayer = existingPlayer;
|
||||
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true);
|
||||
bool isCurrentReload() => attempt.isCurrent && !_hasFatalPlaybackError && !_isExiting.value;
|
||||
bool isCurrentReload() =>
|
||||
attempt.isCurrent &&
|
||||
!_hasFatalPlaybackError &&
|
||||
!_isExiting.value &&
|
||||
(roomLease == null || roomLease.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 previousMetadata = _playbackSession?.metadata ?? _currentMetadata;
|
||||
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
|
||||
final previousPartId = _currentMediaInfo?.partId;
|
||||
final previousMediaSourceId = _currentMediaInfo?.mediaSourceId;
|
||||
@@ -413,6 +421,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle)
|
||||
: null;
|
||||
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
|
||||
Completer<void>? reloadStartupHold;
|
||||
var didOpenReplacement = false;
|
||||
|
||||
// Capture context-dependent values before async gaps. The neutral
|
||||
@@ -426,8 +435,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
late final AppDatabase database;
|
||||
late final MultiServerManager serverManager;
|
||||
late final WatchTogetherProvider? watchTogether;
|
||||
late final bool watchTogetherWasAttached;
|
||||
late final bool cycleWatchTogetherAttachment;
|
||||
late final Object? watchTogetherBinding;
|
||||
late final bool wtOwnsStart;
|
||||
try {
|
||||
offlineWatchService = context.read<OfflineWatchSyncService>();
|
||||
@@ -440,9 +448,8 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
// as user intents. Readiness re-handshakes on re-attach (item changes
|
||||
// start a new media epoch; same-item source switches group-wait while
|
||||
// we reload).
|
||||
watchTogether = _activeWatchTogetherSession();
|
||||
watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false;
|
||||
cycleWatchTogetherAttachment = watchTogetherWasAttached;
|
||||
watchTogether = initialWatchTogether;
|
||||
watchTogetherBinding = _watchTogetherBinding;
|
||||
wtOwnsStart = _watchTogetherOwnsPlaybackStart();
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Failed to prepare media reload during $reason', error: e, stackTrace: stackTrace);
|
||||
@@ -501,8 +508,8 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
|
||||
// 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();
|
||||
if (watchTogether != null && watchTogetherBinding != null) {
|
||||
watchTogether.unbindPlayer(expectedBinding: watchTogetherBinding);
|
||||
}
|
||||
try {
|
||||
await currentPlayer.pause();
|
||||
@@ -616,6 +623,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
plexClient: () => plexClient,
|
||||
getProfileSettings: () => accountPreferences.activePreferences,
|
||||
preferredAudioTrack: initializationAudioTrack,
|
||||
wtStartupHold: () => reloadStartupHold,
|
||||
primarySubtitleTranscoding: () => result.isTranscoding,
|
||||
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
|
||||
clearFirstFrameForOpen: false,
|
||||
@@ -635,9 +643,14 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
// reusing the player for replacement media, otherwise its late
|
||||
// completion can mutate the replacement item's tracks.
|
||||
await attempt.trackMutationDrain;
|
||||
await watchTogether?.pendingRateCommands;
|
||||
// Native seeks must finish before a replacement source opens. The
|
||||
// EOF delegate's reload is not a native seek and never joins this drain.
|
||||
await _nativeSeekDrain?.future;
|
||||
return isCurrentReload();
|
||||
},
|
||||
afterMediaOpened: (_, _, _) async {
|
||||
afterMediaOpened: (_, holdPlaybackStart, _) async {
|
||||
if (wtOwnsStart && holdPlaybackStart) reloadStartupHold = Completer<void>();
|
||||
_episode.completionLatch.reset();
|
||||
if (isItemChange) {
|
||||
// Same-item reloads (including the spurious-EOF recovery itself
|
||||
@@ -675,7 +688,9 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
// The player now owns the new file — publish the session at the
|
||||
// same boundary so identity and source state flip together.
|
||||
didOpenReplacement = true;
|
||||
if (!attempt.isCurrent) return;
|
||||
_commitPlaybackSession(session);
|
||||
_commitWatchTogetherSelection(watchTogether, roomLease, metadata, openResumePosition ?? Duration.zero);
|
||||
},
|
||||
);
|
||||
if (flow == null) return MediaReloadOutcome.superseded;
|
||||
@@ -738,7 +753,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
// 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) {
|
||||
if (!wtOwnsStart && wasPlayingBeforeReload && mounted && player == currentPlayer) {
|
||||
unawaited(_playWithPlaybackIntent(currentPlayer));
|
||||
}
|
||||
}
|
||||
@@ -774,28 +789,26 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
|
||||
}
|
||||
return didOpenReplacement ? MediaReloadOutcome.opened : MediaReloadOutcome.failed;
|
||||
} finally {
|
||||
// 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
|
||||
// controller auto-detached itself on a mid-reload player failure.
|
||||
// _currentMetadata is correct on both the success and rollback paths
|
||||
// by the time we get here.
|
||||
if (attempt.isCurrent && !didOpenReplacement && _currentMetadata.globalKey != previousMetadata.globalKey) {
|
||||
_currentMetadata = previousMetadata;
|
||||
if (previousLaunchIdentity != null) {
|
||||
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
|
||||
}
|
||||
_firstFrame.restore(previousFirstFrame);
|
||||
}
|
||||
// Only the current screen/session/open may restore its committed source.
|
||||
// A pre-open failure restores A; successful open commits B. Superseded
|
||||
// work never binds either over its successor.
|
||||
try {
|
||||
final reattachServerId = _currentMetadata.serverId;
|
||||
if (watchTogetherWasAttached &&
|
||||
watchTogether != null &&
|
||||
watchTogether.isInSession &&
|
||||
mounted &&
|
||||
player == currentPlayer &&
|
||||
reattachServerId != null &&
|
||||
if (watchTogether != null &&
|
||||
roomLease != null &&
|
||||
isCurrentReload() &&
|
||||
watchTogether.isPlaybackLeaseCurrent(roomLease) &&
|
||||
(watchTogetherBinding == null || watchTogether.ownsBinding(watchTogetherBinding)) &&
|
||||
!watchTogether.hasAttachedPlayer) {
|
||||
watchTogether.attachPlayer(
|
||||
currentPlayer,
|
||||
ratingKey: _currentMetadata.id,
|
||||
serverId: reattachServerId,
|
||||
mediaTitle: _currentMetadata.displayTitle,
|
||||
hasFirstFrame: _firstFrame.uiReady.value,
|
||||
remoteSeek: _seekPlayback,
|
||||
_attachToWatchTogetherSession(
|
||||
lease: roomLease,
|
||||
startupHold: didOpenReplacement ? reloadStartupHold?.future : null,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
|
||||
@@ -146,12 +146,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
if (widget.isLive) {
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.sourceStarted.listen((source) {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
_live.bindClockSource(source);
|
||||
}),
|
||||
);
|
||||
_playerStreamSubscriptions.add(
|
||||
currentPlayer.streams.sourceReady.listen((source) {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
@@ -5,6 +5,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
final currentPlayer = player;
|
||||
if (!mounted || currentPlayer == null) return;
|
||||
final attempt = _beginPlaybackAttempt(currentPlayer);
|
||||
final watchTogether = _activeWatchTogetherSession();
|
||||
final watchTogetherLease = widget.watchTogetherLease;
|
||||
_watchTogetherLease = watchTogetherLease;
|
||||
if (watchTogether != null && watchTogetherLease != null && watchTogetherLease.isCurrent) {
|
||||
_watchTogetherProvider = watchTogether;
|
||||
watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch;
|
||||
}
|
||||
bool isCurrentStart() => attempt.isCurrent && (watchTogetherLease == null || watchTogetherLease.isCurrent);
|
||||
_firstFrame.resetRenderedForAttempt();
|
||||
_hasFatalPlaybackError = false;
|
||||
// 503s observed from here on belong to this attempt's open.
|
||||
@@ -212,11 +220,11 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
subtitleSelection: subtitleSelection,
|
||||
headers: streamHeaders,
|
||||
isLocalMedia: _isOfflinePlayback,
|
||||
isCurrent: () => attempt.isCurrent,
|
||||
isCurrent: isCurrentStart,
|
||||
// When a Watch Together session is active the sync layer owns the
|
||||
// start: open paused everywhere and let the host coordinate one
|
||||
// simultaneous group start.
|
||||
watchTogetherOwnsStart: _watchTogetherOwnsPlaybackStart,
|
||||
watchTogetherOwnsStart: () => watchTogetherLease != null && _watchTogetherOwnsPlaybackStart(),
|
||||
resolveShouldAutoStart: (wtOwnsStart) => !wtOwnsStart,
|
||||
resumePosition: () => resumePosition,
|
||||
plexClient: () => plexClientForTracks,
|
||||
@@ -245,12 +253,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// Attach player to Watch Together session for sync (if in session).
|
||||
// With a frame-rate startup gate pending, sync readiness waits for
|
||||
// its release so the group start can't fire mid display switch.
|
||||
if (mounted && !_isOfflinePlayback) {
|
||||
if (wtOwnsStart && holdPlaybackStart) {
|
||||
wtStartupHold = Completer<void>();
|
||||
}
|
||||
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
|
||||
_notifyWatchTogetherMediaChange();
|
||||
if (isCurrentStart() && !_isOfflinePlayback && watchTogetherLease != null) {
|
||||
_commitWatchTogetherSelection(
|
||||
watchTogether,
|
||||
watchTogetherLease,
|
||||
_currentMetadata,
|
||||
resumePosition ?? Duration.zero,
|
||||
);
|
||||
if (wtOwnsStart && holdPlaybackStart) wtStartupHold = Completer<void>();
|
||||
_attachToWatchTogetherSession(lease: watchTogetherLease, startupHold: wtStartupHold?.future);
|
||||
}
|
||||
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
|
||||
await _playWithPlaybackIntent(currentPlayer);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user