Compare commits

..
Author SHA1 Message Date
edde746 703df6a561 refactor(focus): consolidate Back behind PopScope with per-owner press gates
One TV Back press could act twice: a KeyDown-fired action (Apple TV, a TV
text field under its IME, Flutter's own dialog dismissal) popped a route or
moved focus, and the KeyUp then landed on a different focus chain whose
handler acted again. Eight mechanisms had grown around that: a global
one-shot frame marker, a KeyUp suppressor armed from a NavigatorObserver on
both navigators plus a hardware-phase press tracker, per-screen
_suppressBackAfterPop flags driven by didPopNext, a backHandledByKeyEvent
flag copied between screens, ~40 handleBackKeyAction sites with an Apple TV
special case re-implemented by hand, PopScopes disabled on Android keyboard
mode to avoid a double pop, and app-level DismissIntent bindings.

The Flutter Android embedding only redispatches a Back key into
onBackPressed -> popRoute when the framework reports it unhandled, so a
handled key never produces a system pop and the only double is in-framework.
Back now has two owners. A focused control that consumes Back before the
route (text field, sheet, player strip, reorder mode, content -> sidebar)
holds a BackPressGate: it consumes the press, acts on the platform's phase,
and ignores a KeyUp whose KeyDown it did not see. Everything else bubbles to
NavigatorBackHandler, installed once around each navigator, which turns the
press into Navigator.maybePop on the route's synchronous popDisposition, so
a screen's back policy lives in its PopScope for key and system back alike.
An unowned KeyUp is swallowed there instead of reaching the platform; a
press racing a push is swallowed rather than popping the new route; a press
at the true root with nothing to pop is left to the platform.

Deleted: BackKeyCoordinator, BackKeyUpSuppressor, BackKeyPressTracker,
BackKeySuppressorObserver, handleBackKeyAction, handleBackKeyNavigation,
InputModeTracker.shouldBlockSystemBack, both _suppressBackAfterPop copies,
both backHandledByKeyEvent copies, OverlaySheetHost.onSystemBack (now
onBack, the single policy), the goBack/browserBack/gameButtonB DismissIntent
bindings, and the route-popping Focus wrappers on screens whose Back only
popped.
2026-09-05 21:30:18 +02:00
edde746 111af97a61 refactor(focus): route synthetic keys through the framework key pipeline
Gamepad, companion-remote and Siri Remote input was dispatched by walking
FocusNode.parent by hand, so it never reached HardwareKeyboard: pressed-key
state stayed stale, the eight hardware handlers (input-mode tracking, media
transport, the hotkey recorder, the Steam duplicate guard) never saw it, and
a synthetic release went to the node that took the press rather than to the
current focus like a hardware release does.

Synthetic events now enter ui.PlatformDispatcher.onKeyData, the engine's own
entry point, marked synthesized so the framework dispatches them at once.
They run the complete pipeline a hardware key does. The stream is
regularized against the framework's pressed state (a down for a held key is
a repeat, an up for an unheld key is dropped), a disconnecting source
releases what it still holds, and the Siri adapter no longer has to announce
itself to the input-mode tracker by hand.
2026-09-05 21:30:18 +02:00
340 changed files with 6976 additions and 25802 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.19.1"
sha256 "7d3ffd0efccf4db5faf063e8ecc03e897ee30d08e9fa3b007d127c63cbf187e2"
version "2.18.0"
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
@@ -16,10 +16,10 @@ cask "plezy" do
app "Plezy.app"
postflight_steps do
run "/usr/bin/xattr",
args: ["-cr", "{{appdir}}/Plezy.app"],
sudo: false
postflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{appdir}/Plezy.app"],
sudo: false
end
uninstall quit: "com.edde746.plezy"
-5
View File
@@ -135,11 +135,6 @@ 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,177 @@
package com.edde746.plezy.mpv
import android.app.Instrumentation
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.ViewGroup
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.concurrent.CountDownLatch
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 MpvLifecycleDeviceTest {
@Test
fun repeatedMediaCodecPlaybackCompletesTerminalTeardown() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val fixtureBytes = instrumentation.context.assets.open("ffmpeg/mediacodec_teardown.mp4").use { it.readBytes() }
val fixture = copyFixture(fixtureBytes, instrumentation.targetContext.cacheDir)
val activity = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, MpvLifecycleTestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
) as MpvLifecycleTestActivity
instrumentation.waitForIdleSync()
try {
repeat(CYCLE_COUNT) { cycle ->
runPlaybackCycle(instrumentation, activity, fixture, cycle)
}
} finally {
instrumentation.runOnMainSync(activity::finish)
instrumentation.waitForIdleSync()
fixture.delete()
}
}
private fun runPlaybackCycle(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
fixture: File,
cycle: Int
) {
val initialized = CountDownLatch(1)
val initializationResult = AtomicReference<Boolean>()
val events = RecordingDelegate()
val core = AtomicReference<MpvPlayerCore>()
instrumentation.runOnMainSync {
core.set(
MpvPlayerCore(activity, hardwareDecoding = true).also { playerCore ->
playerCore.delegate = events
playerCore.initialize { success ->
initializationResult.set(success)
initialized.countDown()
}
}
)
}
assertCompletes(initialized, "MPV initialization", cycle)
assertTrue("MPV initialization failed in cycle $cycle", initializationResult.get())
setProperty(instrumentation, core.get(), "hwdec", "mediacodec", cycle)
setProperty(instrumentation, core.get(), "aid", "no", cycle)
val commandCompleted = CountDownLatch(1)
val commandResult = AtomicReference<Boolean>()
instrumentation.runOnMainSync {
core.get().command(arrayOf("loadfile", fixture.absolutePath, "replace")) { success ->
commandResult.set(success)
commandCompleted.countDown()
}
}
assertCompletes(commandCompleted, "loadfile command", cycle)
assertTrue("loadfile command failed in cycle $cycle", commandResult.get())
assertCompletes(events.fileLoaded, "file-loaded event", cycle)
assertCompletes(events.playbackRestart, "playback-restart event", cycle)
assertEquals("mediacodec", core.get().getProperty("current-vo"))
assertTrue(
"Expected MediaCodec hardware decoding in cycle $cycle",
core.get().getProperty("hwdec-current")?.startsWith("mediacodec") == true
)
val disposed = CountDownLatch(1)
val nextMainTurn = CountDownLatch(1)
val disposeElapsedMs = AtomicReference<Long>()
val synchronousDisposeElapsedMs = AtomicReference<Long>()
val disposeStartedAt = SystemClock.elapsedRealtime()
instrumentation.runOnMainSync {
val synchronousDisposeStartedAt = SystemClock.elapsedRealtime()
core.get().dispose {
disposeElapsedMs.set(SystemClock.elapsedRealtime() - disposeStartedAt)
disposed.countDown()
}
Handler(Looper.getMainLooper()).post(nextMainTurn::countDown)
synchronousDisposeElapsedMs.set(SystemClock.elapsedRealtime() - synchronousDisposeStartedAt)
}
assertTrue(
"dispose() blocked the main thread for ${synchronousDisposeElapsedMs.get()}ms in cycle $cycle",
synchronousDisposeElapsedMs.get() <= MAX_SYNCHRONOUS_DISPOSE_MS
)
assertCompletes(nextMainTurn, "main-looper turn after dispose", cycle, MAIN_LOOP_TIMEOUT_SECONDS)
assertCompletes(disposed, "terminal teardown", cycle, DISPOSE_TIMEOUT_SECONDS)
assertTrue(
"Terminal teardown took ${disposeElapsedMs.get()}ms in cycle $cycle",
disposeElapsedMs.get() <= MAX_DISPOSE_LATENCY_MS
)
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
assertEquals("Player surface container leaked in cycle $cycle", 1, content.childCount)
}
}
private fun setProperty(
instrumentation: Instrumentation,
core: MpvPlayerCore,
name: String,
value: String,
cycle: Int
) {
val completed = CountDownLatch(1)
val result = AtomicReference<Result<Unit>>()
instrumentation.runOnMainSync {
core.setProperty(name, value) { outcome ->
result.set(outcome)
completed.countDown()
}
}
assertCompletes(completed, "$name property write", cycle)
assertTrue("$name property write failed in cycle $cycle", result.get().isSuccess)
}
private fun assertCompletes(
latch: CountDownLatch,
operation: String,
cycle: Int,
timeoutSeconds: Long = OPERATION_TIMEOUT_SECONDS
) {
assertTrue(
"$operation timed out in cycle $cycle after ${timeoutSeconds}s",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
}
private fun copyFixture(bytes: ByteArray, cacheDir: File): File = File.createTempFile("mpv-lifecycle-", ".mp4", cacheDir).apply { writeBytes(bytes) }
private class RecordingDelegate : PlayerDelegate {
val fileLoaded = CountDownLatch(1)
val playbackRestart = CountDownLatch(1)
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
when (name) {
"file-loaded" -> fileLoaded.countDown()
"playback-restart" -> playbackRestart.countDown()
}
}
}
private companion object {
const val CYCLE_COUNT = 8
const val OPERATION_TIMEOUT_SECONDS = 10L
const val DISPOSE_TIMEOUT_SECONDS = 15L
const val MAIN_LOOP_TIMEOUT_SECONDS = 1L
const val MAX_SYNCHRONOUS_DISPOSE_MS = 500L
const val MAX_DISPOSE_LATENCY_MS = 2_000L
}
}
@@ -1,84 +0,0 @@
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"
}
}
@@ -1,354 +0,0 @@
package com.edde746.plezy.mpv
import android.app.Instrumentation
import android.content.Intent
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
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.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MpvLifecycleDeviceTest {
@Test
fun repeatedMediaCodecPlaybackCompletesTerminalTeardown() = runPlaybackTest(recreateSurfaces = false)
@Test
fun pausedMediaCodecPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true)
@Test
fun pausedGpuPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true, hardwareDecoding = false)
@Test
fun placeholderConsumesFramesWithoutBlockingProducer() {
val placeholder = runBlocking { MpvPlaceholderSurface.create() }
val completed = CountDownLatch(1)
val failure = AtomicReference<Throwable?>()
val producer = Thread {
try {
// More than a BufferQueue can retain without an active consumer.
repeat(16) {
val canvas = placeholder.surface.lockCanvas(null)
canvas.drawColor(Color.BLACK)
placeholder.surface.unlockCanvasAndPost(canvas)
}
} catch (error: Throwable) {
failure.set(error)
} finally {
completed.countDown()
}
}.apply { isDaemon = true }
try {
producer.start()
assertCompletes(completed, "placeholder buffer consumption", 0)
failure.get()?.let { throw AssertionError("Placeholder producer failed", it) }
} finally {
placeholder.close()
producer.join(2_000)
assertTrue("Placeholder producer survived cleanup", !producer.isAlive)
}
}
private fun runPlaybackTest(recreateSurfaces: Boolean, hardwareDecoding: Boolean = true) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val fixtureBytes = instrumentation.context.assets.open("ffmpeg/mediacodec_teardown.mp4").use { it.readBytes() }
val fixture = copyFixture(fixtureBytes, instrumentation.targetContext.cacheDir)
try {
val activity = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, MpvLifecycleTestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
) as MpvLifecycleTestActivity
try {
instrumentation.waitForIdleSync()
repeat(CYCLE_COUNT) { cycle ->
runPlaybackCycle(instrumentation, activity, fixture, cycle, recreateSurfaces, hardwareDecoding)
}
} finally {
instrumentation.runOnMainSync(activity::finish)
instrumentation.waitForIdleSync()
}
} finally {
fixture.delete()
}
}
private fun runPlaybackCycle(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
fixture: File,
cycle: Int,
recreateSurfaces: Boolean,
hardwareDecoding: Boolean
) {
val initialized = CountDownLatch(1)
val initializationResult = AtomicReference<Boolean>()
val events = RecordingDelegate()
val core = AtomicReference<MpvPlayerCore>()
instrumentation.runOnMainSync {
core.set(
MpvPlayerCore(activity, hardwareDecoding = hardwareDecoding).also { playerCore ->
playerCore.delegate = events
playerCore.initialize { success ->
initializationResult.set(success)
initialized.countDown()
}
}
)
}
try {
assertCompletes(initialized, "MPV initialization", cycle)
assertTrue("MPV initialization failed in cycle $cycle", initializationResult.get())
setProperty(instrumentation, core.get(), "hwdec", if (hardwareDecoding) "mediacodec" else "no", cycle)
setProperty(instrumentation, core.get(), "aid", "no", cycle)
if (recreateSurfaces) setProperty(instrumentation, core.get(), "loop-file", "inf", cycle)
val commandCompleted = CountDownLatch(1)
val commandResult = AtomicReference<Boolean>()
instrumentation.runOnMainSync {
core.get().command(arrayOf("loadfile", fixture.absolutePath, "replace")) { success ->
commandResult.set(success)
commandCompleted.countDown()
}
}
assertCompletes(commandCompleted, "loadfile command", cycle)
assertTrue("loadfile command failed in cycle $cycle", commandResult.get())
assertCompletes(events.fileLoaded, "file-loaded event", cycle)
assertCompletes(events.playbackRestart, "playback-restart event", cycle)
assertVideoOutput(core.get(), cycle, hardwareDecoding)
if (recreateSurfaces) exerciseSurfaceRecreation(instrumentation, activity, core.get(), cycle, hardwareDecoding)
} finally {
disposeCore(instrumentation, activity, core.get(), cycle)
}
}
private fun disposeCore(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
core: MpvPlayerCore,
cycle: Int
) {
val disposed = CountDownLatch(1)
val nextMainTurn = CountDownLatch(1)
val disposeElapsedMs = AtomicReference<Long>()
val synchronousDisposeElapsedMs = AtomicReference<Long>()
val disposeStartedAt = SystemClock.elapsedRealtime()
instrumentation.runOnMainSync {
val synchronousDisposeStartedAt = SystemClock.elapsedRealtime()
core.dispose {
disposeElapsedMs.set(SystemClock.elapsedRealtime() - disposeStartedAt)
disposed.countDown()
}
Handler(Looper.getMainLooper()).post(nextMainTurn::countDown)
synchronousDisposeElapsedMs.set(SystemClock.elapsedRealtime() - synchronousDisposeStartedAt)
}
try {
assertTrue(
"dispose() blocked the main thread for ${synchronousDisposeElapsedMs.get()}ms in cycle $cycle",
synchronousDisposeElapsedMs.get() <= MAX_SYNCHRONOUS_DISPOSE_MS
)
assertCompletes(nextMainTurn, "main-looper turn after dispose", cycle, MAIN_LOOP_TIMEOUT_SECONDS)
} finally {
assertCompletes(disposed, "terminal teardown", cycle, DISPOSE_TIMEOUT_SECONDS)
}
assertTrue(
"Terminal teardown took ${disposeElapsedMs.get()}ms in cycle $cycle",
disposeElapsedMs.get() <= MAX_DISPOSE_LATENCY_MS
)
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
assertEquals("Player surface container leaked in cycle $cycle", 1, content.childCount)
}
}
private fun exerciseSurfaceRecreation(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
core: MpvPlayerCore,
cycle: Int,
hardwareDecoding: Boolean
) {
val surfaces = mutableListOf<SurfaceView>()
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
val container = content.getChildAt(0) as ViewGroup
// The host's first plane only punches out letterboxing; the remaining
// SurfaceViews are the actual video and OSD planes.
for (index in 1 until container.childCount) {
(container.getChildAt(index) as? SurfaceView)?.let(surfaces::add)
}
}
assertEquals("Expected video and optional OSD surfaces", if (hardwareDecoding) 2 else 1, surfaces.size)
// Separate visibility changes and callback latches guarantee both real
// destruction/creation orders, including video returning before the OSD.
val orders = if (hardwareDecoding) listOf(surfaces, surfaces.reversed()) else listOf(surfaces)
for (order in orders) {
setProperty(instrumentation, core, "pause", "yes", cycle)
awaitProperty(core, "pause", "public pause", cycle) { it == "yes" }
for (surface in order) {
changeSurfaceVisibility(instrumentation, surface, visible = false, cycle = cycle)
assertEquals("Surface loss cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
}
for (surface in order) {
changeSurfaceVisibility(instrumentation, surface, visible = true, cycle = cycle)
assertEquals("Surface restoration cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
}
val pausedPosition = awaitProperty(core, "time-pos", "paused playback position", cycle) {
it?.toDoubleOrNull() != null
}.toDouble()
setProperty(instrumentation, core, "pause", "no", cycle)
awaitProperty(core, "pause", "public resume", cycle) { it == "no" }
var progressStart = pausedPosition
awaitProperty(core, "time-pos", "playback progress after surface restoration", cycle) { value ->
val position = value?.toDoubleOrNull()
if (position == null) {
false
} else {
// The fixture is two seconds long and loops. Start measuring again
// across a loop boundary rather than mistaking a wrap for a stall.
if (position < progressStart) progressStart = position
position >= progressStart + 0.1
}
}
assertVideoOutput(core, cycle, hardwareDecoding)
}
}
private fun changeSurfaceVisibility(
instrumentation: Instrumentation,
surface: SurfaceView,
visible: Boolean,
cycle: Int
) {
val changed = CountDownLatch(1)
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
if (visible) changed.countDown()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (!visible) changed.countDown()
}
}
try {
instrumentation.runOnMainSync {
surface.holder.addCallback(callback)
surface.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
assertCompletes(changed, if (visible) "real surface creation" else "real surface destruction", cycle)
instrumentation.runOnMainSync {
assertEquals("Unexpected surface validity in cycle $cycle", visible, surface.holder.surface.isValid)
}
} finally {
instrumentation.runOnMainSync { surface.holder.removeCallback(callback) }
}
}
private fun awaitProperty(
core: MpvPlayerCore,
name: String,
operation: String,
cycle: Int,
matches: (String?) -> Boolean
): String {
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(OPERATION_TIMEOUT_SECONDS)
var value: String?
do {
value = core.getProperty(name)
if (matches(value)) return requireNotNull(value)
SystemClock.sleep(20)
} while (SystemClock.elapsedRealtime() < deadline)
throw AssertionError("$operation timed out in cycle $cycle; $name=$value")
}
private fun assertVideoOutput(core: MpvPlayerCore, cycle: Int, hardwareDecoding: Boolean) {
if (hardwareDecoding) {
assertEquals("mediacodec", core.getProperty("current-vo"))
assertTrue(
"Expected MediaCodec hardware decoding in cycle $cycle",
core.getProperty("hwdec-current")?.startsWith("mediacodec") == true
)
} else {
assertEquals("gpu", core.getProperty("current-vo"))
assertEquals("no", core.getProperty("hwdec-current"))
}
}
private fun setProperty(
instrumentation: Instrumentation,
core: MpvPlayerCore,
name: String,
value: String,
cycle: Int
) {
val completed = CountDownLatch(1)
val result = AtomicReference<Result<Unit>>()
instrumentation.runOnMainSync {
core.setProperty(name, value) { outcome ->
result.set(outcome)
completed.countDown()
}
}
assertCompletes(completed, "$name property write", cycle)
assertTrue("$name property write failed in cycle $cycle", result.get().isSuccess)
}
private fun assertCompletes(
latch: CountDownLatch,
operation: String,
cycle: Int,
timeoutSeconds: Long = OPERATION_TIMEOUT_SECONDS
) {
assertTrue(
"$operation timed out in cycle $cycle after ${timeoutSeconds}s",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
}
private fun copyFixture(bytes: ByteArray, cacheDir: File): File = File.createTempFile("mpv-lifecycle-", ".mp4", cacheDir).apply { writeBytes(bytes) }
private class RecordingDelegate : PlayerDelegate {
val fileLoaded = CountDownLatch(1)
val playbackRestart = CountDownLatch(1)
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
when (name) {
"file-loaded" -> fileLoaded.countDown()
"playback-restart" -> playbackRestart.countDown()
}
}
}
private companion object {
const val CYCLE_COUNT = 8
const val OPERATION_TIMEOUT_SECONDS = 10L
const val DISPOSE_TIMEOUT_SECONDS = 15L
const val MAIN_LOOP_TIMEOUT_SECONDS = 1L
const val MAX_SYNCHRONOUS_DISPOSE_MS = 500L
const val MAX_DISPOSE_LATENCY_MS = 2_000L
}
}
@@ -1,146 +0,0 @@
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
}
}
@@ -36,45 +36,17 @@ internal object GpuVoPolicy {
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
/**
* Select native software decoding before opening a decoder when hardware
* cannot serve the stream. H.264 High 10 needs an advertised profile
* (#2065); AV1 needs an actual hardware decoder (#2272), not a software
* MediaCodec component whose surface/copy paths can fail on VO changes.
* [codec] and [codecProfile] come from mpv's pending video track.
* Whether a video track must be software-decoded up front because the
* bitstream is H.264 High 10 and no hardware decoder advertises the
* profile (#2065). Without this the session still ends up in software —
* MediaCodec refuses the stream, FFmpeg falls back, the 10-bit frames
* cannot enter the video plane and the chain fails — but only after a
* decoder init, a failed video chain and a vo recreation, several seconds
* of black with audio already running. [codec] and [codecProfile] come
* from mpv's track-list; the profile string is FFmpeg's
* (`avcodec_profile_name`: "High 10", "High 10 Intra").
*/
fun needsSoftwareDecode(
codec: String?,
codecProfile: String?,
hardwareHigh10: Boolean,
hardwareAv1: Boolean
): Boolean = when (codec) {
"h264" -> !hardwareHigh10 && codecProfile?.startsWith("High 10") == true
"av1" -> !hardwareAv1
else -> false
}
/**
* 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 }
}
fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true
/**
* Whether a GL vo session should drop to the cheap render tier: bilinear
@@ -134,5 +106,5 @@ internal object GpuVoPolicy {
const val REASON_CHAIN_FAILURE = "chain-failure"
const val REASON_HDR_SDR = "hdr-sdr"
const val REASON_SW_DECODE = "sw-decode"
const val REASON_CODEC_SW_DECODE = "codec-sw-decode"
const val REASON_HI10_SW_DECODE = "hi10-sw-decode"
}
@@ -3,22 +3,12 @@ package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
internal class MpvEndFileDiagnostics {
private var errorMessage: String? = null
companion object {
/**
* The audio device stopped taking audio (or never could) and mpv gave up
* on it. Keep in sync with PlayerError.audioOutputFailed in Dart: a device
* fault, so no stream retry or backend switch can recover it.
*/
const val CAUSE_AUDIO_OUTPUT_FAILED = "audio-output-failed"
}
fun onStartFile() {
errorMessage = null
}
@@ -36,7 +26,6 @@ internal class MpvEndFileDiagnostics {
data["reason"] = reason.id
if (reason == EndFileReason.Error) {
errorMessage?.let { data["message"] = it }
if (event.error == MpvError.AoInitFailed) data["cause"] = CAUSE_AUDIO_OUTPUT_FAILED
}
}
errorMessage = null
@@ -1,54 +0,0 @@
package com.edde746.plezy.mpv
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import androidx.media3.common.util.EGLSurfaceTexture
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.withContext
/**
* An offscreen consumer, not an undrained ImageReader queue. MediaCodec and GPU
* output can both keep posting buffers while the real SurfaceViews are absent.
* Media3's common EGL utility consumes each frame on its own GL thread, including
* while the main thread is waiting for MPV to acknowledge a surface handoff.
*/
internal class MpvPlaceholderSurface private constructor(
val surface: Surface,
private val handler: Handler,
private val texture: EGLSurfaceTexture
) : AutoCloseable {
companion object {
/** The caller must retain or close the result even if initialization is canceled. */
suspend fun create(): MpvPlaceholderSurface {
val thread = HandlerThread("MpvPlaceholder").apply { start() }
val handler = Handler(thread.looper)
val texture = EGLSurfaceTexture(handler)
return withContext(handler.asCoroutineDispatcher("MpvPlaceholder")) {
try {
texture.init(EGLSurfaceTexture.SECURE_MODE_NONE)
MpvPlaceholderSurface(Surface(texture.surfaceTexture), handler, texture)
} catch (error: Throwable) {
try {
texture.release()
} finally {
thread.quitSafely()
}
throw error
}
}
}
}
/** Call only after native consumers have retired; destruction stays on the GL thread. */
override fun close() {
handler.post {
try {
surface.release()
texture.release()
} finally {
handler.looper.quitSafely()
}
}
}
}
@@ -3,7 +3,9 @@ package com.edde746.plezy.mpv
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -23,12 +25,10 @@ import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* mpv playback core. Two modes:
* - Video (default): [context] is the host Activity, which is needed for the
@@ -44,44 +44,24 @@ 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,
osdRenderScale: Float = 1f,
initialLogLevel: String = "warn"
) : this(context, audioOnly, hardwareDecoding, osdRenderScale, initialLogLevel, null, null, false)
hardwareDecoding: Boolean = true
) : this(context, audioOnly, hardwareDecoding, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : 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)
) : this(context, audioOnly, true, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
private const val SURFACE_HANDOFF_TIMEOUT_MS = 2_000L
/**
* The initial `vo` chain, decided by whether this session will hardware-
@@ -145,8 +125,8 @@ class MpvPlayerCore private constructor(
* chain-failure watchdog. Written under [gpuVoReasons]. */
@Volatile private var activeGpuVoTarget: String? = null
/** Per-file reasons holding hwdec at `no` (DV P5 reshaping or unsupported
* hardware decoding); the session's own hwdec value is parked in
/** Per-file reasons holding hwdec at `no` (DV P5 reshaping, Hi10 without
* a hardware profile); the session's own hwdec value is parked in
* [parkedHwdec] while any is active. Written under itself. */
private val hwdecHoldReasons = LinkedHashSet<String>()
@@ -154,9 +134,6 @@ 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>()
@@ -196,12 +173,11 @@ class MpvPlayerCore private constructor(
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@Volatile private var disposing: Boolean = false
private var nativeDisposalComplete: CountDownLatch? = null
@Volatile private var pendingSurface: Surface? = null
@Volatile private var attachedSurface: Surface? = null
private var placeholder: MpvPlaceholderSurface? = null
private var placeholderImageReader: ImageReader? = null
@Volatile private var placeholderSurface: Surface? = null
@@ -257,8 +233,6 @@ class MpvPlayerCore private constructor(
@Volatile private var videoOutputRestoring: Boolean = false
@Volatile private var videoOutputFailure: Exception? = null
@Volatile private var deferredResumeRequested: Boolean = false
@Volatile private var resumeBlockedByPublicPause: Boolean = false
@@ -275,6 +249,7 @@ class MpvPlayerCore private constructor(
@Volatile private var videoOutputEpoch: Long = 0L
private val videoOutputMutex = Mutex()
private var pendingVideoOutputDisableJob: Job? = null
private var pendingVideoOutputRefreshJob: Job? = null
private var flutterOverlayApplied = false
@@ -288,6 +263,14 @@ class MpvPlayerCore private constructor(
}
}
private fun ensurePlaceholderSurface() {
if (placeholderSurface?.isValid == true) return
placeholderImageReader?.close()
placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2)
placeholderSurface = placeholderImageReader?.surface
Log.d(TAG, "Created MPV placeholder surface")
}
@Suppress("DEPRECATION")
private fun currentDisplayFpsOverride(): String? {
if (audioOnly) return null
@@ -346,13 +329,14 @@ class MpvPlayerCore private constructor(
attachedToPlaceholder = false
hasAttachedSurface = false
videoOutputRestoring = false
videoOutputFailure = null
deferredResumeRequested = false
synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration += 1L
resumeBlockedByPublicPause = false
}
videoOutputEpoch = 0L
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
@@ -379,6 +363,7 @@ class MpvPlayerCore private constructor(
currentDvConversionMode = "auto"
hdrSurfaceDecided = false
hdrDisplayActive = false
if (!audioOnly) ensurePlaceholderSurface()
// Initialize audio focus handling. mpv has none built in, so both modes
// use the shared manager: pause on (transient) loss, auto-resume on
@@ -406,7 +391,7 @@ class MpvPlayerCore private constructor(
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
surfaceContainer!!.addView(surfaceView)
if (usesMediaCodecVo) {
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback, osdRenderScale)
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
surfaceContainer!!.addView(osdSurfaceView)
}
@@ -426,18 +411,6 @@ class MpvPlayerCore private constructor(
scope.launch {
try {
if (!audioOnly) {
// Cancellation must not lose an EGL consumer created on its GL thread.
withContext(NonCancellable) {
val created = MpvPlaceholderSurface.create()
if (disposing) {
created.close()
} else {
placeholder = created
placeholderSurface = created.surface
}
}
}
if (disposing) {
onResult(false)
return@launch
@@ -449,7 +422,6 @@ 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
@@ -520,15 +492,12 @@ 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). Nothing
// is selected yet either, so the track both policies decide for
// is resolved once here, from mpv's pending selection.
// MediaCodec decoder is already created by then (#2065).
p.hookHandler = { name ->
if (name == "on_preloaded" && !disposing) {
withContext(mpvWriteDispatcher) {
val track = pendingVideoTrack(p)
applyDvReshapePolicy(p, track)
applySoftwareDecodePolicy(p, track)
applyDvReshapePolicy(p)
applySoftwareDecodePolicy(p)
}
}
}
@@ -692,6 +661,7 @@ class MpvPlayerCore private constructor(
val surface = holder.surface
pendingSurface = surface.takeIf { it.isValid }
pendingVideoOutputDisableJob?.cancel()
videoOutputEpoch += 1L
rememberCurrentSurfaceSize()
if (player == null) {
@@ -711,12 +681,8 @@ class MpvPlayerCore private constructor(
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "Surface destroyed")
pendingSurface = null
if (disposing) {
awaitNativeDisposal()
return
}
if (player == null) return
handoffDestroyedSurface("surfaceDestroyed", videoLost = true)
if (player == null || disposing) return
detachSurfaceInternal(reason = "surfaceDestroyed")
}
// OSD surface (the vo=mediacodec subtitle/OSD plane)
@@ -726,8 +692,7 @@ class MpvPlayerCore private constructor(
if (disposing) return
pendingOsdSurface = holder.surface.takeIf { it.isValid }
Log.d(TAG, "OSD surface created")
videoOutputEpoch += 1L
if (player != null && currentCandidateSurface() != null) {
if (player != null && hasAttachedRealSurface()) {
refreshVideoOutput("osdSurfaceCreated")
}
}
@@ -737,18 +702,38 @@ class MpvPlayerCore private constructor(
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "OSD surface destroyed")
pendingOsdSurface = null
if (disposing) {
awaitNativeDisposal()
return
val wasAttached = attachedOsdSurface != null
attachedOsdSurface = null
val p = player ?: return
if (disposing || !wasAttached) return
// Ordered against every other mpv write: a detach that overtook the
// re-attach of a recreated surface would clear the option the attach
// just set, and nothing re-arms it — subtitles would stay dead for the
// rest of the session.
scope.launch(mpvWriteDispatcher) {
try {
p.detachOsdSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach OSD surface", e)
}
}
if (player == null) return
// Rebuild the VO without this plane before Android invalidates it.
// Keep the video target: hiding the OSD when switching to GPU output
// is not a loss of the video surface and must not pause playback.
handoffDestroyedSurface("osdSurfaceDestroyed", videoLost = false)
}
}
/**
* Hands the OSD Surface to the vo. The vo reads the option when it is
* created, so this has to run before the `vo` or `wid` write that creates
* it, never after.
*/
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
if (osd === attachedOsdSurface) return
p.attachOsdSurface(osd)
attachedOsdSurface = osd
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
}
private fun collectVideoDimensions(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeInt("dwidth").collect { value ->
@@ -805,48 +790,50 @@ class MpvPlayerCore private constructor(
*/
private fun applyGpuVoTarget() {
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
runOnMain {
if (disposing) return@runOnMain
if (target == null) {
osdSurfaceView?.visibility = View.VISIBLE
} else {
// The gpu VOs draw their own OSD; a stale subtitle frame must not
// linger on the overlay plane.
osdSurfaceView?.visibility = View.GONE
resetVideoSurfaceToFullContainer()
}
}
try {
videoOutputMutex.withLock {
if (disposing || videoOutputFailure != null) return@withLock
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
runOnMain {
if (disposing) return@runOnMain
if (target == null) {
osdSurfaceView?.visibility = View.VISIBLE
} else {
// GPU output draws its own OSD.
osdSurfaceView?.visibility = View.GONE
resetVideoSurfaceToFullContainer()
// Before the vo write, which recreates the VO: it reads the OSD
// surface option at creation.
val p = player
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
writeProperty("vo", target ?: "mediacodec")
if (p == null) return@launch
applyRenderTier(p, glVoActive = target != null)
if (target != null) {
// A failed conversion chain makes mpv deselect the video track
// ("Could not initialize video chain" -> vid=no) before the VO
// switch lands. Re-select the explicit track id: mid-file "auto"
// resolves to no selection rather than re-running selection.
if (p.getString("vid").let { it == null || it == "no" }) {
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
if (videoTrackId != null) {
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
writeProperty("vid", videoTrackId.toString())
}
}
val p = player
val surface = attachedSurface?.takeIf { it.isValid }
val osd = if (target == null && !attachedToPlaceholder) pendingOsdSurface?.takeIf { it.isValid } else null
if (p != null && surface != null && osd !== attachedOsdSurface) {
p.attachSurfaces(surface, osd)
attachedOsdSurface = osd
}
writeProperty("vo", target ?: "mediacodec")
if (p == null) return@withLock
applyRenderTier(p, glVoActive = target != null)
if (target != null) {
// A failed conversion chain deselects video before this switch.
// Re-select its explicit id; mid-file "auto" resolves to none.
if (p.getString("vid").let { it == null || it == "no" }) {
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
if (videoTrackId != null) {
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
writeProperty("vid", videoTrackId.toString())
}
}
}
applySurfaceSizeInternal(p, force = true)
if (target == null) applyVideoRectLayout()
}
applySurfaceSizeInternal(p, force = true)
if (target == null) {
// Refit the surfaces to the aspect rectangle now that the plane
// owns scaling again (the gpu VOs letterboxed within full
// containers).
applyVideoRectLayout()
}
} catch (e: CancellationException) {
Log.d(TAG, "Canceled vo transition write")
} catch (e: Exception) {
runOnMain { failVideoOutput("VO transition", e) }
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
}
}
}
@@ -854,14 +841,12 @@ 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; 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].
* 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.
*/
private suspend fun applyDvReshapePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
val profile = track?.takeIf { it.has("dolby-vision-profile") }?.getLong("dolby-vision-profile")
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
val profile = selectedVideoDvProfile(p)
val needs = GpuVoPolicy.needsDvReshaping(
dvProfile = profile,
conversionMode = currentDvConversionMode,
@@ -874,24 +859,24 @@ class MpvPlayerCore private constructor(
}
/**
* Route unsupported hardware decoding before decoder initialization:
* H.264 High 10 without a hardware profile (#2065), and AV1 without a
* hardware decoder (#2272). Keep the GL requirement for the file, including
* ambient toggles and surface recreation, rather than waiting for failure
* or transient `hwdec-current` observations. [track] is the pending video
* track, see [pendingVideoTrack].
* Per-file Hi10 routing (#2065): an H.264 High 10 stream on hardware that
* advertises no such profile goes straight to software decode on a GL vo,
* instead of letting MediaCodec refuse it and the video chain fail first.
* 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.
*/
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) {
val track = videoTracks(p).firstOrNull()
val codec = track?.optString("codec")
val codecProfile = track?.optString("codec-profile")
val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support()
val hardwareAv1 = MediaCodecQuery.hardwareAv1Support()
Log.d(TAG, "Decode routing: codec=$codec profile=$codecProfile hardwareHigh10=$hardwareHigh10 hardwareAv1=$hardwareAv1")
val needs = GpuVoPolicy.needsSoftwareDecode(codec, codecProfile, hardwareHigh10, hardwareAv1)
if (holdHwdec(p, GpuVoPolicy.REASON_CODEC_SW_DECODE, needs) && needs) {
Log.i(TAG, "$codec profile=$codecProfile without hardware support: native software decode on the GL vo")
Log.d(TAG, "Decode routing: codec=$codec profile=$codecProfile hardwareHigh10=$hardwareHigh10")
val needs = GpuVoPolicy.needsSoftwareDecode(codec, codecProfile, hardwareHigh10)
if (holdHwdec(p, GpuVoPolicy.REASON_HI10_SW_DECODE, needs) && needs) {
Log.i(TAG, "H.264 High 10 without a hardware profile: software decode on the GL vo")
}
setGpuVoRequirement(GpuVoPolicy.REASON_CODEC_SW_DECODE, needs)
setGpuVoRequirement(GpuVoPolicy.REASON_HI10_SW_DECODE, needs)
}
/**
@@ -963,29 +948,13 @@ class MpvPlayerCore private constructor(
}
/**
* 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].
* 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).
*/
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 }
}
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
?.takeIf { it.has("dolby-vision-profile") }
?.getLong("dolby-vision-profile")
/**
* Observed rather than derived from the hardware-decoding setting because
@@ -1103,25 +1072,25 @@ class MpvPlayerCore private constructor(
rememberSurfaceSize(sv.width, sv.height)
}
// Only callbacks publish usable surfaces. SurfaceHolder.isValid can still
// be true inside surfaceDestroyed, after we have revoked that surface.
private fun currentCandidateSurface(): Surface? = pendingSurface?.takeIf { it.isValid }
private fun currentCandidateSurface(): Surface? = surfaceView?.holder?.surface?.takeIf { it.isValid }
?: pendingSurface?.takeIf { it.isValid }
private fun hasAttachedRealSurface(): Boolean = hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)
// Audio-only mode has no video output to wait for — playback and resume
// paths gated on output readiness must always proceed there.
private fun hasReadyVideoOutput(): Boolean = audioOnly || (videoOutputFailure == null && hasAttachedRealSurface() && !videoOutputRestoring)
private fun hasReadyVideoOutput(): Boolean = audioOnly || (hasAttachedRealSurface() && !videoOutputRestoring)
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && videoOutputFailure == null && epoch == videoOutputEpoch
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && epoch == videoOutputEpoch
private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean {
if (!isCurrentVideoOutputEpoch(epoch)) return false
if (disposing) return false
if (epoch != videoOutputEpoch) return false
return hasAttachedRealSurface()
}
private fun refreshVideoOutput(reason: String) {
if (audioOnly || disposing || videoOutputFailure != null) return
if (audioOnly || disposing) return
rememberCurrentSurfaceSize()
val p = player
@@ -1133,12 +1102,18 @@ class MpvPlayerCore private constructor(
}
if (surface == null || !surface.isValid) {
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
pendingSurface = null
lastAppliedSurfaceSize = null
videoOutputRestoring = true
Log.d(TAG, "refreshVideoOutput($reason): no valid surface available")
return
}
val refreshEpoch = videoOutputEpoch
pendingVideoOutputDisableJob?.cancel()
videoOutputRestoring = true
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
@@ -1151,21 +1126,30 @@ class MpvPlayerCore private constructor(
return@withLock
}
if (!surface.isValid) {
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
pendingSurface = null
lastAppliedSurfaceSize = null
videoOutputRestoring = true
Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)")
return@withLock
}
val osd = pendingOsdSurface?.takeIf { usesMediaCodecVo && activeGpuVoTarget == null && it.isValid }
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osd !== attachedOsdSurface
// An unattached OSD Surface forces a wid re-attach: the VO reads the
// OSD surface option at creation, and setting wid recreates the VO.
// Suppressed off the plane, where the GL vo draws its own OSD.
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
val wasAttachedToPlaceholder = attachedToPlaceholder
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
if (needsAttach) {
p.attachSurfaces(surface, osd)
attachedOsdSurface = osd
attachOsdSurfaceIfNeeded(p)
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
attachedToPlaceholder = false
pendingSurface = null
Log.d(TAG, "refreshVideoOutput($reason): attached surface")
} else {
Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state")
@@ -1194,7 +1178,7 @@ class MpvPlayerCore private constructor(
} catch (e: CancellationException) {
Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)")
} catch (e: Exception) {
runOnMain { failVideoOutput("refresh ($reason)", e) }
Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e)
}
}
}
@@ -1226,107 +1210,80 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}")
}
/**
* SurfaceHolder requires consumers to stop using a surface before destruction
* returns. The worker and GL placeholder never need the main looper to finish.
* A timeout is a terminal output failure, not permission to mark it ready or
* release references still held by native code.
*/
private fun handoffDestroyedSurface(reason: String, videoLost: Boolean) {
val p = player ?: return
if (videoOutputFailure != null) return
videoOutputRestoring = true
val epoch = videoOutputEpoch + 1L
videoOutputEpoch = epoch
val completed = CountDownLatch(1)
val failure = AtomicReference<Exception?>()
scope.launch(Dispatchers.IO, start = CoroutineStart.ATOMIC) {
private fun schedulePlaceholderSurfaceAttach(
p: MpvPlayer,
reason: String,
epoch: Long
) {
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) {
try {
videoOutputMutex.withLock {
if (!isCurrentVideoOutputEpoch(epoch)) return@withLock
val target = if (videoLost) placeholderSurface else currentCandidateSurface() ?: placeholderSurface
check(target != null && target.isValid) { "No valid MPV surface for $reason" }
val isPlaceholder = target === placeholderSurface
if (isPlaceholder && !attachedToPlaceholder) {
publicPauseWriteMutex.withLock {
val wasPaused = p.getFlag("pause") ?: cachedPaused
if (!wasPaused) {
if (!isCurrentVideoOutputEpoch(epoch)) {
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
return@withLock
}
publicPauseWriteMutex.withLock {
val wasPaused = try {
p.getFlag("pause") == true
} catch (e: Exception) {
cachedPaused
}
if (!wasPaused) {
try {
p.setProperty("pause", true)
cachedPaused = true
pausedForSurfaceLoss = true
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
} catch (e: Exception) {
pausedForSurfaceLoss = false
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
}
} else {
pausedForSurfaceLoss = false
}
}
// Revoke both planes when the video disappears. Otherwise retain
// the currently available video and remove only the destroyed OSD.
val osd = if (isPlaceholder) {
null
} else {
pendingOsdSurface?.takeIf {
usesMediaCodecVo && activeGpuVoTarget == null && it.isValid
}
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
return@withLock
}
if (attachedSurface !== target || attachedOsdSurface !== osd) {
p.attachSurfaces(target, osd)
}
attachedSurface = target
attachedOsdSurface = osd
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
attachedToPlaceholder = isPlaceholder
attachedToPlaceholder = true
lastAppliedSurfaceSize = null
if (!isCurrentVideoOutputEpoch(epoch)) return@withLock
videoOutputRestoring = isPlaceholder
if (!isPlaceholder) {
applySurfaceSizeInternal(p, force = true)
if (!isCurrentVideoOutputEpoch(epoch)) return@withLock
applyDeferredResumeIfNeeded(p, reason)
pausedForSurfaceLoss = false
}
Log.d(TAG, "Surface handoff complete ($reason, epoch=$epoch, placeholder=$isPlaceholder)")
Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)")
}
} catch (error: Exception) {
failure.set(error)
} finally {
completed.countDown()
} catch (e: CancellationException) {
Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)")
} catch (e: Exception) {
Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e)
}
}
val acknowledged = try {
completed.await(SURFACE_HANDOFF_TIMEOUT_MS, TimeUnit.MILLISECONDS)
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
failure.set(error)
false
}
if (!acknowledged || failure.get() != null) {
failVideoOutput(reason, failure.get() ?: MpvException("Surface handoff timed out after ${SURFACE_HANDOFF_TIMEOUT_MS}ms"))
}
}
private fun failVideoOutput(reason: String, error: Exception) {
if (disposing || videoOutputFailure != null) return
videoOutputFailure = error
videoOutputEpoch += 1L
private fun detachSurfaceInternal(reason: String) {
val hadAttachedSurface = hasAttachedSurface || attachedSurface != null
hasAttachedSurface = false
attachedSurface = null
attachedToPlaceholder = false
videoOutputRestoring = true
deferredResumeRequested = false
Log.e(TAG, "MPV video output failed during $reason", error)
// Same terminal playback-error envelope as the other Android player.
// Do not claim that the native call finished or retire its surfaces here.
delegate?.onEvent(
"end-file",
mapOf("reason" to "error", "message" to "Video output failed", "cause" to "$reason: ${error.message}")
)
}
lastAppliedSurfaceSize = null
val detachEpoch = videoOutputEpoch + 1L
videoOutputEpoch = detachEpoch
private fun awaitNativeDisposal() {
val completed = nativeDisposalComplete ?: return
try {
if (!completed.await(SURFACE_HANDOFF_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
Log.e(TAG, "Native teardown did not release the destroyed surface within ${SURFACE_HANDOFF_TIMEOUT_MS}ms")
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
Log.e(TAG, "Interrupted waiting for native surface retirement", error)
val p = player ?: return
if (!hadAttachedSurface) {
Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear")
return
}
schedulePlaceholderSurfaceAttach(
p = p,
reason = reason,
epoch = detachEpoch
)
Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)")
}
private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) {
@@ -1599,35 +1556,6 @@ 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")))
@@ -1666,12 +1594,6 @@ class MpvPlayerCore private constructor(
}
val paused = if (name == "pause") normalizePauseValue(value) else null
if (paused == false) {
videoOutputFailure?.let { error ->
runOnMain { onComplete?.invoke(Result.failure(error)) }
return
}
}
val pauseIntent = paused?.let {
synchronized(publicPauseIntentLock) {
PublicPauseIntent(
@@ -1901,34 +1823,21 @@ 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(Result.failure(IllegalStateException("MPV player unavailable")))
onComplete?.invoke(false)
return
}
scope.launch(mpvWriteDispatcher) {
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))
}
var success = false
try {
player?.command(*args)
success = true
} catch (e: Exception) {
Log.w(TAG, "command failed", e)
Result.failure(e)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete(outcome)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(success)
}
}
}
}
@@ -2035,10 +1944,10 @@ class MpvPlayerCore private constructor(
check(Looper.myLooper() == Looper.getMainLooper())
Log.d(TAG, "Disposing")
val disposalComplete = CountDownLatch(1)
nativeDisposalComplete = disposalComplete
// Hiding a SurfaceView destroys its surface. Keep the views visible until
// native teardown retires both consumers; Flutter's overlay stays above.
surfaceContainer?.let { container ->
container.visibility = View.INVISIBLE
Log.d(TAG, "Hiding surface container during dispose")
}
handler.removeCallbacksAndMessages(null)
@@ -2053,6 +1962,8 @@ class MpvPlayerCore private constructor(
// Cancel all coroutines
scope.cancel()
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
pendingVideoOutputRefreshJob?.cancel()
pendingVideoOutputRefreshJob = null
@@ -2073,7 +1984,8 @@ class MpvPlayerCore private constructor(
val osdSv = osdSurfaceView
val container = surfaceContainer
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
val retiringPlaceholder = placeholder
val retiringPlaceholderSurface = placeholderSurface
val retiringPlaceholderImageReader = placeholderImageReader
surfaceContainer = null
surfaceView = null
@@ -2089,7 +2001,7 @@ class MpvPlayerCore private constructor(
pendingSurface = null
placeholderSurface = null
placeholder = null
placeholderImageReader = null
pausedForSurfaceLoss = false
pausedForAudioFocusLoss = false
attachedToPlaceholder = false
@@ -2101,6 +2013,7 @@ class MpvPlayerCore private constructor(
desiredPaused = true
}
videoOutputEpoch = 0L
pendingVideoOutputDisableJob = null
isInitialized = false
// Close the player on a background thread, then release surfaces and remove views.
@@ -2113,8 +2026,8 @@ class MpvPlayerCore private constructor(
} catch (e: Exception) {
Log.w(TAG, "MPV close failed", e)
}
disposalComplete.countDown()
retiringPlaceholder?.close()
retiringPlaceholderSurface?.release()
retiringPlaceholderImageReader?.close()
player = null
Log.d(TAG, "Disposed (native)")
Handler(Looper.getMainLooper()).post {
@@ -2128,8 +2041,8 @@ class MpvPlayerCore private constructor(
}.start()
} else {
// No player — safe to remove views immediately
disposalComplete.countDown()
retiringPlaceholder?.close()
retiringPlaceholderSurface?.release()
retiringPlaceholderImageReader?.close()
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
@@ -206,11 +206,6 @@ 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
@@ -269,7 +264,7 @@ open class MpvPlayerPlugin(
}
gen = ++sessionGeneration
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
@@ -474,42 +469,25 @@ open class MpvPlayerPlugin(
result.error("NOT_INITIALIZED", "Player not initialized", null)
return
}
// `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)
}
)
core.command(args.toTypedArray()) { success ->
if (success) {
result.success(null)
} else {
result.error("COMMAND_FAILED", "mpv command failed", args)
}
}
}
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
val level = call.argument<Any>("level") as? String
if (level == null) {
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
if (call.argument<String>("level") == null) {
result.error("INVALID_ARGS", "Missing 'level'", null)
return
}
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)
}
}
}
result.error(
"UNSUPPORTED",
"Runtime mpv log level changes are not supported on Android",
null
)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
@@ -1,41 +0,0 @@
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,18 +71,11 @@ 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
@@ -55,13 +55,6 @@ internal object MediaCodecQuery {
} != null
}
/** Software MediaCodec AV1 components are not a hardware decode path (#2272). */
fun hardwareAv1Support(): Boolean = hardwareAv1.value
private val hardwareAv1: Lazy<Boolean> = lazy {
findHardwareDecoder("video/av01") != null
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -7,7 +7,6 @@ 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 {
@@ -64,13 +63,8 @@ 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, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
@@ -80,13 +74,6 @@ 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 {
-9
View File
@@ -18,12 +18,3 @@ 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,7 +2,6 @@
#define ANDROID_LOG_INFO 4
#define ANDROID_LOG_WARN 5
#define ANDROID_LOG_ERROR 6
#ifdef __cplusplus
extern "C" {
-47
View File
@@ -1,6 +1,5 @@
#pragma once
#include <cstdarg>
#include <cstdint>
#include <cstring>
#include <string>
@@ -15,10 +14,6 @@ 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*;
@@ -33,53 +28,11 @@ 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) {
@@ -1,700 +0,0 @@
#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"
#define pthread_mutex_lock tracked_mutex_lock
#include "../../../../libmpv/src/main/cpp/render.cpp"
#undef pthread_mutex_lock
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;
jobject osd_option = nullptr;
int rebuilds = 0;
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;
// Each NewGlobalRef is a distinct handle, even for the same Java Surface.
std::vector<std::unique_ptr<jobject>> reference_handles;
std::map<jobject, jobject> global_refs;
int fail_ref_after = 0;
std::vector<std::string> option_failures;
bool consume_osd_before_failure = false;
bool hold_wid = false;
bool wid_entered = false;
bool allow_wid = false;
bool surface_waiting = false;
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, surface_handoff };
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) {
for (jobject ref : {handle->video, handle->osd, handle->osd_option}) {
if (ref) require(global_refs.count(ref) == 1, "Surface option/consumer retained a deleted JNI reference");
}
}
jobject new_global_ref(jobject object) {
std::lock_guard<std::mutex> lock(gate);
if (!object) return nullptr;
if (fail_ref_after > 0 && --fail_ref_after == 0) return nullptr;
reference_handles.push_back(std::make_unique<jobject>(object));
jobject ref = reference_handles.back().get();
global_refs.emplace(ref, object);
return ref;
}
void delete_global_ref(jobject object) {
if (!object) return;
std::lock_guard<std::mutex> lock(gate);
require(global_refs.count(object) == 1, "JNI global reference released more than once");
if (active_handle &&
(active_handle->video == object || active_handle->osd == object || active_handle->osd_option == object)) {
require(active_handle->terminated, "Surface released while still referenced by MPV");
}
global_refs.erase(object);
}
int live_surface_refs() {
int count = 0;
for (const auto& entry : global_refs) {
if (entry.second != &app_context) ++count;
}
return count;
}
void require_pair(mpv_handle* handle, jobject video, jobject osd) {
require_surfaces(handle);
require(global_refs.at(handle->video) == video, "VO retained the wrong video Surface");
require((handle->osd ? global_refs.at(handle->osd) : nullptr) == osd, "VO retained the wrong OSD Surface");
require(handle->osd_option == handle->osd, "OSD option and active consumer disagree");
}
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); }
jint attach_surfaces(jlong session, jobject video, jobject osd) {
return jni_func_name(nativeAttachSurfaces)(&jni, nullptr, session, video, osd);
}
void reset_dependencies() {
std::lock_guard<std::mutex> lock(gate);
require(active_handle == nullptr, "previous test left a live MPV instance");
require(live_surface_refs() == 0, "terminal Surface cleanup leaked a global reference");
handles.clear();
callback_strings.clear();
global_refs.clear();
reference_handles.clear();
fail_ref_after = 0;
option_failures.clear();
consume_osd_before_failure = false;
hold_wid = false;
wid_entered = false;
allow_wid = false;
surface_waiting = false;
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;
require(attach_surfaces(old_session, &video, &osd) == 0, "initial surface handoff failed");
{
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);
require(
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
"revoked surface handoff was admitted during termination");
{
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;
}
require(attach_surfaces(successor_session, &video, &osd) == 0, "successor surface handoff failed");
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);
require(
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
"stale surface handoff reached successor");
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);
}
}
void paired_surface_replacements() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, osd_a, osd_b;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
require_pair(handle, &video_a, &osd_a);
require(attach_surfaces(session, &video_b, &osd_b) == 0, "paired replacement failed");
require_pair(handle, &video_b, &osd_b);
for (jobject next_osd : {static_cast<jobject>(&osd_a), static_cast<jobject>(nullptr), static_cast<jobject>(&osd_b)}) {
const int rebuilds = handle->rebuilds;
require(attach_surfaces(session, &video_b, next_osd) == 0, "OSD-only replacement failed");
require_pair(handle, &video_b, next_osd);
require(handle->rebuilds == rebuilds + 1, "OSD-only change did not rebuild the VO");
require(live_surface_refs() == (next_osd ? 2 : 1), "replacement leaked overwritten Surface references");
}
destroy_player(session);
}
void surface_handoff_failures() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, osd_a, osd_b;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
require(
attach_surfaces(session, nullptr, &osd_b) == MPV_ERROR_INVALID_PARAMETER,
"live session admitted a null video Surface");
for (int allocation : {1, 2}) {
fail_ref_after = allocation;
require(
attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_NOMEM, "global-reference allocation failure was lost");
require_pair(handle, &video_a, &osd_a);
require(live_surface_refs() == 2, "allocation failure leaked a staged Surface");
}
option_failures = {"vo-mediacodec-osd-surface"};
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "OSD option failure was lost");
require_pair(handle, &video_a, &osd_a);
require(live_surface_refs() == 2, "rejected OSD option leaked staged references");
option_failures = {"wid"};
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "wid option failure was lost");
require_pair(handle, &video_a, &osd_a);
require(attach_surfaces(session, &video_b, &osd_b) == 0, "handoff did not recover from wid failure");
require_pair(handle, &video_b, &osd_b);
require(live_surface_refs() == 2, "successful replacement retained failed handoff references");
// A VO may start between the option writes. Rollback changes only the OSD
// option, so the staged reference must survive even when rollback succeeds.
consume_osd_before_failure = true;
for (bool fail_rollback : {false, true}) {
option_failures = {"wid"};
if (fail_rollback) option_failures.push_back("vo-mediacodec-osd-surface");
require(attach_surfaces(session, &video_a, &osd_a) == MPV_ERROR_GENERIC, "failed handoff reported success");
require(option_failures.empty(), "OSD option rollback was not attempted");
require_surfaces(handle);
}
require(attach_surfaces(session, &video_a, nullptr) == 0, "handoff did not recover from rollback failure");
require_pair(handle, &video_a, nullptr);
require(live_surface_refs() == 1, "successful null-OSD replacement leaked failed handoff references");
option_failures = {"wid", "vo-mediacodec-osd-surface"};
require(attach_surfaces(session, &video_b, &osd_b) < 0, "terminal failed handoff reported success");
destroy_player(session);
require(live_surface_refs() == 0, "termination leaked pending failed-handoff references");
}
void overlapping_handoffs_and_teardown() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, video_c, osd_a, osd_b, osd_c;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
hold_wid = true;
jint first_result = MPV_ERROR_GENERIC, second_result = MPV_ERROR_GENERIC;
std::thread first([&] { first_result = attach_surfaces(session, &video_b, &osd_b); });
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return wid_entered; }, "first handoff did not enter synchronous VO rebuild");
require_surfaces(handle);
require(live_surface_refs() == 4, "old Surface references did not survive the wid rebuild");
}
std::thread second([&] {
operation = Operation::surface_handoff;
second_result = attach_surfaces(session, &video_c, &osd_c);
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return surface_waiting; }, "overlapping handoff bypassed surface serialization");
require(live_surface_refs() == 4, "waiting handoff mutated active Surface references");
}
std::thread retiring([&] {
operation = Operation::replacing_reader;
destroy_player(session);
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return reader_draining; }, "teardown did not drain admitted surface handoffs");
require(!handle->termination_entered, "teardown overtook an admitted surface handoff");
allow_wid = true;
changed.notify_all();
}
first.join();
second.join();
retiring.join();
require(first_result == 0 && second_result == 0, "admitted handoff lost its session during teardown");
require(handle->rebuilds == 3, "overlapping handoffs lost a complete VO rebuild");
require(live_surface_refs() == 0, "overlapping replacement/teardown leaked Surface references");
}
} // 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 && operation != Operation::surface_handoff) return pthread_mutex_lock(mutex);
const int result = pthread_mutex_trylock(mutex);
if (result != EBUSY) return result;
{
std::lock_guard<std::mutex> lock(gate);
if (operation == Operation::successor) successor_waiting = true;
if (operation == Operation::surface_handoff) surface_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");
require(live_surface_refs() == 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::unique_lock<std::mutex> lock(gate);
require_live(handle);
require_surfaces(handle);
require(format == MPV_FORMAT_INT64, "unexpected Surface option format");
jobject object = reinterpret_cast<jobject>(static_cast<intptr_t>(*static_cast<int64_t*>(data)));
require(!object || global_refs.count(object) == 1, "option received an invalid JNI reference");
const bool is_wid = std::strcmp(name, "wid") == 0;
if (is_wid && hold_wid) {
wid_entered = true;
changed.notify_all();
await(lock, [] { return allow_wid; }, "test did not release the synchronous wid rebuild");
require_surfaces(handle);
}
if (!option_failures.empty() && option_failures.front() == name) {
option_failures.erase(option_failures.begin());
if (is_wid && consume_osd_before_failure) handle->osd = handle->osd_option;
return MPV_ERROR_GENERIC;
}
if (is_wid) {
// mpv ignores equal option values; changing wid synchronously retires the
// consumers and rebuilds using the OSD option, not the former OSD plane.
if (handle->video != object) {
handle->video = object;
handle->osd = handle->osd_option;
++handle->rebuilds;
}
} else {
require(std::strcmp(name, "vo-mediacodec-osd-surface") == 0, "unexpected Surface option");
handle->osd_option = 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();
paired_surface_replacements();
surface_handoff_failures();
overlapping_handoffs_and_teardown();
reset_dependencies();
std::puts("MPV lifecycle: session isolation, paired handoffs, rollback ownership and overlapping teardown passed");
return 0;
}
@@ -236,8 +236,7 @@ class ExoPlayerPluginTest {
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val exoCore = ExoPlayerCore(activity)
val loads = ConcurrentLinkedQueue<List<String>>()
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
val plugin = ExoPlayerPlugin()
val sink = RecordingEventSink()
plugin.onListen(null, sink)
@@ -274,30 +273,15 @@ 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)
// 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(true, getField(plugin, "usingMpvFallback"))
assertEquals(false, getField(plugin, "fallbackInProgress"))
assertNull(getField(plugin, "pendingOpen"))
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
val disposeResult = RecordingResult()
@@ -659,9 +643,7 @@ class ExoPlayerPluginTest {
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
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.
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
core.setPrivateField("desiredPaused", false)
core.setPrivateField("cachedPaused", false)
core.setPrivateField("resumeBlockedByPublicPause", false)
@@ -682,10 +664,11 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
// 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])
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"))
assertFalse(awaitPauseWriteCount(writes, 1))
core.dispose()
}
@@ -694,10 +677,10 @@ class ExoPlayerPluginTest {
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
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 core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
core.setPrivateField("desiredPaused", true)
core.setPrivateField("cachedPaused", true)
core.setPrivateField("resumeBlockedByPublicPause", true)
val plugin = reusedFallbackPlugin(activity, core)
val result = RecordingResult()
@@ -712,10 +695,9 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
// 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])
assertEquals(false, core.getPrivateField("desiredPaused"))
assertEquals(false, core.getPrivateField("cachedPaused"))
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
assertFalse(awaitPauseWriteCount(writes, 1))
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
core.dispose()
@@ -973,14 +955,6 @@ 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>
@@ -82,65 +82,23 @@ class GpuVoPolicyTest {
@Test
fun `High 10 without a hardware profile is software-decoded up front`() {
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = false, hardwareAv1 = true))
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false, hardwareAv1 = true))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CODEC_SW_DECODE)))
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = false))
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HI10_SW_DECODE)))
}
@Test
fun `hardware supported and unrelated streams retain the configured decoder`() {
fun `Hi10 routing leaves every other stream to the hardware path`() {
// A decoder that advertises the profile gets to try.
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true))
// 8-bit profiles, other codecs, and streams whose container carries no
// profile (Annex B transport streams) are not routed.
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false, hardwareAv1 = false))
}
@Test
fun `AV1 bypasses software MediaCodec even without a reported profile`() {
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = true, hardwareAv1 = false))
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", null, hardwareHigh10 = true, hardwareAv1 = false))
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "", hardwareHigh10 = true, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = false, hardwareAv1 = true))
}
// 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()))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false))
}
@Test
@@ -1,43 +0,0 @@
package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MpvEndFileDiagnosticsTest {
private val diagnostics = MpvEndFileDiagnostics()
@Test
fun `an AO failure ends the file with the audio-output cause and the last error line`() {
diagnostics.onStartFile()
diagnostics.onLogMessage(LogMessage("cplayer", LogLevel.Error, "Audio output stopped responding; stopping playback."))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.AoInitFailed))
assertEquals(
mapOf(
"sourceId" to 7L,
"reason" to EndFileReason.Error.id,
"message" to "Audio output stopped responding; stopping playback.",
"cause" to MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED
),
data
)
}
@Test
fun `other errors carry no cause so Dart keeps its generic handling`() {
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.LoadingFailed))
assertNull(data?.get("cause"))
}
@Test
fun `a clean stop never reports a cause even after an earlier error line`() {
diagnostics.onLogMessage(LogMessage("ao/audiotrack", LogLevel.Error, "AudioTrack.write failed with -32"))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Stop, 7))
assertEquals(mapOf("sourceId" to 7L, "reason" to EndFileReason.Stop.id), data)
}
}
@@ -49,22 +49,6 @@ 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
@@ -774,53 +758,15 @@ class MpvPlayerPluginTest {
}
@Test
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)
fun setLogLevelReportsUnsupported() {
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)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
MpvPlayerPlugin().onMethodCall(
MethodCall("setLogLevel", mapOf("level" to "warn")),
result
)
assertEquals("UNSUPPORTED", result.errorCode)
assertNull(result.successValue)
}
@@ -1,32 +0,0 @@
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))
}
}
+9 -8
View File
@@ -6,13 +6,14 @@
# 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(...);
public static void onEvent(...);
public static void onEndFile(...);
public static void onLogMessage(...);
public static void onHook(...);
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);
}
+20 -38
View File
@@ -7,50 +7,34 @@
#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, jsession, jprop, (jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jprop, (jlong)source_id, jhas_source_id);
break;
case MPV_FORMAT_FLAG:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jsession, jprop, (jboolean) * (int*)prop->data,
(jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jprop, (jboolean) * (int*)prop->data, (jlong)source_id,
jhas_source_id);
break;
case MPV_FORMAT_INT64:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jsession, jprop, (jlong) * (int64_t*)prop->data,
(jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, 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, jsession, jprop, (jdouble) * (double*)prop->data,
(jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, 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, jsession, jprop, jvalue, (jlong)source_id,
jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jprop, jvalue, (jlong)source_id, jhas_source_id);
break;
default:
break;
@@ -63,39 +47,33 @@ 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, (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);
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);
}
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
const int reason = end_file ? end_file->reason : -1;
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
// mpv_error code when reason is MPV_END_FILE_REASON_ERROR, 0 otherwise.
const int error = end_file ? end_file->error : 0;
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
end_file ? JNI_TRUE : JNI_FALSE, (jint)error);
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (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, (jlong)thread_session, jprefix, (jint)msg->log_level, jtext);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, 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(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.
// nativeHookContinue(id); MpvPlayer guarantees that answer for every hook.
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
jstring jname = new_java_string(env, hook->name);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, (jlong)thread_session, jname, (jlong)hook->id);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, jname, (jlong)hook->id);
if (jname) env->DeleteLocalRef(jname);
}
@@ -112,7 +90,7 @@ void* event_thread(void* arg) {
mpv_event_property* mp_property;
mpv_event_log_message* msg;
mp_event = mpv_wait_event(thread_mpv, -1.0);
mp_event = mpv_wait_event(g_mpv, -1.0);
if (g_event_thread_request_exit) break;
@@ -121,6 +99,7 @@ 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:
@@ -134,10 +113,12 @@ 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:
@@ -146,8 +127,9 @@ void* event_thread(void* arg) {
case MPV_EVENT_PLAYBACK_RESTART: {
double position_seconds = 0.0;
const bool has_position_seconds =
mpv_get_property(thread_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
mpv_get_property(g_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;
}
-10
View File
@@ -1,13 +1,3 @@
#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);
-37
View File
@@ -2,46 +2,9 @@
#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;
};
+9 -10
View File
@@ -50,22 +50,21 @@ 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", "(JLjava/lang/String;JZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V");
mpv_MpvPlayer_onPropertyChanged_SZJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;ZJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V");
mpv_MpvPlayer_onPropertyChanged_SJJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V");
mpv_MpvPlayer_onPropertyChanged_SDJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;DJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;DJZ)V");
mpv_MpvPlayer_onPropertyChanged_SSJZ =
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", "(JIJZI)V");
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");
mpv_MpvPlayer_onLogMessage =
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");
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");
methods_initialized = true;
}
+7
View File
@@ -2,8 +2,15 @@
#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);
+56 -148
View File
@@ -6,8 +6,8 @@
#include <clocale>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <mutex>
#include <string>
#include <vector>
@@ -16,7 +16,6 @@ extern "C" {
}
#include "event.h"
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
@@ -25,48 +24,20 @@ extern "C" {
void render_cleanup(JNIEnv* env);
extern "C" {
jni_func(jlong, nativeCreate, jobject appctx);
jni_func(jint, nativeInit, jlong session);
jni_func(void, nativeDestroy, jlong session);
jni_func(void, nativeCreate, jobject appctx);
jni_func(void, nativeInit);
jni_func(void, nativeDestroy);
jni_func(jint, nativeSetLogLevel, jlong session, jstring level);
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray);
jni_func(void, nativeHookContinue, jlong session, jlong id);
jni_func(void, nativeCommand, jobjectArray jarray);
jni_func(void, nativeHookContinue, 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 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 std::mutex g_lifecycle_mutex;
static void prepare_environment(JNIEnv* env, jobject appctx) {
setlocale(LC_NUMERIC, "C");
@@ -79,69 +50,40 @@ static void prepare_environment(JNIEnv* env, jobject appctx) {
init_methods_cache(env);
}
// 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;
}
// 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);
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.
jni_func(void, nativeCreate, jobject appctx) {
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
prepare_environment(env, appctx);
SessionWriteGuard admission;
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);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
mpv_terminate_destroy(leaked_mpv);
render_cleanup(env);
}
g_mpv = mpv_create();
if (!g_mpv) {
die("context init failed");
return 0;
return;
}
g_session = ++next_session;
mpv_request_log_messages(g_mpv, "warn");
return (jlong)g_session;
mpv_request_log_messages(g_mpv, "v");
}
jni_func(jint, nativeInit, jlong session) {
LifecycleGuard lock;
SessionWriteGuard admission;
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
jni_func(void, nativeInit) {
if (!g_mpv) {
die("mpv is not created");
return;
}
const int result = mpv_initialize(g_mpv);
if (result < 0) {
ALOGE("mpv_initialize returned error %s", mpv_error_string(result));
return result;
if (mpv_initialize(g_mpv) < 0) {
die("mpv init failed");
return;
}
// Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land
@@ -151,53 +93,41 @@ jni_func(jint, nativeInit, jlong session) {
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 MPV_ERROR_GENERIC;
return;
}
event_thread_started = true;
pthread_setname_np(event_thread_id, "event_thread");
return 0;
}
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();
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;
}
destroy_locked(env, local_mpv);
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);
}
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;
jni_func(void, nativeCommand, jobjectArray jarray) {
CHECK_MPV_INIT();
const char* arguments[128] = {0};
int len = env->GetArrayLength(jarray);
if (len >= (int)ARRAYLEN(arguments)) {
die("too many command arguments");
return MPV_ERROR_INVALID_PARAMETER;
return;
}
std::vector<std::string> storage;
@@ -209,32 +139,10 @@ jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
env->DeleteLocalRef(jarg);
}
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;
mpv_command(g_mpv, arguments);
}
// 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);
jni_func(void, nativeHookContinue, jlong id) {
if (!g_mpv) return;
mpv_hook_continue(g_mpv, (uint64_t)id);
}
+38 -45
View File
@@ -9,55 +9,49 @@
#include "log.h"
extern "C" {
jni_func(jint, nativeSetOptionString, jlong session, jstring option, jstring value);
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
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(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(void, nativeObserveProperty, jlong session, jstring property, jint format);
jni_func(void, nativeObserveProperty, jstring property, jint format);
}
jni_func(jint, nativeSetOptionString, jlong session, jstring joption, jstring jvalue) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
CHECK_MPV_INIT_RET(0);
const char* option = env->GetStringUTFChars(joption, NULL);
const std::string value = java_string_to_utf8(env, jvalue);
int result = mpv_set_option_string(guard.mpv, option, value.c_str());
int result = mpv_set_option_string(g_mpv, option, value.c_str());
env->ReleaseStringUTFChars(joption, option);
return result;
}
// 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;
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
CHECK_MPV_INIT_RET(-1);
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_get_property(guard.mpv, prop, format, output);
int result = mpv_get_property(g_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, jlong session, jstring jproperty, mpv_format format, void* value) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
CHECK_MPV_INIT_RET(-1);
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_set_property(guard.mpv, prop, format, value);
int result = mpv_set_property(g_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);
@@ -65,58 +59,57 @@ static int common_set_property(JNIEnv* env, jlong session, jstring jproperty, mp
return result;
}
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring jproperty) {
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
int64_t value = 0;
if (common_get_property(env, session, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
}
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring jproperty) {
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
double value = 0;
if (common_get_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
}
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring jproperty) {
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
int value = 0;
if (common_get_property(env, session, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
}
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty) {
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
char* value;
if (common_get_property(env, session, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
jstring jvalue = new_java_string(env, value);
mpv_free(value);
return jvalue;
}
jni_func(void, nativeSetPropertyInt, jlong session, jstring jproperty, jint jvalue) {
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
int64_t value = static_cast<int64_t>(jvalue);
common_set_property(env, session, jproperty, MPV_FORMAT_INT64, &value);
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
}
jni_func(void, nativeSetPropertyDouble, jlong session, jstring jproperty, jdouble jvalue) {
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
double value = static_cast<double>(jvalue);
common_set_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value);
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
}
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring jproperty, jboolean jvalue) {
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
int value = jvalue == JNI_TRUE ? 1 : 0;
common_set_property(env, session, jproperty, MPV_FORMAT_FLAG, &value);
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
}
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue) {
jni_func(void, nativeSetPropertyString, 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, session, jproperty, MPV_FORMAT_STRING, &value_ptr);
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
}
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format) {
SessionGuard guard(session);
if (!guard.mpv) return;
jni_func(void, nativeObserveProperty, jstring property, jint format) {
CHECK_MPV_INIT();
const char* prop = env->GetStringUTFChars(property, NULL);
int result = mpv_observe_property(guard.mpv, 0, prop, (mpv_format)format);
int result = mpv_observe_property(g_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);
}
+55 -75
View File
@@ -1,99 +1,79 @@
#include <jni.h>
#include <mpv/client.h>
#include <new>
#include <vector>
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
extern "C" {
jni_func(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_);
jni_func(void, nativeAttachSurface, jobject surface_);
jni_func(void, nativeDetachSurface);
jni_func(void, nativeAttachOsdSurface, jobject surface_);
jni_func(void, nativeDetachOsdSurface);
};
// Admission (S) precedes this mutex. Teardown drains S before cleanup and
// never takes this mutex, so a waiting handoff cannot outlive its mpv handle.
static pthread_mutex_t surface_lock = PTHREAD_MUTEX_INITIALIZER;
class SurfaceGuard {
public:
SurfaceGuard() { pthread_mutex_lock(&surface_lock); }
~SurfaceGuard() { pthread_mutex_unlock(&surface_lock); }
SurfaceGuard(const SurfaceGuard&) = delete;
SurfaceGuard& operator=(const SurfaceGuard&) = delete;
};
static jobject surface;
static jobject osd_surface;
// A failed wid update can leave an OSD option/consumer using a staged ref.
// Even a successful option rollback cannot retire a consumer created between
// the two writes. Release these only after a successful wid rebuild or destroy.
static std::vector<jobject> pending_osd_surfaces;
jni_func(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
if (!surface_) return MPV_ERROR_INVALID_PARAMETER;
SurfaceGuard lock;
jni_func(void, nativeAttachSurface, jobject surface_) {
CHECK_MPV_INIT();
if (osd_surface_) {
try {
pending_osd_surfaces.reserve(pending_osd_surfaces.size() + 1);
} catch (const std::bad_alloc&) {
return MPV_ERROR_NOMEM;
}
surface = env->NewGlobalRef(surface_);
if (!surface) {
die("invalid surface provided");
return;
}
jobject next_surface = env->NewGlobalRef(surface_);
if (!next_surface) return MPV_ERROR_NOMEM;
jobject next_osd = osd_surface_ ? env->NewGlobalRef(osd_surface_) : nullptr;
if (osd_surface_ && !next_osd) {
env->DeleteGlobalRef(next_surface);
return MPV_ERROR_NOMEM;
}
int64_t osd_wid = reinterpret_cast<intptr_t>(next_osd);
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
if (result < 0) {
env->DeleteGlobalRef(next_surface);
if (next_osd) env->DeleteGlobalRef(next_osd);
return result;
}
if (next_osd) pending_osd_surfaces.push_back(next_osd);
// wid has UPDATE_VO: mpv_set_option synchronously tears down the old VO and
// decoder before rebuilding with both options. A fresh video global ref also
// changes wid when only the Java OSD Surface changed (equal values are ignored).
int64_t wid = reinterpret_cast<intptr_t>(next_surface);
result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) {
osd_wid = reinterpret_cast<intptr_t>(osd_surface);
const int rollback = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
if (rollback < 0) ALOGE("OSD surface rollback failed: %s", mpv_error_string(rollback));
env->DeleteGlobalRef(next_surface);
return result;
}
if (next_osd) pending_osd_surfaces.pop_back();
if (surface) env->DeleteGlobalRef(surface);
if (osd_surface) env->DeleteGlobalRef(osd_surface);
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
pending_osd_surfaces.clear();
surface = next_surface;
osd_surface = next_osd;
return 0;
int64_t wid = reinterpret_cast<intptr_t>(surface);
int result = mpv_set_option(g_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();
int64_t wid = 0;
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
env->DeleteGlobalRef(surface);
surface = NULL;
}
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();
osd_surface = env->NewGlobalRef(surface_);
if (!osd_surface) {
die("invalid osd surface provided");
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);
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;
int64_t wid = 0;
int result = mpv_set_option(g_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);
surface = nullptr;
surface = NULL;
}
if (osd_surface) {
env->DeleteGlobalRef(osd_surface);
osd_surface = nullptr;
osd_surface = NULL;
}
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
pending_osd_surfaces.clear();
}
@@ -1,18 +0,0 @@
package com.edde746.plezy.libmpv
/** The `mpv_error` codes an end-file event can carry (client.h). */
enum class MpvError(val code: Int) {
LoadingFailed(-13),
AoInitFailed(-14),
VoInitFailed(-15),
NothingToPlay(-16),
UnknownFormat(-17),
Unsupported(-18),
NotImplemented(-19),
Generic(-20);
companion object {
/** Null for success (0) and for codes this enum does not model. */
fun fromCode(code: Int): MpvError? = entries.find { it.code == code }
}
}
@@ -6,9 +6,7 @@ sealed interface MpvEvent {
data class StartFile(override val sourceId: Long?) : MpvEvent
data class EndFile(
val reason: EndFileReason?,
override val sourceId: Long?,
/** mpv_error code when [reason] is [EndFileReason.Error]; null otherwise. */
val error: MpvError? = null
override val sourceId: Long?
) : MpvEvent
data class FileLoaded(override val sourceId: Long?) : MpvEvent
data class PlaybackRestart(
@@ -20,19 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
* 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 {
class MpvPlayer private constructor() : AutoCloseable {
companion object {
/** Upper bound for a hook handler; longer stalls playback start. */
@@ -55,76 +43,64 @@ class MpvPlayer private constructor(
configure: MpvPlayerConfig.() -> Unit = {}
): MpvPlayer = withContext(Dispatchers.IO) {
checkNotMainThread("MPV initialization")
// 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
}
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
try {
MpvPlayerConfig(player.session).apply(configure)
val result = nativeInit(player.session)
if (result < 0) throw MpvException("Failed to initialize mpv: error $result")
nativeCreate(context.applicationContext)
MpvPlayerConfig().apply(configure)
nativeInit()
ensureActive()
player
} catch (e: Throwable) {
player.close()
instance.compareAndSet(player, null)
try {
nativeDestroy()
} catch (_: Throwable) {}
throw e
}
}
// 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 }
// JNI callbacks — called from native event thread
@JvmStatic
fun onPropertyChanged(session: Long, name: String, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(session: Long, name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(session: Long, name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(session: Long, name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(session: Long, name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onEvent(
session: Long,
eventId: Int,
sourceId: Long,
hasSourceId: Boolean,
@@ -136,33 +112,31 @@ class MpvPlayer private constructor(
sourceId.takeIf { hasSourceId },
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
) ?: return
target(session)?.rawEvents?.trySend(event)
instance.get()?.rawEvents?.trySend(event)
}
@JvmStatic
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean, error: Int) {
target(session)?.rawEvents?.trySend(
fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawEvents?.trySend(
MpvEvent.EndFile(
EndFileReason.fromId(reason),
sourceId.takeIf { hasSourceId },
MpvError.fromCode(error)
sourceId.takeIf { hasSourceId }
)
)
}
@JvmStatic
fun onLogMessage(session: Long, prefix: String, level: Int, text: String) {
fun onLogMessage(prefix: String, level: Int, text: String) {
val logLevel = LogLevel.fromNative(level) ?: return
target(session)?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
}
@JvmStatic
fun onHook(session: Long, name: String, id: Long) {
val player = target(session)
fun onHook(name: String, id: Long) {
val player = instance.get()
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
// 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)
// Nobody will answer: release mpv rather than leave it waiting.
nativeHookContinue(id)
}
}
@@ -172,56 +146,47 @@ class MpvPlayer private constructor(
}
}
// 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.
// JNI native declarations — private to avoid internal name mangling
/** Retires any leaked native session and returns the new session's identity. */
@JvmStatic private external fun nativeCreate(appctx: Context): Long
@JvmStatic private external fun nativeCreate(appctx: Context)
/** 0 on success, otherwise a negative mpv error. */
@JvmStatic private external fun nativeInit(session: Long): Int
@JvmStatic private external fun nativeInit()
@JvmStatic private external fun nativeDestroy(session: Long)
@JvmStatic private external fun nativeDestroy()
/** 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 nativeCommand(cmd: Array<out String>)
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
@JvmStatic private external fun nativeHookContinue(id: Long)
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
@JvmStatic private external fun nativeAttachSurfaces(session: Long, surface: Surface, osdSurface: Surface?): Int
@JvmStatic private external fun nativeDetachSurface()
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
@JvmStatic private external fun nativeDetachOsdSurface()
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
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")
}
}
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
}
// The native event thread hands everything to unbounded channels: trySend
@@ -257,7 +222,7 @@ class MpvPlayer private constructor(
pumpScope.launch {
for (hook in rawHooks) {
try {
val handler = if (closed) null else hookHandler
val handler = hookHandler
if (handler != null) {
withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) }
?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback")
@@ -265,10 +230,7 @@ class MpvPlayer private constructor(
} catch (e: Exception) {
android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e)
} finally {
// 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)
if (!closed) nativeHookContinue(hook.id)
}
}
}
@@ -282,88 +244,89 @@ class MpvPlayer private constructor(
// Commands
/**
* 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? {
suspend fun command(vararg args: String) {
checkNotClosed()
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
withContext(Dispatchers.IO) { nativeCommand(args) }
}
/** Called on the core's ordered IO writer, without suspending between writes. */
fun setLogLevel(level: String) {
// Surface — not suspend, called from SurfaceHolder.Callback
fun attachSurface(surface: Surface) {
checkNotClosed()
requestLogMessages(session, level)
nativeAttachSurface(surface)
}
/** Installs both planes and synchronously rebuilds the VO on the core's ordered IO writer. */
fun attachSurfaces(surface: Surface, osdSurface: Surface?) {
fun detachSurface() {
checkNotClosed()
checkNotMainThread("MPV surface handoff")
val result = nativeAttachSurfaces(session, surface, osdSurface)
if (result < 0) throw MpvException("Failed to attach MPV surfaces: error $result")
nativeDetachSurface()
}
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
fun attachOsdSurface(surface: Surface) {
checkNotClosed()
nativeAttachOsdSurface(surface)
}
fun detachOsdSurface() {
checkNotClosed()
nativeDetachOsdSurface()
}
// Property getters
suspend fun getInt(name: String): Int? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyInt(session, name) }
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
}
suspend fun getDouble(name: String): Double? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(session, name) }
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
}
suspend fun getFlag(name: String): Boolean? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(session, name) }
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
}
suspend fun getString(name: String): String? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyString(session, name) }
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
}
// Property setters
suspend fun setProperty(name: String, value: Int) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyInt(session, name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
}
suspend fun setProperty(name: String, value: Double) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyDouble(session, name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
}
suspend fun setProperty(name: String, value: Boolean) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(session, name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
}
suspend fun setProperty(name: String, value: String) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyString(session, name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
}
// Property observation
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
checkNotClosed()
nativeObserveProperty(session, name, format.nativeValue)
nativeObserveProperty(name, format.nativeValue)
return propertyFlow.filter { it.name == name }
}
fun observeFlag(name: String): Flow<Boolean> {
checkNotClosed()
nativeObserveProperty(session, name, PropertyFormat.Flag.nativeValue)
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Flag>()
.filter { it.name == name }
@@ -372,7 +335,7 @@ class MpvPlayer private constructor(
fun observeInt(name: String): Flow<Long> {
checkNotClosed()
nativeObserveProperty(session, name, PropertyFormat.Int64.nativeValue)
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Int64>()
.filter { it.name == name }
@@ -381,7 +344,7 @@ class MpvPlayer private constructor(
fun observeDouble(name: String): Flow<Double> {
checkNotClosed()
nativeObserveProperty(session, name, PropertyFormat.Double.nativeValue)
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Double>()
.filter { it.name == name }
@@ -390,7 +353,7 @@ class MpvPlayer private constructor(
fun observeString(name: String): Flow<String> {
checkNotClosed()
nativeObserveProperty(session, name, PropertyFormat.String.nativeValue)
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Str>()
.filter { it.name == name }
@@ -407,18 +370,16 @@ class MpvPlayer private constructor(
* Android main thread so a slow vendor decoder cannot stall the UI.
*/
override fun close() {
synchronized(instance) {
if (closed) return
checkNotMainThread("MPV destruction")
closed = true
hookHandler = null
instance.compareAndSet(this, null)
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()
}
// 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.
// After nativeDestroy no callback can produce: closing the channels
// lets each pump drain what is already queued and then complete.
rawEvents.close()
rawHooks.close()
rawPropertyChanges.close()
@@ -1,15 +1,10 @@
package com.edde746.plezy.libmpv
/** Pre-initialize configuration of one native session; every write names it. */
class MpvPlayerConfig internal constructor(private val session: Long) {
class MpvPlayerConfig internal constructor() {
fun setOption(name: String, value: String) {
val result = MpvPlayer.setOptionString(session, name, value)
val result = MpvPlayer.setOptionString(name, value)
if (result < 0) {
throw MpvException("Failed to set option '$name' to '$value': error $result")
}
}
fun setLogLevel(level: String) {
MpvPlayer.requestLogMessages(session, level)
}
}
+6
View File
@@ -1,20 +1,26 @@
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
+1 -1
View File
@@ -802,7 +802,7 @@
repositoryURL = "https://github.com/edde746/mpv-build";
requirement = {
kind = revision;
revision = 6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee;
revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
}
},
{
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
}
},
{
+1 -1
View File
@@ -15,7 +15,7 @@ platform :ios do
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store BUGS_APPLE_ARCHIVE=build/ios/archive/Runner.xcarchive ./scripts/upload-symbols.sh ios")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
upload_to_app_store(
ipa: "../build/ios/ipa/Plezy.ipa",
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Which event of a physical Back press performs the action.
///
/// Verified delivery model (Flutter 3.47.1 engine sources, stage 0 of the focus
/// consolidation):
///
/// * Android: `FlutterView.dispatchKeyEvent` claims every key and only
/// redispatches it to the Activity — where `onKeyUp(KEYCODE_BACK)` becomes
/// `onBackPressed` → `popRoute` — when the framework reports the event
/// **unhandled**. A Back key the focus system handles therefore never
/// produces a system pop; key-first ordering is structural, not timing.
/// * tvOS (custom engine): Menu arrives as an engine-synthesized `escape`
/// press. The KeyUp is not delivered reliably, so the action runs on KeyDown.
/// * Desktop: `escape` / `browserBack` are plain key presses. There is no
/// system back.
/// * Handheld gestures (iOS swipe, Android 13+) arrive only as `popRoute` and
/// never as key events.
///
/// The one remaining way a single press can act twice is entirely inside the
/// framework: an action that runs on KeyDown (Apple TV, a TV text field whose
/// IME would swallow the KeyUp) moves focus or pops a route, and the KeyUp is
/// then delivered to a *different* focus chain. [BackPressGate] closes that by
/// only acting on a KeyUp whose KeyDown it saw itself.
enum BackPhase { keyDown, keyUp }
/// The phase on which a Back handler acts for [event], unless the owner has a
/// reason of its own (a TV text field under a native IME passes
/// [BackPhase.keyDown] explicitly).
BackPhase backPhaseFor(KeyEvent event) => PlatformDetector.isAppleTV() ? BackPhase.keyDown : BackPhase.keyUp;
/// Per-owner state for a Back key press: acts once per physical press, on the
/// configured phase, and never on a KeyUp whose KeyDown it did not see.
///
/// Owners are the widgets that consume Back *before* the route stack — a text
/// field dismissing its IME, a sheet closing a nested page, the player hiding
/// its content strip, a screen moving focus from content to the sidebar. Each
/// owner holds one gate; it costs two booleans. Everything that is not such an
/// owner lets the press bubble to [NavigatorBackHandler], which turns it into
/// `Navigator.maybePop` so the route's `PopScope` is the single place a screen
/// decides its back policy for both key and system back.
///
/// Results:
/// * KeyDown → `handled`, press armed; the action runs now when the phase is
/// [BackPhase.keyDown].
/// * KeyRepeat → `handled` while armed, otherwise `ignored`.
/// * KeyUp of an armed press → `handled`; the action runs now when the phase is
/// [BackPhase.keyUp].
/// * KeyUp without an armed press (the press started on another focus chain)
/// → `ignored`, so it bubbles to the navigator handler, which swallows it.
/// Returning `handled` here would be wrong for a press the owner declined.
/// * A KeyDown while still armed (the previous KeyUp was swallowed off-app,
/// e.g. by a closing TV IME) is a fresh press and re-arms.
class BackPressGate {
bool _armed = false;
bool _fired = false;
/// Whether a press is in flight between its KeyDown and KeyUp.
bool get isArmed => _armed;
KeyEventResult handle(KeyEvent event, VoidCallback onBack, {BackPhase? phase}) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
final effectivePhase = phase ?? backPhaseFor(event);
if (event is KeyDownEvent) {
_armed = true;
_fired = effectivePhase == BackPhase.keyDown;
if (_fired) onBack();
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return _armed ? KeyEventResult.handled : KeyEventResult.ignored;
}
if (event is KeyUpEvent) {
if (!_armed) return KeyEventResult.ignored;
_armed = false;
final fired = _fired;
_fired = false;
if (!fired) onBack();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Forget an in-flight press. Call when the owner leaves the focus chain
/// mid-press and must not act on the eventual KeyUp.
void reset() {
_armed = false;
_fired = false;
}
}
/// A non-focusable [Focus] that consumes Back for its subtree with its own
/// [BackPressGate], for owners that are not already a `State` holding a gate:
/// a sheet sub-page returning to its parent, a screen region whose Back moves
/// focus rather than popping the route.
///
/// A route's own back policy does not belong here — put it in `PopScope`, where
/// [NavigatorBackHandler] delivers key back and the platform delivers system
/// back.
class BackKeyOwner extends StatefulWidget {
const BackKeyOwner({super.key, required this.onBack, this.phase, required this.child});
final VoidCallback onBack;
/// Overrides [backPhaseFor] for this owner.
final BackPhase? phase;
final Widget child;
@override
State<BackKeyOwner> createState() => _BackKeyOwnerState();
}
class _BackKeyOwnerState extends State<BackKeyOwner> {
final BackPressGate _gate = BackPressGate();
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
return _gate.handle(event, widget.onBack, phase: widget.phase);
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'BackKeyOwner',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+5 -69
View File
@@ -71,10 +71,9 @@ extension DpadKeyExtension on LogicalKeyboardKey {
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
/// Base class for suppressing key events after a key category triggers an
/// action (e.g. opening a sheet). While suppressed, every event of the matched
/// key category is consumed — including a [KeyDownEvent] when
/// [consumeKeyDowns] is set, because an armer running in the
/// Suppresses SELECT key events after a select press already acted (e.g. a
/// long press opened a sheet). While suppressed, every select event is
/// consumed — including a [KeyDownEvent], because an armer running in the
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
/// very KeyDown that is then re-dispatched through the focus tree.
///
@@ -84,19 +83,10 @@ extension DpadKeyExtension on LogicalKeyboardKey {
/// focus-phase consumers still swallow it — which guarantees a stale armed
/// state (a KeyUp delivered to a focus chain that never consulted us) can
/// never swallow the next press.
///
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
/// matching [KeyDownEvent] as proof the suppressed press already ended without
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
/// state is cleared and the fresh press passes through unconsumed.
class _KeyUpSuppressor {
final bool Function(LogicalKeyboardKey) _keyMatcher;
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
/// documentation for why armers targeting an in-flight KeyUp must unset it.
final bool consumeKeyDowns;
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
_KeyUpSuppressor(this._keyMatcher);
bool _suppressed = false;
@@ -125,18 +115,11 @@ class _KeyUpSuppressor {
void clearSuppression() => _suppressed = false;
/// Returns `true` (consumed) when the event belongs to the matched key
/// category and suppression is active. Clears suppression on [KeyUpEvent],
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
/// category and suppression is active. Clears suppression on [KeyUpEvent].
bool consumeIfSuppressed(KeyEvent event) {
if (!_suppressed) return false;
if (!_keyMatcher(event.logicalKey)) return false;
if (event is KeyUpEvent) _suppressed = false;
if (event is KeyDownEvent && !consumeKeyDowns) {
// The in-flight KeyUp never arrived; this is a fresh press, not the
// suppressed one. Clear and let it through.
_suppressed = false;
return false;
}
return true;
}
}
@@ -149,50 +132,3 @@ class SelectKeyUpSuppressor {
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Global helper to suppress the next BACK key-up event.
///
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
/// down-only back handler moves focus before the matching KeyUp is dispatched.
/// A matching KeyDown while armed clears the arming instead of consuming: it
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
/// fresh press must act normally.
class BackKeyUpSuppressor {
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
static void suppressBackUntilKeyUp() => _instance.suppress();
/// Clear any pending suppression. Call when opening a new modal
/// to ensure stale suppression from previous closes doesn't affect it.
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Tracks whether a back key is currently physically pressed.
///
/// Used by [BackKeySuppressorObserver] to detect when a route pop was
/// caused by a back key press (e.g. Flutter's built-in DismissAction,
/// DismissAction on KeyRepeat, or Android TV system back gesture) so it
/// can automatically suppress the stray KeyUp that follows.
class BackKeyPressTracker {
static bool _isBackKeyDown = false;
/// Whether a back key is currently held down.
///
/// Also checks [HardwareKeyboard.instance.logicalKeysPressed] as a
/// fallback in case our tracking drifted out of sync.
static bool get isBackKeyDown {
if (_isBackKeyDown) return true;
return HardwareKeyboard.instance.logicalKeysPressed.any((key) => key.isBackKey);
}
static bool handleKeyEvent(KeyEvent event) {
if (event.logicalKey.isBackKey) {
// KeyDown and KeyRepeat both mean the key is physically held.
_isBackKeyDown = event is! KeyUpEvent;
}
return false; // Never consume
}
}
+5 -18
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../widgets/overlay_sheet.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
/// inside a sheet or dialog.
@@ -41,7 +40,7 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
int? _originalIndex;
List<E>? _originalOrder;
bool _backKeyDownSeen = false;
final BackPressGate _backGate = BackPressGate();
/// The list being reordered. Mutated in place while moving and replaced
/// wholesale when a move is cancelled.
@@ -87,21 +86,9 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Track back key down/up pairing. If focus was elsewhere during KeyDown
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
// KeyUp that would incorrectly pop the dialog. Consume it instead.
if (key.isBackKey) {
if (event is KeyDownEvent) {
_backKeyDownSeen = true;
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
_backKeyDownSeen = false;
}
}
final backResult = handleBackKeyAction(event, () {
// The gate ignores a Back release whose press landed elsewhere (focus was
// on a sheet during KeyDown and returned here before KeyUp).
final backResult = _backGate.handle(event, () {
if (movingIndex != null) {
setState(() {
final originalOrder = _originalOrder;
+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../widgets/app_icon.dart';
import '../widgets/clickable_cursor.dart';
import 'back_press.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
@@ -98,6 +99,8 @@ class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _focusStates;
bool _hasAnyFocus = false;
// One gate for the row: every button shares [FocusableActionBar.onBack].
final _backGate = BackPressGate();
FocusNode? getFocusNode(int index) => index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
@@ -320,7 +323,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
final previousIndex = _previousEnabledIndex(index);
+4 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/scroll_utils.dart';
import 'back_press.dart';
import 'owned_focus_node_binding.dart';
import 'input_mode_tracker.dart';
import 'dpad_navigator.dart';
@@ -44,6 +45,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding();
bool _isFocused = false;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
FocusNode? _boundExternalNode;
/// Override to return the widget's optional external focus node.
@@ -90,6 +92,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
setState(() => _isFocused = hasFocus);
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Same convention as FocusableTileStateMixin: a chip inside a
// scrollable strip (TabChipStrip, filter bars) reveals itself on
@@ -121,7 +124,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final key = event.logicalKey;
if (callbacks.onBack != null) {
final backResult = handleBackKeyAction(event, callbacks.onBack!);
final backResult = _backGate.handle(event, callbacks.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
+57 -217
View File
@@ -1,7 +1,5 @@
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';
@@ -9,6 +7,7 @@ import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart';
@@ -173,6 +172,7 @@ KeyEventResult _handleInputKey({
int? maxLines,
VoidCallback? onSelect,
VoidCallback? onBack,
required BackPressGate backGate,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
@@ -234,38 +234,13 @@ 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
// down-only shape as [handleBackKeyAction]'s Apple TV branch, coordinator
// mark included so a parallel back dispatch still dedupes. Elsewhere the
// shared handler's KeyUp semantics apply.
if (PlatformDetector.isTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
// onBack may move focus (empty search field -> sidebar); the matching
// KeyUp is then delivered to the NEW focus chain, whose shared
// handlers act on KeyUp — a second back action. Arm the suppressor
// (after onBack, so a modal opened by it cannot clear the arming) so
// whichever chain receives the KeyUp swallows it. This cannot pin:
// the suppressor's hardware observer clears the armed state once the
// physical press ends, and if the IME swallows that KeyUp entirely,
// the next back KeyDown is treated as stale arming and passes
// through — see _KeyUpSuppressor.
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
return finish(KeyEventResult.handled, 'onBack');
}
final backResult = handleBackKeyAction(event, onBack);
// closing IME session eats it), so back fires on KeyDown. onBack may move
// focus (empty search field -> sidebar); the KeyUp is then delivered to the
// new focus chain, where no gate has seen its KeyDown and the navigator
// handler swallows it. Elsewhere the shared KeyUp phase applies.
final backResult = backGate.handle(event, onBack, phase: PlatformDetector.isTV() ? BackPhase.keyDown : null);
if (backResult != KeyEventResult.ignored) return finish(backResult, 'onBack');
}
@@ -749,6 +724,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
VoidCallback openKeyboard, {
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
required BackPressGate backGate,
}) {
return _handleInputKey(
controller: controller,
@@ -769,6 +745,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
maxLines: maxLines,
onSelect: onSelect,
onBack: onBack,
backGate: backGate,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
@@ -801,32 +778,11 @@ class _FocusableTextInputHost extends StatefulWidget {
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
}
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 {
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
final BackPressGate _backGate = BackPressGate();
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
late final VoidCallback _focusListener = _handleFocusChanged;
final Object _nativeFocusToken = Object();
@@ -837,13 +793,10 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
_NativeTextInputSession? _nativeTextInputSession;
int _nativeTextInputGeneration = 0;
ui.FlutterView? _nativeTextInputView;
bool _nativeTextInputActivated = false;
bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false;
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
bool _nativeTextInputCompletionHandled = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -854,40 +807,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
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);
@@ -904,17 +823,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false;
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
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);
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
_nativeTextInputActivated = false;
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
@@ -926,7 +841,6 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
@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).
@@ -968,12 +882,11 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
final scope = node.enclosingScope;
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
final generation = _nativeTextInputGeneration;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || generation != _nativeTextInputGeneration) return;
if (!mounted) return;
final target = _installedFocusNode;
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
if (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.
@@ -987,7 +900,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
if (!focused) {
_suppressNativeTextInputForCurrentFocus = false;
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
_setNativeTextInputActivated(false);
return;
}
if (_suppressNativeTextInputForCurrentFocus) return;
@@ -1003,109 +916,47 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
case TvTextInputAutoOpenBehavior.automatic:
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
_hasSeenNativeTextInputFocus = true;
_beginNativeTextInput();
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenNativeTextInputFocus) {
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = true;
return;
}
_beginNativeTextInput();
_setNativeTextInputActivated(true);
case TvTextInputAutoOpenBehavior.never:
return;
}
}
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++;
}
void _setNativeTextInputActivated(bool activated) {
if (_nativeTextInputActivated == activated) return;
if (activated) _nativeTextInputCompletionHandled = false;
if (!mounted) {
_nativeTextInputActivated = activated;
return;
}
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();
});
setState(() => _nativeTextInputActivated = activated);
}
void _activateNativeTextInput() {
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = false;
_beginNativeTextInput();
_setNativeTextInputActivated(true);
}
VoidCallback? get _effectiveOnEditingComplete {
final input = widget.input;
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
final session = _nativeTextInputSession;
return () => _handleNativeEditingComplete(session);
return _handleNativeEditingComplete;
}
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);
void _handleNativeEditingComplete() {
if (_nativeTextInputCompletionHandled) return;
_nativeTextInputCompletionHandled = true;
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
final input = widget.input;
final callback = input._effectiveOnEditingComplete;
@@ -1275,8 +1126,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
/// 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) {
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
}
// No-op for the Flutter presentation when no overlay is up: setting its
@@ -1296,20 +1148,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
_suppressNativeTextInputForCurrentFocus = true;
_tvKeyboardOpenScheduled = false;
if (widget.input._usesNativeTvKeyboard) {
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
_setNativeTextInputActivated(false);
}
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
final generation = _nativeTextInputGeneration;
focusNode.requestFocus();
scheduleMicrotask(() {
if (!mounted ||
generation != _nativeTextInputGeneration ||
!identical(focusNode, _installedFocusNode) ||
focusNode.hasFocus ||
_tvKeyboardOpen ||
_tvKeyboardOpenScheduled) {
return;
}
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_suppressTvKeyboardAutoOpen = false;
_suppressNativeTextInputForCurrentFocus = false;
});
@@ -1327,17 +1171,11 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
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 &&
generation == _nativeTextInputGeneration &&
identical(focusNode, _installedFocusNode) &&
focusNode.hasFocus) {
_openTextInputForFocusedField();
}
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
});
}
@@ -1387,9 +1225,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_endNativeTextInput(
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
);
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
@@ -1397,28 +1234,30 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
_scheduleNativeTextInputReactivation();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
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 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();
// ran out): close the session and consume the press so it cannot also
// pop the route underneath.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
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).
_endNativeTextInput(_NativeTextInputEndReason.restart);
_scheduleNativeTextInputReactivation();
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
return KeyEventResult.handled;
}
// Android arrows fall through deliberately: a healthy visible IME
@@ -1434,6 +1273,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
_openTvKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: _activateNativeTextInput,
backGate: _backGate,
);
}
@@ -1459,7 +1299,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with W
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
_nativeTextInputActivated = false;
_suppressNativeTextInputForCurrentFocus = false;
final node = _installedFocusNode;
if (node != null) {
+5 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart';
import 'focus_chrome.dart';
@@ -245,6 +246,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
Animation<double>? _scaleAnimation;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
@override
void initState() {
@@ -307,9 +309,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_isFocused = hasFocus;
});
// Reset long press state when focus is lost
// Reset press state when focus is lost
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Animate scale
@@ -448,7 +451,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return finish(backResult, 'onBack');
}
+27 -20
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -60,18 +58,10 @@ class InputModeTracker extends StatefulWidget {
/// Keyboard/D-pad on TV, pointer everywhere else.
static InputMode get _defaultMode => PlatformDetector.isTV() ? InputMode.keyboard : InputMode.pointer;
/// Whether system back must be blocked because the dpad key handler owns
/// back navigation: on Android in keyboard mode (TV/gamepad), letting the
/// system back through as well double-pops the route.
static bool shouldBlockSystemBack(BuildContext context) {
return Platform.isAndroid && isKeyboardMode(context);
}
/// Report input from a device that cannot point — gamepad, companion remote,
/// Siri Remote touch. Those services synthesize their key events through
/// [KeyEventSimulatorController], which dispatches straight down the focus
/// chain and never reaches [HardwareKeyboard], so they cannot be observed by
/// [eventRequestsFocusNavigation] and must announce themselves here.
/// Report input from a device that cannot point and does not produce key
/// events — a gamepad shoulder or a companion-remote tab command. Key-shaped
/// input from those devices goes through [KeyEventSimulatorController], which
/// runs the framework key pipeline and is observed here like any hardware key.
///
/// Calls `setState`, so never call this during build. A no-op when no tracker
/// is mounted.
@@ -89,6 +79,9 @@ class InputModeTracker extends StatefulWidget {
class _InputModeTrackerState extends State<InputModeTracker> {
InputMode _mode = InputModeTracker._defaultMode;
/// Whether the in-flight Back press found a primary focus on KeyDown.
bool _backDownHadFocus = false;
@override
void initState() {
super.initState();
@@ -109,18 +102,32 @@ class _InputModeTrackerState extends State<InputModeTracker> {
}
bool _handleKeyEvent(KeyEvent event) {
// Track back key press state for automatic suppression of stray KeyUp
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only a key that asks to navigate by focus starts a keyboard session.
// Activation and dismissal act on what is already focused, so promoting on
// them would arm focus chrome for a viewer who never asked to navigate.
if (eventRequestsFocusNavigation(event)) {
_setMode(InputMode.keyboard);
}
// Return false to let the event continue propagating
return false;
return _consumeBackReleaseWithoutFocus(event);
}
/// A Back KeyDown acted on a focus chain that has since been torn down, so
/// its KeyUp arrives with no primary focus at all. The focus system skips
/// dispatch and reports it unhandled; on Android the embedding then
/// redispatches it and `onBackPressed` → `popRoute` performs a second,
/// system-driven back. Claim it here in the hardware phase instead. A press
/// that already had no focus on KeyDown is left alone: neither event was
/// acted on, and the platform's own back is the correct outcome.
bool _consumeBackReleaseWithoutFocus(KeyEvent event) {
if (!event.logicalKey.isBackKey) return false;
if (event is KeyDownEvent) {
_backDownHadFocus = FocusManager.instance.primaryFocus != null;
return false;
}
if (event is! KeyUpEvent) return false;
final hadFocus = _backDownHadFocus;
_backDownHadFocus = false;
return hadFocus && FocusManager.instance.primaryFocus == null;
}
void _setMode(InputMode mode) {
+3 -131
View File
@@ -1,118 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
///
/// Optionally pass a [result] to return to the previous route.
///
/// Use this as an `onKeyEvent` callback for Focus widgets that need
/// simple back navigation behavior:
///
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
/// child: ...
/// )
/// ```
///
/// With a result value:
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
/// context,
/// event,
/// result: _hasChanges,
/// ),
/// child: ...
/// )
/// ```
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (!_handledThisFrame) return false;
clear();
return true;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Check if this BACK event should be suppressed (e.g., after modal closed)
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// No suppressor arming is needed here: onBack typically calls
// Navigator.pop, swapping the focus tree before the matching KeyUp is
// dispatched — but the orphaned KeyUp lands on the new chain, where this
// same branch consumes it silently.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
onBack();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
// Let system handle back if there's nothing to pop (e.g. root screen)
if (!Navigator.canPop(context)) {
return KeyEventResult.ignored;
}
// Don't handle back when a dialog/overlay is on top of our route —
// the overlay handles its own dismissal via DismissAction.
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
// Back key handling lives in back_press.dart (BackPressGate for owners that
// consume Back before the route) and navigator_back_handler.dart (the
// per-navigator fallback that turns Back into Navigator.maybePop).
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
@@ -234,24 +127,3 @@ FocusOnKeyEventCallback dpadKeyHandler({
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed. (The atomic engine fix delivers
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
// true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
/// Turns a Back key press that no focused owner consumed into
/// `Navigator.maybePop` on [navigatorKey], so key back and system back both end
/// in the route's `PopScope`.
///
/// Wrap each [Navigator] (root and profile session) once. The wrapper sits in
/// the focus chain above every route of that navigator, so it sees only the
/// presses that bubbled out of the focused subtree. It decides on KeyDown, from
/// the route's synchronous [ModalRoute.popDisposition], whether this navigator
/// owns the press:
///
/// * `pop` / `doNotPop` — a route below or a `PopScope(canPop: false)`: the
/// press is consumed and `maybePop` runs on the configured [BackPhase].
/// * `bubble` — nothing to pop and no `PopScope`: the press is declined, both
/// KeyDown and KeyUp bubble on. On Android an unhandled Back becomes the
/// platform's own `onBackPressed` → `popRoute` → `SystemNavigator.pop`, which
/// is the correct "leave the app" behavior at the true root.
///
/// A KeyUp or KeyRepeat that arrives with no armed press — the press acted on
/// another focus chain before focus moved here — is swallowed. Letting it reach
/// the platform unhandled would synthesize a second, system-driven back.
class NavigatorBackHandler extends StatefulWidget {
const NavigatorBackHandler({super.key, required this.navigatorKey, required this.child});
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
@override
State<NavigatorBackHandler> createState() => _NavigatorBackHandlerState();
}
/// What this navigator does with the Back press whose KeyDown just arrived.
enum _Claim {
/// Consume the press and run `maybePop` on the configured phase.
pop,
/// Consume the press and do nothing: the focused route is not the current
/// one. A route was just pushed and the new route's autofocus has not been
/// applied yet (FocusManager applies it in a microtask), so the old chain
/// still receives keys; `maybePop` would pop the route that was just pushed.
swallow,
/// Leave both events to the platform: nothing to pop and no `PopScope`.
decline,
}
class _NavigatorBackHandlerState extends State<NavigatorBackHandler> {
final BackPressGate _gate = BackPressGate();
bool _declined = false;
/// The route of this navigator that contains the primary focus, walking out
/// of nested navigators. Null when the focus is not inside any route of this
/// navigator (e.g. chrome mounted beside the navigator).
ModalRoute<Object?>? _routeForPrimaryFocus() {
final navigator = widget.navigatorKey.currentState;
if (navigator == null) return null;
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return null;
var route = ModalRoute.of(context);
while (route != null && !identical(route.navigator, navigator)) {
final navigatorContext = route.navigator?.context;
if (navigatorContext == null) return null;
route = ModalRoute.of(navigatorContext);
}
return route;
}
_Claim _claimForPrimaryFocus() {
final route = _routeForPrimaryFocus();
if (route == null) return _Claim.decline;
if (!route.isCurrent) return _Claim.swallow;
return route.popDisposition == RoutePopDisposition.bubble ? _Claim.decline : _Claim.pop;
}
void _pop() {
unawaited(widget.navigatorKey.currentState?.maybePop());
}
static void _noop() {}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
switch (_claimForPrimaryFocus()) {
case _Claim.decline:
_declined = true;
_gate.reset();
return KeyEventResult.ignored;
case _Claim.swallow:
_declined = false;
return _gate.handle(event, _noop, phase: BackPhase.keyDown);
case _Claim.pop:
_declined = false;
return _gate.handle(event, _pop);
}
}
if (_declined) {
if (event is KeyUpEvent) _declined = false;
return KeyEventResult.ignored;
}
// An armed press acts (or, already acted on KeyDown, is finished); an
// unarmed KeyUp/KeyRepeat acted on another focus chain and is swallowed.
final result = _gate.handle(event, _pop);
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'NavigatorBackHandler',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
"playbackCancelled": "Oynatma ləğv edildi.",
"playbackFailed": "Oynatma başladılarkən xəta.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Təşkilatçı seansı bitirdi"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
},
"services": {
"title": "Xidmətlər",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
"playbackCancelled": "Възпроизвеждането беше отменено.",
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
@@ -1662,8 +1660,7 @@
"timedOut": "Релейният сървър не отговори навреме",
"connectionLost": "Връзката се затвори, преди сесията да е готова",
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
"sessionEnded": "Организаторът прекрати сесията",
"sessionUnavailable": ""
"sessionEnded": "Организаторът прекрати сесията"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
"noUserInformation": "Seerr не върна информация за потребителя",
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
},
"services": {
"title": "Услуги",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
"playbackCancelled": "Afspilningen blev annulleret.",
"playbackFailed": "Afspilningen kunne ikke startes.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Værten afsluttede sessionen"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
},
"services": {
"title": "Tjenester",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Der Host hat die Sitzung beendet"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
},
"services": {
"title": "Dienste",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "The server returned invalid playback information.",
"playbackCancelled": "Playback was canceled.",
"playbackFailed": "Playback could not be started.",
"audioOutputFailed": "The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.",
"mediaUnavailable": "This content is no longer available.",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"musicNotSupported": "Music playback is not yet supported",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
"sessionEnded": "The host ended the session"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
"permissionRevoked": "You no longer have permission to request this"
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
},
"services": {
"title": "Services",
+2 -7
View File
@@ -779,8 +779,6 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "El anfitrión finalizó la sesión"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
},
"services": {
"title": "Servicios",
+2 -7
View File
@@ -779,8 +779,6 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "L’hôte a mis fin à la session"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
},
"services": {
"title": "Services",
+2 -7
View File
@@ -779,8 +779,6 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "A házigazda befejezte a munkamenetet"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
},
"services": {
"title": "Szolgáltatások",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
"playbackCancelled": "Riproduzione annullata.",
"playbackFailed": "Impossibile avviare la riproduzione.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "L’host ha terminato la sessione"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
},
"services": {
"title": "Servizi",
+2 -7
View File
@@ -775,8 +775,6 @@
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
"playbackCancelled": "再生がキャンセルされました。",
"playbackFailed": "再生を開始できませんでした。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません",
@@ -1651,8 +1649,7 @@
"timedOut": "リレーサーバーが時間内に応答しませんでした",
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
"sessionEnded": "ホストがセッションを終了しました",
"sessionUnavailable": ""
"sessionEnded": "ホストがセッションを終了しました"
}
},
"downloads": {
@@ -2108,9 +2105,7 @@
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
},
"services": {
"title": "サービス",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
"playbackCancelled": "Ойнатудан бас тартылды.",
"playbackFailed": "Ойнатуды іске қосу қатесі.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
"errorLoadingSeries": "Сериалды жүктеу қатесі",
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
@@ -1662,8 +1660,7 @@
"timedOut": "Реле сервері уақытында жауап бермеді",
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
"sessionUnavailable": ""
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
},
"services": {
"title": "Қызметтер",
+2 -7
View File
@@ -775,8 +775,6 @@
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
"playbackCancelled": "재생이 취소되었습니다.",
"playbackFailed": "재생을 시작할 수 없습니다.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원",
@@ -1651,8 +1649,7 @@
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
"sessionEnded": "호스트가 세션을 종료했습니다",
"sessionUnavailable": ""
"sessionEnded": "호스트가 세션을 종료했습니다"
}
},
"downloads": {
@@ -2108,9 +2105,7 @@
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
},
"services": {
"title": "서비스",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
"playbackCancelled": "Avspillingen ble avbrutt.",
"playbackFailed": "Kunne ikke starte avspillingen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Verten avsluttet økten"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
},
"services": {
"title": "Tjenester",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.",
"playbackCancelled": "Het afspelen is geannuleerd.",
"playbackFailed": "Het afspelen kon niet worden gestart.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}",
"errorLoadingSeries": "Fout bij laden van serie",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "De host heeft de sessie beëindigd"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd"
},
"services": {
"title": "Diensten",
+2 -7
View File
@@ -787,8 +787,6 @@
"playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.",
"playbackCancelled": "Odtwarzanie zostało anulowane.",
"playbackFailed": "Nie udało się rozpocząć odtwarzania.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
@@ -1684,8 +1682,7 @@
"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ę",
"sessionUnavailable": ""
"sessionEnded": "Gospodarz zakończył sesję"
}
},
"downloads": {
@@ -2141,9 +2138,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu"
},
"services": {
"title": "Usługi",
+2 -7
View File
@@ -779,8 +779,6 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "O anfitrião encerrou a sessão"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente"
},
"services": {
"title": "Serviços",
+2 -7
View File
@@ -787,8 +787,6 @@
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
"playbackCancelled": "Воспроизведение отменено.",
"playbackFailed": "Не удалось начать воспроизведение.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
@@ -1684,8 +1682,7 @@
"timedOut": "Сервер ретрансляции не ответил вовремя",
"connectionLost": "Соединение закрылось до того, как сессия была готова",
"invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ",
"sessionEnded": "Организатор завершил сессию",
"sessionUnavailable": ""
"sessionEnded": "Организатор завершил сессию"
}
},
"downloads": {
@@ -2141,9 +2138,7 @@
"noSessionCookie": "Seerr не выдал cookie-файл сеанса",
"freshCookieRejected": "Seerr отклонил новый cookie-файл сеанса",
"noUserInformation": "Seerr не вернул сведения о пользователе",
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа"
},
"services": {
"title": "Сервисы",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 43688 (1985 per locale)
/// Strings: 43683 (1985 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+4 -24
View File
@@ -2272,12 +2272,6 @@ class Translations$messages$en {
/// en: 'Playback could not be started.'
String get playbackFailed => 'Playback could not be started.';
/// en: 'The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.'
String get audioOutputFailed => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.';
/// 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}';
@@ -5341,12 +5335,6 @@ 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
@@ -6571,9 +6559,6 @@ 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
@@ -7752,8 +7737,6 @@ extension on Translations {
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
'messages.playbackCancelled' => 'Playback was canceled.',
'messages.playbackFailed' => 'Playback could not be started.',
'messages.audioOutputFailed' => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.',
'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',
@@ -8048,10 +8031,10 @@ extension on Translations {
'libraries.noFoldersFound' => 'No folders found',
'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
_ => null,
} ?? switch (path) {
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
'libraries.groupings.all' => 'All',
@@ -8528,7 +8511,6 @@ 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',
@@ -8562,11 +8544,11 @@ extension on Translations {
'downloads.downloadingTooltip' => 'Downloading...',
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
'downloads.noDownloadsTree' => 'No downloads',
_ => null,
} ?? switch (path) {
'downloads.pauseAll' => 'Pause all',
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
_ => null,
} ?? switch (path) {
'downloads.selectVersion' => 'Select Version',
'downloads.allEpisodes' => 'All episodes',
'downloads.unwatchedOnly' => 'Unwatched only',
@@ -8954,8 +8936,6 @@ 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',
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.",
"playbackCancelled": "Uppspelningen avbröts.",
"playbackFailed": "Det gick inte att starta uppspelningen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Värden avslutade sessionen"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"noSessionCookie": "Seerr skapade ingen sessionscookie",
"freshCookieRejected": "Seerr avvisade den nya sessionscookien",
"noUserInformation": "Seerr returnerade ingen användarinformation",
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen"
},
"services": {
"title": "Tjänster",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.",
"playbackCancelled": "Oynatma iptal edildi.",
"playbackFailed": "Oynatma başlatılamadı.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}",
"errorLoadingSeries": "Dizi yüklenirken hata",
"musicNotSupported": "Müzik oynatma henüz desteklenmiyor",
@@ -1662,8 +1660,7 @@
"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ı",
"sessionUnavailable": ""
"sessionEnded": "Kurucu oturumu sonlandırdı"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi"
},
"services": {
"title": "Servisler",
+2 -7
View File
@@ -779,8 +779,6 @@
"playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.",
"playbackCancelled": "Ijro bekor qilindi.",
"playbackFailed": "Ijroni ishga tushirishda xatolik.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}",
"errorLoadingSeries": "Serialni yuklashda xatolik",
"musicNotSupported": "Musiqa ijrosi hali qoʻllab-quvvatlanmaydi",
@@ -1662,8 +1660,7 @@
"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",
"sessionUnavailable": ""
"sessionEnded": "Tashkilotchi seansni tugatdi"
}
},
"downloads": {
@@ -2119,9 +2116,7 @@
"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",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi"
},
"services": {
"title": "Xizmatlar",
+2 -7
View File
@@ -775,8 +775,6 @@
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "無法開始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
"errorLoadingSeries": "載入影集時發生錯誤",
"musicNotSupported": "目前不支援播放音樂",
@@ -1651,8 +1649,7 @@
"timedOut": "中繼伺服器未及時回應",
"connectionLost": "工作階段就緒前連線已中斷",
"invalidRelayResponse": "中繼伺服器傳回了非預期的回應",
"sessionEnded": "主持人已結束工作階段",
"sessionUnavailable": ""
"sessionEnded": "主持人已結束工作階段"
}
},
"downloads": {
@@ -2108,9 +2105,7 @@
"noSessionCookie": "Seerr 未發出工作階段 Cookie",
"freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie",
"noUserInformation": "Seerr 未傳回使用者資訊",
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕"
},
"services": {
"title": "外部服務",
+2 -7
View File
@@ -775,8 +775,6 @@
"playbackDataInvalid": "服务器返回了无效的播放信息。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "无法开始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
"errorLoadingSeries": "加载剧集时出错",
"musicNotSupported": "尚不支持播放音乐",
@@ -1651,8 +1649,7 @@
"timedOut": "中继服务器未及时响应",
"connectionLost": "会话准备就绪前连接已断开",
"invalidRelayResponse": "中继服务器返回了意外的响应",
"sessionEnded": "主持人已结束会话",
"sessionUnavailable": ""
"sessionEnded": "主持人已结束会话"
}
},
"downloads": {
@@ -2108,9 +2105,7 @@
"noSessionCookie": "Seerr 未签发会话 Cookie",
"freshCookieRejected": "Seerr 拒绝了新的会话 Cookie",
"noUserInformation": "Seerr 未返回用户信息",
"sessionRejectedAfterReauth": "重新登录后会话被拒绝",
"permissionDenied": "",
"permissionRevoked": ""
"sessionRejectedAfterReauth": "重新登录后会话被拒绝"
},
"services": {
"title": "服务",
+13 -30
View File
@@ -49,7 +49,6 @@ import 'package:path_provider/path_provider.dart';
import 'services/image_cache_service.dart';
import 'services/gamepad_service.dart';
import 'services/trackers/tracker_coordinator.dart';
import 'services/playback_coordinator.dart';
import 'providers/account_preferences_controller.dart';
import 'services/account_preferences_repository.dart';
import 'providers/multi_server_provider.dart';
@@ -84,7 +83,7 @@ import 'i18n/app_locale_utils.dart';
import 'i18n/strings.g.dart';
import 'widgets/app_icon.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'focus/navigator_back_handler.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'utils/navigation_transitions.dart';
import 'utils/log_redaction_manager.dart';
@@ -1250,7 +1249,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
bool _isAutoDeleteRunning = false;
bool _lastConnectivityWasWifi = false;
bool _lastConnectivityHadNetwork = true;
Future<void>? _shutdownFuture;
bool _shutdownStarted = false;
/// Last time server health probes ran from a resume event (cooldown for desktop)
DateTime _lastResumeProbe = DateTime(0);
@@ -1303,14 +1302,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
);
}
Future<void> _shutdownForExit() => _shutdownFuture ??= _runShutdownForExit().timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Application exit teardown exceeded its deadline');
},
);
Future<void> _shutdownForExit() async {
if (_shutdownStarted) return;
_shutdownStarted = true;
Future<void> _runShutdownForExit() async {
// Hide the window before anything else so the exit reads as an instant
// close: the teardown below runs against a still-mounted tree and its
// state churn must never be user-visible. Cmd+Q and OS-initiated exits
@@ -1325,14 +1320,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
}
}
// The player owns the backend session. Flush it while its client and
// native position still exist, before unrelated cleanup can delay exit.
try {
await PlaybackCoordinator.instance.shutdownVideo().timeout(const Duration(seconds: 3));
} catch (e, st) {
appLogger.w('Video shutdown did not complete before exit', error: e, stackTrace: st);
}
_syncDebounce?.cancel();
await _watchStateSubscription?.cancel();
_removeConnectivitySyncListener();
@@ -1343,11 +1330,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Quitting straight from the player is a real stop: the trackers that own
// their own watched semantics need the terminal report before the process
// goes away. Bounded — a hung tracker must not hold the app open.
try {
await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3));
} catch (e, st) {
appLogger.w('Tracker shutdown did not complete before exit', error: e, stackTrace: st);
}
await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3), onTimeout: () {});
TrackerCoordinator.instance.cancelInFlight();
await _serverManager.shutdown();
@@ -1366,7 +1349,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_removeConnectivitySyncListener();
_memoryCheckTimer?.cancel();
_appLifecycleListener.dispose();
if (_shutdownFuture == null) {
if (!_shutdownStarted) {
_libraryEventService.dispose();
_downloadManager.dispose();
_serverManager.dispose();
@@ -1819,23 +1802,23 @@ class _AppShell extends StatelessWidget {
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorKey: rootNavigatorKey,
navigatorObservers: [BackKeySuppressorObserver()],
home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
// Siri Remote select + gamepad A report as
// LogicalKeyboardKey.{select,gameButtonA} which aren't
// in Flutter's default shortcut set — Material-level
// widgets (menu items, showModalBottomSheet actions)
// ignore them. Map both to ActivateIntent so tapping
// select on tvOS activates the focused widget.
// select on tvOS activates the focused widget. Back keys
// need no DismissIntent binding: NavigatorBackHandler
// turns them into maybePop before they reach here.
shortcuts: <ShortcutActivator, Intent>{
...WidgetsApp.defaultShortcuts,
const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(),
const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
const SingleActivator(LogicalKeyboardKey.goBack): const DismissIntent(),
const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(),
const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
},
builder: (context, child) => rootShell(child),
builder: (context, child) => rootShell(
NavigatorBackHandler(navigatorKey: rootNavigatorKey, child: child ?? const SizedBox.shrink()),
),
),
),
);
+4 -4
View File
@@ -13,7 +13,7 @@ class AccountPreferenceTarget {
required this.ref,
required this.label,
this.subtitle,
this.isDefaultConnection = false,
this.isActiveProfileAccount = false,
});
final AccountRef ref;
@@ -24,9 +24,9 @@ class AccountPreferenceTarget {
/// Secondary row label: user · URL (MediaBrowser) or Home user (Plex).
final String? subtitle;
/// Whether the profile designated this connection as its picker default.
/// Presentation only: this does not establish playback authority.
final bool isDefaultConnection;
/// Whether this is the account the active profile is currently browsing
/// with. Used to order the picker; not a permission.
final bool isActiveProfileAccount;
MediaBackend get backend => ref.backend;
}
+1 -8
View File
@@ -16,8 +16,7 @@ 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,
/// which consumers must read as [targetsWholeServer] — never as "nothing".
/// MediaBrowser collection-folder ids). Empty when the backend did not say.
final Set<String> libraryIds;
/// Backend-native ids of removed items, when the backend named them (Plex
@@ -42,12 +41,6 @@ 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, '
+2 -16
View File
@@ -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 a token query fallback. Plezy therefore drives both through one
/// and `api_key=` 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,16 +76,8 @@ 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 (`?ApiKey=` on Jellyfin, `?api_key=` on Emby,
/// `ForceKeepAlive`/`KeepAlive`,
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
String get webSocketPath => switch (this) {
@@ -125,12 +117,6 @@ enum MediaBrowserDialect {
/// leading codec must be one the dialect is known to emit.
bool get rotatesDisabledTranscodeCodecs => this == MediaBrowserDialect.jellyfin;
/// Restrict Live TV HLS negotiation to MPEG-TS. Emby fMP4 live streams
/// fail with missing initialization metadata in affected setups (#2273),
/// while the same sources play as TS. VOD keeps fMP4; this is a transport
/// compatibility policy, not a restriction on direct play or stream copy.
bool get requiresMpegTsForLiveTv => this == MediaBrowserDialect.emby;
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
/// route resolves to audio streaming with `Lyrics` as the container and
/// spawns an ffmpeg process that fails with a 500.
+12 -57
View File
@@ -2,42 +2,15 @@ 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. Library id and title
/// follow [_libraryPair].
/// existing context wins when the backend omits it.
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: library.libraryId,
libraryTitle: library.libraryTitle,
libraryId: fetched.libraryId ?? existing?.libraryId,
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
);
}
@@ -67,44 +40,26 @@ int compareLibraryCopies(MediaItem a, MediaItem b) {
return a.globalKey.compareTo(b.globalKey);
}
/// Fold a re-resolved copy into the one already known for its
/// [MediaItem.globalKey].
/// Fold a re-resolved copy into the one already on screen.
///
/// 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
/// 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) {
/// version list behind the resolution hint is treated the same way.
MediaItem _mergeCopy(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: library.libraryId,
libraryTitle: library.libraryTitle,
serverName: serverName,
mediaVersions: mediaVersions,
libraryId: addition.libraryId ?? existing.libraryId,
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
serverName: addition.serverName ?? existing.serverName,
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
);
}
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
/// with [compareLibraryCopies]. A key on both sides is folded by
/// [mergeLibraryCopy].
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
///
/// 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
@@ -114,7 +69,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 : mergeLibraryCopy(existing, item);
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
}
return byKey.values.toList()..sort(compareLibraryCopies);
}
+10 -25
View File
@@ -114,11 +114,6 @@ 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();
@@ -627,9 +622,9 @@ abstract class MediaServerClient {
///
/// Every entry of [titles] is searched, and the union of their id-verified
/// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`)
/// 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
/// 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
/// 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
@@ -649,14 +644,12 @@ abstract class MediaServerClient {
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
/// on a guess.
///
/// 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(
/// 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(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -692,15 +685,7 @@ 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.
/// 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,
});
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
/// Build a scrub preview source for [item] using [mediaSource]. Plex
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
-13
View File
@@ -15,10 +15,6 @@ 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;
@@ -40,8 +36,6 @@ class MediaSourceInfo {
this.partId,
this.displayCriteria,
this.mediaSourceId,
this.mediaIndex,
this.partIndex,
this.defaultAudioStreamIndex,
this.defaultSubtitleStreamIndex,
this.trickplayByWidth,
@@ -60,8 +54,6 @@ class MediaSourceInfo {
partId: partId,
displayCriteria: displayCriteria,
mediaSourceId: mediaSourceId,
mediaIndex: mediaIndex,
partIndex: partIndex,
defaultAudioStreamIndex: defaultAudioStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
trickplayByWidth: trickplayByWidth,
@@ -116,9 +108,6 @@ 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({
@@ -131,7 +120,6 @@ class MediaAudioTrack with _TrackLabelMixin {
this.displayTitle,
this.channels,
required this.selected,
this.isDefault = false,
this.external = false,
});
@@ -149,7 +137,6 @@ class MediaAudioTrack with _TrackLabelMixin {
displayTitle: displayTitle,
channels: channels,
selected: selected,
isDefault: isDefault,
external: external,
);
}
+29 -122
View File
@@ -1,66 +1,27 @@
import 'package:flutter/material.dart';
/// 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.
/// 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.
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;
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item', String? debugLabel}) {
return gridItemFocusNodes.putIfAbsent(index, () => _GridFocusNode(debugLabel: debugLabel ?? '${prefix}_$index'));
/// 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'));
}
/// [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;
/// 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}) {
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
}
@@ -71,88 +32,39 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
}
}
/// 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;
}
/// Whether the last-focused index is still valid for restoration.
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) {
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
_gridItemIdentities.remove(key);
gridItemFocusNodes[key]?.dispose();
gridItemFocusNodes.remove(key);
}
if (lastFocusedGridIndex != null && lastFocusedGridIndex! >= itemCount) lastFocusedGridIndex = null;
}
/// Evict detached nodes far from the viewport, never live attachments or
/// remembered/pending destinations. The budget is soft while those survive.
/// Evict focus nodes far from [centerIndex], keeping at most [keepCount] around it.
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 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 gridItemFocusNodes.keys) {
if (key < keepStart || key > keepEnd) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
_gridItemIdentities.remove(key);
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();
}
}
@@ -160,11 +72,6 @@ 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();
}
}
+18 -5
View File
@@ -1,15 +1,28 @@
import 'package:flutter/material.dart';
import 'grid_focus_node_mixin.dart';
/// 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);
/// 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;
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) {
+1 -7
View File
@@ -424,13 +424,7 @@ class CatalogItem {
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
endDate: detail.endDate ?? endDate,
originalTitle: detail.originalTitle ?? originalTitle,
// 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(),
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
tagline: detail.tagline ?? tagline,
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
format: detail.format ?? format,
-7
View File
@@ -55,13 +55,6 @@ sealed class PlayerError with _$PlayerError {
/// copy from this tag instead of parsing it.
static const String playerInitFailed = 'player-init-failed';
/// Cause tag for an audio device that stopped taking audio (or never
/// could) after the native core's own bounded recovery. A device fault, not
/// a stream fault: no stream retry, quality change, or backend switch can
/// recover it, so it is terminal on live TV too. Keep in sync with
/// MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED on Android.
static const String audioOutputFailed = 'audio-output-failed';
/// HTTP status [logText] reports, or null when it names none.
///
/// A [PlayerError] carries no status field: mpv only ever tells us the
-4
View File
@@ -74,10 +74,6 @@ 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,
+7 -43
View File
@@ -6,7 +6,6 @@ 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';
@@ -239,8 +238,6 @@ 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;
@@ -262,19 +259,11 @@ 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, 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.
// 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.
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) {
@@ -370,25 +359,17 @@ 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<int?> open(
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
if (_nativeCoreUnavailable) return null;
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
if (_nativeCoreUnavailable) return null;
if (_nativeCoreUnavailable) return;
// `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.
@@ -459,11 +440,7 @@ class PlayerNative extends PlayerBase {
loadfileArgs.addAll(['-1', loadfileOption]);
}
if (audioOnly) _expectOpenFileLoad = true;
// 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'];
await command(loadfileArgs);
// mpv's pause property survives loadfile; in-place reloads pause the old
// file before resolving, so explicitly unpause for the replacement. Set
@@ -472,7 +449,6 @@ class PlayerNative extends PlayerBase {
if (play) {
await setProperty('pause', 'no');
}
return playlistEntryId is int ? playlistEntryId : null;
}
@override
@@ -909,18 +885,6 @@ 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});
}
+14 -15
View File
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import '../connection/connection_registry.dart';
import '../focus/covered_route_focus_boundary.dart';
import '../focus/key_event_utils.dart';
import '../focus/navigator_back_handler.dart';
import '../media/ids.dart';
import '../media/media_server_client.dart';
import '../profiles/active_profile_provider.dart';
@@ -209,11 +209,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
create: (context) => ExploreProvider(context.read<CatalogSourcesProvider>()),
lazy: true,
),
Provider(
create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()),
dispose: (_, matcher) => matcher.dispose(),
lazy: true,
),
Provider(create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()), lazy: true),
ChangeNotifierProvider(
create: (context) =>
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
@@ -362,15 +358,18 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
child: SettingsShortcut(
navigatorKey: _navigatorKey,
settingsRoutes: _settingsRouteTracker,
child: Stack(
children: [
Navigator(
key: _navigatorKey,
observers: [_routeObserver, _musicRouteObserver, _settingsRouteTracker, BackKeySuppressorObserver()],
onGenerateRoute: _onGenerateRoute,
),
const Positioned.fill(child: MusicMiniPlayerOverlay()),
],
child: NavigatorBackHandler(
navigatorKey: _navigatorKey,
child: Stack(
children: [
Navigator(
key: _navigatorKey,
observers: [_routeObserver, _musicRouteObserver, _settingsRouteTracker],
onGenerateRoute: _onGenerateRoute,
),
const Positioned.fill(child: MusicMiniPlayerOverlay()),
],
),
),
),
),
+6 -4
View File
@@ -16,7 +16,7 @@ class ActivePlexToken {
final String token;
}
/// Resolve the active profile's effective Plex credential for cloud callers.
/// THE resolver for the active profile's effective Plex credential.
///
/// One policy: the profile's per-user token (the `ProfileConnection` row
/// bound to the identity's account, minted via `/home/users/{uuid}/switch`)
@@ -24,9 +24,11 @@ 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. 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.
/// 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.
Future<ActivePlexToken?> resolveActivePlexToken({
required ActiveProfileProvider activeProfile,
required ConnectionRegistry connections,
+41 -109
View File
@@ -64,8 +64,6 @@ 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
@@ -102,11 +100,7 @@ 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 {
while (!isDisposed) {
final generation = _generation;
await _activeProfile?.awaitBindingSettle();
if (generation == _generation) break;
}
await _activeProfile?.awaitBindingSettle();
// Every watcher emission after attach supersedes the previous resolve;
// waiting for the chain to go quiet is what makes the cached check below
// meaningful.
@@ -131,18 +125,12 @@ 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();
});
@@ -171,29 +159,15 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
final active = _activeProfile;
if (active == null) return;
final id = active.activeId;
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;
}
if (id == _lastSeenActiveProfileId) return;
_lastSeenActiveProfileId = id;
_resetScope();
_watchActiveProfile(active.active);
_resolve();
}
void _resetScope() {
_generation++;
// Another user's values must not survive the switch, not even for the
// duration of the next fetch.
_activeRef = null;
_accounts = const [];
repository.clear();
safeNotifyListeners();
_watchActiveProfile(active.active);
_resolve();
}
void _watchActiveProfile(Profile? profile) {
@@ -207,7 +181,6 @@ 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();
});
@@ -218,24 +191,23 @@ 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 (profile != null && (connectionRows == null || profileRows == null)) {
if (connectionRows == null || profileRows == null) {
_resolveTask = (_firstRows ??= Completer<void>()).future;
return;
}
AccountPreferenceResolution resolved;
List<AccountPreferenceAccount> resolved;
try {
resolved = resolveAccountPreferenceAccounts(
profile: profile,
profileConnections: profileRows ?? const [],
connections: connectionRows ?? const [],
profileConnections: profileRows,
connections: connectionRows,
);
} catch (error, stackTrace) {
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
resolved = const AccountPreferenceResolution();
resolved = const [];
}
final firstRows = _firstRows;
_firstRows = null;
@@ -243,52 +215,30 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
firstRows?.complete();
}
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);
Future<void> _applyAccounts(List<AccountPreferenceAccount> resolved) async {
final changed = !_sameAccounts(_accounts, resolved);
if (changed) {
_accounts = resolved;
safeNotifyListeners();
}
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;
// Sorted best-first, so the head is the active profile's own account.
final ref = resolved.isEmpty ? null : resolved.first.ref;
final refChanged = ref != _activeRef;
_accounts = accounts;
_activeRef = 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;
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);
}
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 || generation != _generation || accountGeneration != _accountGeneration || ref != _activeRef) return;
if (isDisposed || ref != _activeRef) return;
try {
await repository.load(ref, forceRefresh: true);
} on AccountPreferencesUnavailableException {
@@ -302,27 +252,21 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
}
}
Future<AccountPreferenceResolution> _readAccounts() async {
final generation = _generation;
Future<List<AccountPreferenceAccount>> _readAccounts() async {
final connections = _connections;
final profileConnections = _profileConnections;
final profile = _activeProfile?.active;
if (connections == null || profileConnections == null || profile == null) {
return const AccountPreferenceResolution();
}
if (connections == null || profileConnections == null || profile == null) return const [];
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: profileRows,
connections: connectionRows,
profileConnections: await profileConnections.listForProfile(profile.id),
connections: await connections.list(),
);
} catch (error, stackTrace) {
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
return const AccountPreferenceResolution();
return const [];
}
}
@@ -330,8 +274,7 @@ 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 || !_sameCredentials(a[i], b[i])) return false;
if (a[i].target.isDefaultConnection != b[i].target.isDefaultConnection) return false;
if (a[i].target.subtitle != b[i].target.subtitle || a[i].plexToken != b[i].plexToken) return false;
}
return true;
}
@@ -339,7 +282,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()).accounts) {
for (final account in await _readAccounts()) {
if (account.ref.connectionId == connectionId) return account;
}
return null;
@@ -349,41 +292,30 @@ 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 = selected.plexToken;
final token = await _resolvePlexToken(ref);
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();
+1 -3
View File
@@ -982,9 +982,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
if (changed) safeNotifyListeners();
if (event.origin == DeletionOrigin.local) {
unawaited(refreshContinueWatching());
}
unawaited(refreshContinueWatching());
}
void _onHiddenLibrariesChanged() {
+10 -21
View File
@@ -9,7 +9,6 @@ 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';
@@ -97,37 +96,27 @@ 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 (library.serverId != event.serverId.value) continue;
if (!coversServer && !event.libraryIds.contains(library.id)) continue;
if (!eventTargetsLibrary(event, library)) 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; 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.
/// 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.
bool eventTargetsLibrary(LibraryChangeEvent event, MediaLibrary library) {
if (library.serverId == null || library.serverId != event.serverId.value) return false;
return event.libraryIds.contains(library.id) || _eventCoversServer(event);
if (event.libraryIds.isEmpty) return true;
if (event.libraryIds.contains(library.id)) return true;
return !_serverHasLibraryIn(event.serverId.value, event.libraryIds);
}
/// 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;
bool _serverHasLibraryIn(String serverId, Set<String> libraryIds) {
for (final library in _libraries) {
if (library.serverId == serverId && libraryIds.contains(library.id)) return true;
}
return false;
}
+16 -32
View File
@@ -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 '../services/trackers/future_coalescer.dart';
import '../utils/serial_future_queue.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,7 +49,12 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
final SeerrAuthService authService;
SeerrPlexTokenSupplier? _plexTokenSupplier;
final FutureCoalescer<void> _userRefresh = FutureCoalescer();
/// 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);
void _logPersistenceFailure(Object e) => appLogger.w('Seerr: session persistence failed', error: e);
@@ -62,13 +67,6 @@ 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;
@@ -93,11 +91,9 @@ 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);
if (_isCurrentBinding(userUuid, generation)) unawaited(refreshUser());
unawaited(_refreshUser(userUuid, generation));
}
/// A stored session's permissions and display name date from sign-in;
@@ -106,10 +102,8 @@ 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() => _userRefresh.run(_refreshUser);
Future<void> _refreshUser() async {
if (isDisposed) return;
Future<void> _refreshUser(String userUuid, int generation) async {
if (!_isCurrentBinding(userUuid, generation)) return;
final client = _catalogClient;
if (client == null) return;
try {
@@ -121,32 +115,22 @@ 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;
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);
await _enqueuePersistence(() => _store.save(userUuid, session));
_setSessionAndRebind(userUuid, ++_bindingGeneration, session);
}
/// Sign out server-side (best effort) and clear local state.
Future<void> disconnect() async {
if (isDisposed) return;
final userUuid = _activeUserUuid;
final session = _session;
final generation = ++_bindingGeneration;
_setSessionAndRebind(userUuid, generation, null);
if (!_isCurrentBinding(userUuid, generation)) return;
await _store.clear(userUuid);
_setSessionAndRebind(userUuid, ++_bindingGeneration, null);
await _enqueuePersistence(() => _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
@@ -171,7 +155,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
void _handleSessionUpdated(String userUuid, int generation, SeerrSession session) {
if (!_isCurrentBinding(userUuid, generation)) return;
_session = session;
unawaited(_store.save(userUuid, session).catchError(_logPersistenceFailure));
unawaited(_enqueuePersistence(() => _store.save(userUuid, session)).catchError(_logPersistenceFailure));
safeNotifyListeners();
}
@@ -180,7 +164,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
void _handleSessionInvalidated(String userUuid, int generation) {
if (!_isCurrentBinding(userUuid, generation)) return;
final nextGeneration = ++_bindingGeneration;
unawaited(_store.clear(userUuid).catchError(_logPersistenceFailure));
unawaited(_enqueuePersistence(() => _store.clear(userUuid)).catchError(_logPersistenceFailure));
_setSessionAndRebind(userUuid, nextGeneration, null);
}
+46 -55
View File
@@ -22,7 +22,6 @@ import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../media/media_backend.dart';
import '../media/media_browser_dialect.dart';
import '../navigation/profile_session_screen.dart';
@@ -260,65 +259,57 @@ class _AuthScreenState extends State<AuthScreen> {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [_buildAuthBody()],
),
return Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(mainAxisSize: .min, crossAxisAlignment: .stretch, children: [_buildAuthBody()]),
),
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
),
),
),
),
);
+229 -277
View File
@@ -12,8 +12,6 @@ import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/locked_hub_controller.dart';
import '../i18n/app_locale_utils.dart';
import '../i18n/strings.g.dart';
@@ -27,8 +25,6 @@ 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';
@@ -95,6 +91,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
List<FocusNode> _libraryMatchFocusNodes = const [];
CatalogSource? _watchlistSource;
SeerrCatalogSource? _requestSource;
bool _mutatingWatchlist = false;
CatalogItem? _detailItem;
@@ -102,10 +99,11 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
/// Library items matching this catalog item; null while resolving.
List<MediaItem>? _matches;
/// 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;
/// 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 = {};
final Set<String> _uncheckedServerIds = {};
/// Cast/characters from the item's own source; null while loading (the
@@ -129,6 +127,16 @@ 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);
@@ -167,32 +175,22 @@ 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);
if (!mounted) return;
if (generation == _resolutionGeneration) {
_resolvingMatches = false;
_uncheckedServerIds
..clear()
..addAll(context.read<MultiServerProvider>().expectedServerIds);
}
_mergeMatches(const []);
// 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 []);
return;
}
if (!mounted) return;
if (generation == _resolutionGeneration) {
_resolvingMatches = false;
_uncheckedServerIds
..clear()
..addAll(result.failedServerIds)
..addAll(result.cancelledServerIds)
..addAll(result.unqueriedServerIds);
}
_checkedServerIds.addAll(result.succeededServerIds);
_uncheckedServerIds
..addAll(result.failedServerIds)
..addAll(result.cancelledServerIds)
..removeAll(_checkedServerIds);
_mergeMatches(result.items);
}
@@ -319,24 +317,8 @@ 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 [_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).
/// arrive with the detail fetch (see initState), so read the enriched item.
bool get _canRequest => _requestSource != null && _item.ids.tmdb != null;
bool get _hasActions => _watchlistSource != null || _canRequest || _hasTrailer;
@@ -676,7 +658,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: mutedColor);
final matches = _matches;
if (matches == null || (matches.isEmpty && _resolvingMatches)) {
if (matches == null) {
return Row(
children: [
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
@@ -1294,21 +1276,12 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
);
}
void _handleSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final item = _item;
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
@@ -1318,236 +1291,215 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final isMobile = PlatformDetector.isMobile(context);
final viewInsets = MediaQuery.paddingOf(context);
final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context);
// Match the established detail-screen back policy: TV/keyboard back is
// owned by the focus tree, while native mobile back and iOS swipe-back
// remain route-driven. The overlay host always gets first refusal.
// The host owns route back for key and system back alike: a back with a
// sheet open closes it; otherwise the route pops. iOS keeps canPop for the
// interactive swipe. Nothing here handles Back keys — unconsumed presses
// reach the navigator, which runs the host's PopScope.
return OverlaySheetHost(
canPop: !blockSystemBack,
onSystemBack: _handleSystemBack,
canPop: PlatformDetector.isHandheldIOS(context),
onBack: () => Navigator.pop(context),
child: Builder(
builder: (hostContext) => Focus(
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
child: Scaffold(
body: Stack(
children: [
SingleChildScrollView(
key: const Key('catalog_detail_scroll'),
controller: _scrollController,
// The backdrop lives inside the scrollable so it moves with
// the content (it extends under the status bar, so the safe
// areas are baked into the content padding instead of a
// SafeArea around the scroll view).
child: Stack(
children: [
if (backdropUrl != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 320,
child: ShaderMask(
shaderCallback: (rect) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
stops: const [0.3, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: OptimizedMediaImage.thumb(
imagePath: backdropUrl,
width: double.infinity,
height: 320,
fit: BoxFit.cover,
fallbackIcon: null,
),
),
),
// 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),
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
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 (_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 (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),
],
],
builder: (hostContext) => Scaffold(
body: Stack(
children: [
SingleChildScrollView(
key: const Key('catalog_detail_scroll'),
controller: _scrollController,
// The backdrop lives inside the scrollable so it moves with
// the content (it extends under the status bar, so the safe
// areas are baked into the content padding instead of a
// SafeArea around the scroll view).
child: Stack(
children: [
if (backdropUrl != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 320,
child: ShaderMask(
shaderCallback: (rect) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
stops: const [0.3, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: OptimizedMediaImage.thumb(
imagePath: backdropUrl,
width: double.infinity,
height: 320,
fit: BoxFit.cover,
fallbackIcon: null,
),
),
),
],
),
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),
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
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 (_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 (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),
],
],
),
),
],
),
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
context: hostContext,
)!,
),
],
),
),
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
context: hostContext,
)!,
),
],
),
),
),
+104 -119
View File
@@ -13,6 +13,7 @@ import '../focus/hub_vertical_navigation.dart';
import '../focus/locked_hub_controller.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
@@ -78,6 +79,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab, WidgetsBindingObserver {
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200);
final _backGate = BackPressGate();
/// Data + refresh policy live in [DiscoverProvider]; this state keeps only
/// UI concerns (hero carousel, focus, spotlight). The proxy getters keep
@@ -108,9 +110,6 @@ 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 [];
@@ -258,7 +257,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
_pendingTvBrowseRailFocus = true;
_railClaimFocusOrigin = FocusManager.instance.primaryFocus;
if (immediate && _tvBrowseHubs.isNotEmpty) {
final rail = _tvBrowseRailKey.currentState;
if (rail != null) {
@@ -270,7 +268,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false) || _focusHasLeftScreen) {
_pendingTvBrowseRailFocus = false;
return;
}
@@ -284,18 +282,12 @@ 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: 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 {
/// 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 {
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);
@@ -303,7 +295,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
void _applyPendingTvBrowseRailFocus() {
if (!_pendingTvBrowseRailFocus) return;
if (_railClaimAbandoned) {
if (_focusHasLeftScreen) {
_pendingTvBrowseRailFocus = false;
return;
}
@@ -422,7 +414,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Handle key events for the hero section.
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
final backResult = handleBackKeyAction(event, _navigateToSidebar);
final backResult = _backGate.handle(event, _navigateToSidebar);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
@@ -1413,119 +1405,112 @@ class _DiscoverScreenState extends State<DiscoverScreen>
: isLargeScreen
? 200
: 0,
// 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,
),
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,
),
textAlign: alignLeft ? TextAlign.left : TextAlign.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,
),
],
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
),
],
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
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,
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,
),
],
),
],
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
),
] 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)],
],
),
),
),
+35 -37
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/grid_focus_node_mixin.dart';
@@ -26,15 +25,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// Action bar key for accessing focus nodes
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// The first item shares grid ownership and can move to another index.
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: 'detail_first_item');
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
// App bar focus state
bool isAppBarFocused = false;
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
bool backHandledByKeyEvent = false;
/// Called when items are available and we want to check if focus should be set
bool get hasItems;
@@ -44,6 +40,7 @@ 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();
}
@@ -57,14 +54,21 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
/// Handle BACK key from content - navigate to app bar and set flag to prevent PopScope exit
void handleBackFromContent() {
if (getAppBarActions().isEmpty) {
if (mounted) Navigator.pop(context);
return;
/// Whether the mounted app bar has actions. Derived from the bar itself so it
/// is safe in callbacks: [getAppBarActions] may read providers with
/// `context.select`, which is only legal during build.
bool get hasAppBarActions => actionBarKey.currentState?.widget.actions.isNotEmpty ?? false;
/// Back handler for focused content (grid cards, track rows): the content
/// widget's gate consumes the press and moves focus to the app bar, or pops
/// the route when there is no app bar to move to. Decided at press time, so a
/// bar mounted after the content was built is still honored.
void onBackFromContent() {
if (hasAppBarActions) {
navigateToAppBar();
} else if (mounted) {
Navigator.pop(context);
}
backHandledByKeyEvent = true;
navigateToAppBar();
}
/// Navigate focus from app bar down to the grid
@@ -83,8 +87,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'detail_grid_item');
/// Wrap [slivers] in the standard detail-screen scaffold — an overlay-sheet
/// host that defers route back to [handleBackNavigation], plus a Scaffold
/// with a CustomScrollView bound as the primary scroll view. Callers build
/// host whose route back (key or system) runs [handleBackNavigation], plus a
/// Scaffold with a CustomScrollView bound as the primary scroll view. Callers build
/// the slivers themselves (typically
/// `[appBar, ...header, ...buildStateSlivers(), grid]`); a trailing
/// [SliverSystemBottomInset] is appended so the last row clears the system
@@ -109,11 +113,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
controller: scrollController,
child: OverlaySheetHost(
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) {
Navigator.pop(context);
}
onBack: () {
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(body: body),
),
@@ -121,30 +122,26 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
);
}
/// Handle back navigation for PopScope. Returns true if should pop.
/// The route's back policy, for key and system back alike: pop once the app
/// bar is focused (or there is none); otherwise focus the app bar first.
/// Returns true if the route should pop.
bool handleBackNavigation() {
// If BACK was already handled by a key event, don't pop
if (backHandledByKeyEvent) {
backHandledByKeyEvent = false;
return false;
}
if (isAppBarFocused || getAppBarActions().isEmpty) {
return true;
} else {
// Focus app bar first
navigateToAppBar();
return false;
}
if (isAppBarFocused || !hasAppBarActions) return true;
navigateToAppBar();
return false;
}
/// Build focusable app bar action widgets
/// Build focusable app bar action widgets. The bar handles no Back key of its
/// own: a press with the bar focused bubbles to the route, whose PopScope
/// runs [handleBackNavigation] (true once the bar is focused) and pops.
List<Widget> buildFocusableAppBarActions() {
return [
FocusableActionBar(
key: actionBarKey,
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
onFocusChange: (hasFocus) {
if (hasFocus && !isAppBarFocused) setState(() => isAppBarFocused = true);
},
actions: getAppBarActions(),
),
];
@@ -214,6 +211,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
CardShape? shape,
int indexOffset = 0,
}) {
final onBack = onBackFromContent;
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
@@ -247,7 +245,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// sections fall through to traversal, which enters the previous
// section's grid.
onNavigateUp: position.isFirstRow && indexOffset == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onBack: onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(globalIndex, hasFocus),
);
}
+9 -31
View File
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import '../mixins/paginated_item_loader.dart';
import 'libraries/sort_bottom_sheet.dart';
@@ -108,12 +107,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
_focusNodeForIndex(targetIndex).requestFocus();
}
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(
index,
firstItemFocusNode,
prefix: 'hub_detail_item',
itemIdentity: _filteredItems[index].globalKey,
);
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item');
@override
void initState() {
@@ -235,7 +229,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
});
}
});
_remapFocusToFocusedItem();
}
void _showSortBottomSheet() {
@@ -450,12 +443,6 @@ 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(() {});
@@ -515,12 +502,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// otherwise focus the app bar first, then pop (handleBackNavigation).
// canPop preserves the iOS interactive swipe-back.
// Host owns sheet + route back for key and system back alike: a back
// with a sheet open closes it; otherwise focus the app bar first, then
// pop (handleBackNavigation). canPop preserves the iOS interactive
// swipe-back.
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
onBack: () {
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(
@@ -550,6 +537,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final onBack = onBackFromContent;
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
@@ -568,11 +556,6 @@ 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,
@@ -584,11 +567,6 @@ 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,
@@ -604,7 +582,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
? _continuationRetryFocusNode.requestFocus
: null,
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onBack: onBack,
onFocusChange: (hasFocus) => _handleGridItemFocusChange(
index,
hasFocus,
@@ -624,7 +602,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
retryFocusNode: _continuationRetryFocusNode,
errorContext: widget.hub.title,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
onBack: onBackFromContent,
),
const SliverSystemBottomInset(),
],

Some files were not shown because too many files have changed in this diff Show More