Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703df6a561 | ||
|
|
111af97a61 |
+6
-6
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
-354
@@ -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
|
||||
}
|
||||
}
|
||||
-146
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Услуги",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サービス",
|
||||
|
||||
@@ -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": "Қызметтер",
|
||||
|
||||
@@ -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": "서비스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сервисы",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "外部服務",
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -982,9 +982,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
if (changed) safeNotifyListeners();
|
||||
if (event.origin == DeletionOrigin.local) {
|
||||
unawaited(refreshContinueWatching());
|
||||
}
|
||||
unawaited(refreshContinueWatching());
|
||||
}
|
||||
|
||||
void _onHiddenLibrariesChanged() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user