Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33557f0eda | ||
|
|
a4f3c5393b | ||
|
|
533028fa0e | ||
|
|
d7f1d4a8fc | ||
|
|
c6c0ce8bea | ||
|
|
5a7e0966a3 | ||
|
|
c84b12eacd | ||
|
|
f9b9fbf611 | ||
|
|
e727f5bef4 | ||
|
|
3cf412b9c8 | ||
|
|
048bf688b2 | ||
|
|
8238705d3e | ||
|
|
722c4ddd00 | ||
|
|
56b990c04c | ||
|
|
b904c11750 | ||
|
|
8870852a9e | ||
|
|
6039fde33b | ||
|
|
82ae3609f6 | ||
|
|
32f72c6dd2 | ||
|
|
8c5d0b4213 | ||
|
|
3dd5b5d48e | ||
|
|
b6aa83af74 | ||
|
|
4712414b3d | ||
|
|
89b326e3f3 | ||
|
|
cf0dd0607e | ||
|
|
9a44f062a6 | ||
|
|
178634e943 | ||
|
|
99f4fda55a | ||
|
|
908ccbb233 | ||
|
|
2b176861b7 | ||
|
|
647971a5d6 | ||
|
|
a18c54f5ce | ||
|
|
c5ed666aa9 | ||
|
|
6580c4cf97 | ||
|
|
3ca565baa9 | ||
|
|
bc1a715411 | ||
|
|
c20a6cf162 | ||
|
|
ab1c9b9afd | ||
|
|
9ef3de985f | ||
|
|
54438c8d7a | ||
|
|
1d5b175cc8 | ||
|
|
b80aa1916e | ||
|
|
5c40ba0505 | ||
|
|
cbc47790c3 | ||
|
|
baa31742cb | ||
|
|
01750ad92b | ||
|
|
5de2d28166 | ||
|
|
88e6d33221 | ||
|
|
ac7fb87ade | ||
|
|
ce3a3b177c | ||
|
|
b795ef1604 | ||
|
|
deeec6810d | ||
|
|
d27cb61a2b | ||
|
|
e660a06499 | ||
|
|
2fb04a14ad | ||
|
|
c228a12d1e | ||
|
|
d47bcdec12 | ||
|
|
bd9fb38413 | ||
|
|
dca8029a99 | ||
|
|
7aa1679036 | ||
|
|
9ae34473af | ||
|
|
3fb8794619 | ||
|
|
98c6a09b41 | ||
|
|
621b50141c | ||
|
|
02dc57bd55 | ||
|
|
38f00f51c4 | ||
|
|
77422f2e6e | ||
|
|
d2d73f35f8 | ||
|
|
d6f0aa8936 | ||
|
|
e35f6b3a64 | ||
|
|
80bc37eb34 | ||
|
|
0490de5b5b | ||
|
|
f738e74994 | ||
|
|
65af57dd93 | ||
|
|
bc483a8206 | ||
|
|
c0d090079b | ||
|
|
6147736bba | ||
|
|
7885bea016 | ||
|
|
05598f1708 | ||
|
|
dc06b2e181 | ||
|
|
dbf8bf54c5 | ||
|
|
36e898067a | ||
|
|
a475e92d7e | ||
|
|
55985b8f9c | ||
|
|
c5968029e0 | ||
|
|
cd22475cb6 | ||
|
|
bc13ff007a | ||
|
|
29a6286fe6 | ||
|
|
6815850b61 | ||
|
|
a2c9bea041 | ||
|
|
a629a3407a | ||
|
|
7e930580cf | ||
|
|
b79af75a1f | ||
|
|
a873009fff | ||
|
|
41c92b6324 | ||
|
|
e59b79266a | ||
|
|
d2fe21b169 | ||
|
|
f45eaf1367 | ||
|
|
4f9e527ef2 | ||
|
|
5efc4f384c | ||
|
|
12a380807d |
+6
-6
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
version "2.19.1"
|
||||
sha256 "7d3ffd0efccf4db5faf063e8ecc03e897ee30d08e9fa3b007d127c63cbf187e2"
|
||||
|
||||
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 do
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-cr", "#{appdir}/Plezy.app"],
|
||||
sudo: false
|
||||
postflight_steps do
|
||||
run "/usr/bin/xattr",
|
||||
args: ["-cr", "{{appdir}}/Plezy.app"],
|
||||
sudo: false
|
||||
end
|
||||
|
||||
uninstall quit: "com.edde746.plezy"
|
||||
|
||||
@@ -30,22 +30,47 @@ A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built w
|
||||
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
|
||||
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
|
||||
|
||||
Package managers:
|
||||
<details>
|
||||
<summary>Install with a package manager</summary>
|
||||
|
||||
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
|
||||
- **Homebrew** (macOS):
|
||||
```bash
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
brew install --cask plezy
|
||||
```
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
|
||||
```bash
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
- **WinGet** (Windows):
|
||||
```bash
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
### macOS — Homebrew
|
||||
|
||||
```bash
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
brew install --cask plezy
|
||||
```
|
||||
|
||||
### Windows — WinGet
|
||||
|
||||
```bash
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
|
||||
### Arch Linux — Pacman
|
||||
|
||||
[Distribution package](https://archlinux.org/packages/extra/x86_64/plezy/).
|
||||
|
||||
```bash
|
||||
sudo pacman -S plezy
|
||||
```
|
||||
|
||||
### Fedora / Red Hat — DNF
|
||||
|
||||
[Installation instructions](https://github.com/aldobarr/plezy-rpm) · Community repository by [@aldobarr](https://github.com/aldobarr).
|
||||
|
||||
### Nix
|
||||
|
||||
[Community package](https://search.nixos.org/packages?channel=unstable&query=plezy) maintained by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn).
|
||||
|
||||
### aerynOS — Moss
|
||||
|
||||
[Distribution package](https://github.com/aerynOS/recipes/tree/main/p/plezy).
|
||||
|
||||
```bash
|
||||
sudo moss it plezy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -110,6 +135,11 @@ Package managers:
|
||||
### <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]
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Exercises MPV's JNI reachability against the production-shrunk app with
|
||||
* `-Pplezy.testBuildType=minified`. Native initialization resolves all nine static
|
||||
* callback descriptors, so a stale or missing keep fails before initialization completes.
|
||||
*
|
||||
* Only calls the core APIs used by production. Referencing MpvPlayer's callbacks
|
||||
* directly here would let the harness mask missing retention in the app APK.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvPlayerReachabilityTest {
|
||||
@Test
|
||||
fun productionCoreInitializesDeliversNativeLogAndDisposes() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val initializationSucceeded = AtomicBoolean()
|
||||
val commandCompleted = CountDownLatch(1)
|
||||
val commandSucceeded = AtomicBoolean()
|
||||
val nativeLogReceived = CountDownLatch(1)
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
// Pass every constructor argument: production uses this constructor, not
|
||||
// the default-argument bridge that R8 may legitimately remove.
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, true, true, 1f, "v"))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message" && data?.get("level") == "info" && data["text"] == LOG_MARKER) {
|
||||
nativeLogReceived.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().initialize {
|
||||
initializationSucceeded.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertTrue("Native MPV initialization failed", initializationSucceeded.get())
|
||||
|
||||
// initialize completes after the production flow collectors subscribe.
|
||||
// This marker must cross the real native event thread and reach the delegate.
|
||||
instrumentation.runOnMainSync {
|
||||
core.get().command(arrayOf("print-text", LOG_MARKER)) {
|
||||
commandSucceeded.set(it)
|
||||
commandCompleted.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(commandCompleted, "print-text")
|
||||
assertTrue("Native MPV print-text failed", commandSucceeded.get())
|
||||
assertCompletes(nativeLogReceived, "native log callback")
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "native teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
const val LOG_MARKER = "plezy-mpv-r8-native-log"
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
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
@@ -0,0 +1,146 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Instrumentation
|
||||
import android.os.SystemClock
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MpvLogLevelDeviceTest {
|
||||
@Test
|
||||
fun normalLoggingPreservesErrorsAndCanToggleVerboseOffAgain() = withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "normal", expectInfo = false)
|
||||
setLogLevel(instrumentation, core, "v")
|
||||
assertLogPhase(instrumentation, core, logs, "verbose", expectInfo = true)
|
||||
setLogLevel(instrumentation, core, "warn")
|
||||
assertLogPhase(instrumentation, core, logs, "normal-again", expectInfo = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verboseLoggingCanBeSelectedBeforeNativeInitialization() = withCore(initialLogLevel = "v") { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "initial-verbose", expectInfo = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedInitialLogLevelDoesNotBlockTheNextPlayer() {
|
||||
withCore(initialLogLevel = "not-a-level", initializationSucceeds = false) { _, _, _ -> }
|
||||
withCore { instrumentation, core, logs ->
|
||||
assertLogPhase(instrumentation, core, logs, "after-rejected-init", expectInfo = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun withCore(
|
||||
initialLogLevel: String = "warn",
|
||||
initializationSucceeds: Boolean = true,
|
||||
block: (Instrumentation, MpvPlayerCore, LinkedBlockingQueue<Pair<String, String>>) -> Unit
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val logs = LinkedBlockingQueue<Pair<String, String>>()
|
||||
val initialized = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
val core = AtomicReference<MpvPlayerCore>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.set(MpvPlayerCore(instrumentation.targetContext, audioOnly = true, initialLogLevel = initialLogLevel))
|
||||
core.get().delegate = object : PlayerDelegate {
|
||||
override fun onPropertyChange(name: String, value: Any?) = Unit
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
if (name == "log-message") {
|
||||
logs.add((data?.get("level") as? String ?: "") to (data?.get("text") as? String ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
core.get().initialize {
|
||||
success.set(it)
|
||||
initialized.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertCompletes(initialized, "initialization")
|
||||
assertEquals("Native MPV initialization result", initializationSucceeds, success.get())
|
||||
block(instrumentation, core.get(), logs)
|
||||
} finally {
|
||||
val disposed = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
|
||||
assertCompletes(disposed, "teardown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLogLevel(instrumentation: Instrumentation, core: MpvPlayerCore, level: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val result = AtomicReference<Result<Unit>>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.setLogLevel(level) {
|
||||
result.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, "setLogLevel($level)")
|
||||
result.get().getOrThrow()
|
||||
}
|
||||
|
||||
private fun command(instrumentation: Instrumentation, core: MpvPlayerCore, vararg args: String) {
|
||||
val completed = CountDownLatch(1)
|
||||
val success = AtomicReference<Boolean>()
|
||||
instrumentation.runOnMainSync {
|
||||
core.command(arrayOf(*args)) {
|
||||
success.set(it)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertCompletes(completed, args.first())
|
||||
assertTrue("MPV command failed: ${args.first()}", success.get())
|
||||
}
|
||||
|
||||
private fun assertLogPhase(
|
||||
instrumentation: Instrumentation,
|
||||
core: MpvPlayerCore,
|
||||
logs: LinkedBlockingQueue<Pair<String, String>>,
|
||||
phase: String,
|
||||
expectInfo: Boolean
|
||||
) {
|
||||
val token = "mpv-log-$phase-${UUID.randomUUID()}"
|
||||
val infoMarker = "$token-info"
|
||||
val errorMarker = "$token-missing"
|
||||
val missingFile = File(instrumentation.targetContext.cacheDir, errorMarker)
|
||||
command(instrumentation, core, "print-text", infoMarker)
|
||||
command(instrumentation, core, "loadfile", missingFile.absolutePath, "replace")
|
||||
|
||||
// The failed open is an error-level barrier in the same ordered log stream.
|
||||
// Seeing it proves the preceding informational message was either delivered
|
||||
// or filtered; no sleep is needed to assert that a quiet log stayed quiet.
|
||||
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)
|
||||
var sawInfo = false
|
||||
while (true) {
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
assertTrue("Missing native error log in $phase", remaining > 0)
|
||||
val log = logs.poll(remaining, TimeUnit.MILLISECONDS)
|
||||
assertTrue("Missing native error log in $phase", log != null)
|
||||
if (log!!.second.contains(infoMarker)) {
|
||||
assertEquals("info", log.first)
|
||||
sawInfo = true
|
||||
}
|
||||
if (log.first == "error" && log.second.contains(errorMarker)) break
|
||||
}
|
||||
assertEquals("Informational log visibility in $phase", expectInfo, sawInfo)
|
||||
}
|
||||
|
||||
private fun assertCompletes(latch: CountDownLatch, operation: String) {
|
||||
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 15L
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,83 @@ 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.
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL vo session should drop to the cheap render tier: bilinear
|
||||
* scalers and no dither. Keyed on `GL_EXT_texture_norm16` being absent,
|
||||
* which on Android singles out the low-end Mali/Adreno TV class whose
|
||||
* texture units cannot afford a second full-resolution pass at 1080p
|
||||
* (measured on an S905X4/Mali-G31: mpv's default lanczos chroma pass alone
|
||||
* runs the frame over budget, bilinear brings it back under; every
|
||||
* norm16-capable GPU tested renders the whole default ladder in a few
|
||||
* milliseconds). The video plane never scales in GL, so hardware sessions
|
||||
* on it are untouched.
|
||||
*/
|
||||
fun needsCheapRenderTier(glVoActive: Boolean, textureNorm16: Boolean): Boolean = glVoActive && !textureNorm16
|
||||
|
||||
/** mpv option -> value for the cheap render tier, applied only where the
|
||||
* option still carries its mpv default (a user's mpv.conf line wins). */
|
||||
val CHEAP_RENDER_OPTIONS: Map<String, String> = linkedMapOf(
|
||||
"scale" to "bilinear",
|
||||
"cscale" to "bilinear",
|
||||
"dscale" to "bilinear",
|
||||
"dither" to "no"
|
||||
)
|
||||
|
||||
/** The values mpv 0.41 reports for [CHEAP_RENDER_OPTIONS] when nothing
|
||||
* set them; anything else is a user choice and stays. `cscale` inherits
|
||||
* `scale` by default, which the property reads back as an empty string
|
||||
* (measured on 0.41) or `inherit`. */
|
||||
val MPV_DEFAULT_RENDER_OPTIONS: Map<String, Set<String>> = mapOf(
|
||||
"scale" to setOf("lanczos"),
|
||||
"cscale" to setOf("", "inherit"),
|
||||
"dscale" to setOf("hermite"),
|
||||
"dither" to setOf("fruit")
|
||||
)
|
||||
|
||||
/** Whether [value] is what mpv reports for [option] by default. */
|
||||
fun isDefaultRenderOption(option: String, value: String?): Boolean = value != null && MPV_DEFAULT_RENDER_OPTIONS[option]?.contains(value) == true
|
||||
|
||||
/**
|
||||
* The vo a session with these active requirements should run, or null for
|
||||
* the video plane.
|
||||
@@ -57,4 +134,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"
|
||||
}
|
||||
|
||||
@@ -3,12 +3,22 @@ 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
|
||||
}
|
||||
@@ -26,6 +36,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,6 +206,11 @@ open class MpvPlayerPlugin(
|
||||
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
|
||||
// from older callers; hardware decode is the setting's default.
|
||||
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
|
||||
// Subtitle "Render Resolution" for the vo=mediacodec OSD plane (Full / ¾ / ½ /
|
||||
// ⅓ / ¼ of the surface); the same fraction the ExoPlayer overlay applies.
|
||||
// Absent from older callers and the audio-only core; full is the default.
|
||||
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1f
|
||||
val logLevel = call.argument<String>("logLevel") ?: "warn"
|
||||
// Video cores need the Activity (surface/view hierarchy); the audio-only
|
||||
// core is built on the application context so it can outlive it.
|
||||
val coreContext: Context? = if (audioOnly) applicationContext else activity
|
||||
@@ -264,7 +269,7 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
|
||||
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
@@ -469,25 +474,42 @@ open class MpvPlayerPlugin(
|
||||
result.error("NOT_INITIALIZED", "Player not initialized", null)
|
||||
return
|
||||
}
|
||||
core.command(args.toTypedArray()) { success ->
|
||||
if (success) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("COMMAND_FAILED", "mpv command failed", args)
|
||||
}
|
||||
// `loadfile` answers with the playlist entry it created so Dart can tie
|
||||
// the load to that source's start-file/playback-restart/end-file events;
|
||||
// every other command answers null.
|
||||
core.commandForSource(args.toTypedArray()) { outcome ->
|
||||
outcome.fold(
|
||||
onSuccess = { playlistEntryId ->
|
||||
result.success(playlistEntryId?.let { mapOf("playlistEntryId" to it) })
|
||||
},
|
||||
onFailure = { error ->
|
||||
result.error("COMMAND_FAILED", error.message ?: "mpv command failed", args)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.argument<String>("level") == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'level'", null)
|
||||
val level = call.argument<Any>("level") as? String
|
||||
if (level == null) {
|
||||
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
|
||||
return
|
||||
}
|
||||
result.error(
|
||||
"UNSUPPORTED",
|
||||
"Runtime mpv log level changes are not supported on Android",
|
||||
null
|
||||
)
|
||||
val core = playerCore
|
||||
if (core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
core.setLogLevel(level) { outcome ->
|
||||
when (val failure = outcome.exceptionOrNull()) {
|
||||
null -> result.success(null)
|
||||
is CancellationException -> completeMpvPropertyNotInitialized(result)
|
||||
else -> {
|
||||
Log.w(tag, "MPV rejected log level change", failure)
|
||||
result.error("SET_LOG_LEVEL_FAILED", "MPV log level change was rejected", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure geometry for the `vo=mediacodec` subtitle/OSD plane.
|
||||
*
|
||||
* mpv rasterizes subtitles at whatever size the OSD Surface reports, so the
|
||||
* "Render Resolution" setting is applied by giving the OSD SurfaceView a
|
||||
* fixed buffer size below its view size; SurfaceFlinger scales the plane back
|
||||
* up. Every stage of the OSD pass (libass rasterization, the software
|
||||
* composite, the Surface copy) is proportional to that pixel count, which is
|
||||
* what makes the setting a throughput knob on render-bound TV hardware. The
|
||||
* ExoPlayer path applies the same fraction to its libass overlay.
|
||||
*/
|
||||
internal object OsdPlanePolicy {
|
||||
data class Size(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* Fixed buffer size for an OSD plane whose view measures [viewWidth] x
|
||||
* [viewHeight], or null when the plane should track the view (full
|
||||
* resolution, or a view that has not been laid out yet).
|
||||
*
|
||||
* Dimensions are rounded to even values: the 4:2:0-derived alignment
|
||||
* assumptions in the compositor stack are cheap to honor here and an odd
|
||||
* OSD width buys nothing.
|
||||
*/
|
||||
fun fixedSizeFor(viewWidth: Int, viewHeight: Int, renderScale: Float): Size? {
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return null
|
||||
if (!(renderScale > 0f) || renderScale >= 1f) return null
|
||||
return Size(
|
||||
width = scaledEven(viewWidth, renderScale),
|
||||
height = scaledEven(viewHeight, renderScale)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scaledEven(value: Int, scale: Float): Int {
|
||||
val scaled = (value * scale).roundToInt().coerceAtLeast(2)
|
||||
return scaled and 1.inv()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES20
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* GLES capabilities that decide render policy before mpv creates its own
|
||||
* context. Probed once per process on a throwaway pbuffer context; the
|
||||
* answers are hardware/driver properties and do not change at runtime.
|
||||
*/
|
||||
internal object GlCapabilities {
|
||||
private const val TAG = "GlCapabilities"
|
||||
private const val EGL_OPENGL_ES3_BIT_KHR = 0x0040
|
||||
|
||||
@Volatile private var textureNorm16: Boolean? = null
|
||||
|
||||
/**
|
||||
* Whether the GLES driver exposes `GL_EXT_texture_norm16`. Without it mpv
|
||||
* cannot upload >8-bit planes as filterable textures, and the GPUs that
|
||||
* lack it are the ones that cannot afford mpv's default scalers either
|
||||
* (see [com.edde746.plezy.mpv.GpuVoPolicy.needsCheapRenderTier]).
|
||||
*
|
||||
* A probe failure reports `true`: the default render path is the safe
|
||||
* answer for an unknown GPU, not the cheap tier.
|
||||
*/
|
||||
fun hasTextureNorm16(): Boolean {
|
||||
textureNorm16?.let { return it }
|
||||
synchronized(this) {
|
||||
textureNorm16?.let { return it }
|
||||
val probed = probe { extensions -> extensions.contains("GL_EXT_texture_norm16") } ?: true
|
||||
textureNorm16 = probed
|
||||
return probed
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: pretend the probe answered [value]. */
|
||||
internal fun overrideTextureNorm16ForTesting(value: Boolean?) {
|
||||
textureNorm16 = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [read] against the extension string of a fresh ES3 pbuffer context
|
||||
* and tears everything down again. Returns null when EGL refuses any step;
|
||||
* callers pick the safe default. Must not run on a thread that already
|
||||
* has an EGL context current — the temporary context replaces it.
|
||||
*/
|
||||
private fun probe(read: (String) -> Boolean): Boolean? {
|
||||
var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
var context: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
var surface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
try {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display == EGL14.EGL_NO_DISPLAY) return null
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
|
||||
val configAttributes = intArrayOf(
|
||||
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR,
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val count = IntArray(1)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
if (context == EGL14.EGL_NO_CONTEXT) return null
|
||||
if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null
|
||||
val extensions = GLES20.glGetString(GLES20.GL_EXTENSIONS) ?: return null
|
||||
return read(extensions)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "GLES capability probe failed", e)
|
||||
return null
|
||||
} finally {
|
||||
if (display != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
|
||||
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
|
||||
if (surface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surface)
|
||||
// No eglTerminate: the default display is shared with the Flutter
|
||||
// engine and mpv; eglInitialize on an initialized display is a no-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,33 @@ internal object MediaCodecQuery {
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a hardware `video/avc` decoder advertises H.264 High 10 (Hi10P).
|
||||
* Decoders that do not advertise it either refuse the stream (this is what
|
||||
* the mpv/FFmpeg MediaCodec path sees) or, on some SoCs, accept it and
|
||||
* render garbage; neither is a reason to let the hardware path try first
|
||||
* (#2065). Answered once per process: the codec list is static.
|
||||
*/
|
||||
fun hardwareAvcHigh10Support(): Boolean = hardwareAvcHigh10.value
|
||||
|
||||
private val hardwareAvcHigh10: Lazy<Boolean> = lazy {
|
||||
findHardwareDecoder("video/avc") { info, type ->
|
||||
val profiles = try {
|
||||
info.getCapabilitiesForType(type).profileLevels
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@findHardwareDecoder false
|
||||
}
|
||||
profiles.any { it.profile == MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10 }
|
||||
} != 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,6 +7,7 @@ import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.edde746.plezy.mpv.OsdPlanePolicy
|
||||
|
||||
/** Shared Android view scaffold beneath the ExoPlayer and mpv cores. */
|
||||
internal object PlayerSurfaceHost {
|
||||
@@ -63,8 +64,13 @@ internal object PlayerSurfaceHost {
|
||||
* Transparent plane directly above the video surface for the mpv
|
||||
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
|
||||
* the video SurfaceView but still beneath the Flutter window content.
|
||||
*
|
||||
* [renderScale] < 1 gives the plane a fixed buffer size below its view size
|
||||
* (see [OsdPlanePolicy]); mpv then rasterizes at that size and the
|
||||
* compositor scales the plane to the view. Re-applied on every layout so a
|
||||
* container resize keeps the ratio.
|
||||
*/
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
|
||||
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
@@ -74,6 +80,13 @@ internal object PlayerSurfaceHost {
|
||||
setZOrderOnTop(false)
|
||||
setZOrderMediaOverlay(true)
|
||||
FlutterOverlayHelper.applyCompositionOrder(this, -1)
|
||||
if (renderScale < 1f) {
|
||||
addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (right - left == oldRight - oldLeft && bottom - top == oldBottom - oldTop) return@addOnLayoutChangeListener
|
||||
val size = OsdPlanePolicy.fixedSizeFor(right - left, bottom - top, renderScale) ?: return@addOnLayoutChangeListener
|
||||
(view as SurfaceView).holder.setFixedSize(size.width, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
|
||||
|
||||
@@ -18,3 +18,12 @@ add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
|
||||
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
|
||||
|
||||
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
add_executable(mpv_lifecycle_test mpv_lifecycle_test.cpp)
|
||||
target_compile_features(mpv_lifecycle_test PRIVATE cxx_std_17)
|
||||
target_include_directories(mpv_lifecycle_test PRIVATE fakes ../../../../libmpv/src/main/cpp/include)
|
||||
target_link_libraries(mpv_lifecycle_test PRIVATE Threads::Threads)
|
||||
|
||||
add_test(NAME mpv_lifecycle_test COMMAND mpv_lifecycle_test)
|
||||
set_tests_properties(mpv_lifecycle_test PROPERTIES TIMEOUT 30)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#define ANDROID_LOG_INFO 4
|
||||
#define ANDROID_LOG_WARN 5
|
||||
#define ANDROID_LOG_ERROR 6
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -14,6 +15,10 @@ using jboolean = uint8_t;
|
||||
using jbyte = int8_t;
|
||||
using jint = int32_t;
|
||||
using jsize = jint;
|
||||
using jlong = int64_t;
|
||||
using jdouble = double;
|
||||
using jobject = void*;
|
||||
using jmethodID = void*;
|
||||
|
||||
struct _jclass {};
|
||||
using jclass = _jclass*;
|
||||
@@ -28,11 +33,53 @@ struct _jstring {
|
||||
};
|
||||
using jstring = _jstring*;
|
||||
|
||||
struct _jobjectArray {
|
||||
std::vector<jobject> objects;
|
||||
};
|
||||
using jobjectArray = _jobjectArray*;
|
||||
|
||||
class JavaVM {
|
||||
public:
|
||||
void (*on_detach)() = nullptr;
|
||||
|
||||
jint DetachCurrentThread() {
|
||||
if (on_detach) on_detach();
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
class JNIEnv {
|
||||
public:
|
||||
bool exception_pending = false;
|
||||
bool fail_next_write = false;
|
||||
|
||||
JavaVM* vm = nullptr;
|
||||
jobject (*on_new_global_ref)(jobject) = nullptr;
|
||||
void (*on_delete_global_ref)(jobject) = nullptr;
|
||||
void (*on_static_void_method)(jmethodID, va_list) = nullptr;
|
||||
|
||||
jint GetJavaVM(JavaVM** result) {
|
||||
*result = vm;
|
||||
return 0;
|
||||
}
|
||||
|
||||
jobject NewGlobalRef(jobject object) { return on_new_global_ref ? on_new_global_ref(object) : object; }
|
||||
|
||||
void DeleteGlobalRef(jobject object) {
|
||||
if (on_delete_global_ref) on_delete_global_ref(object);
|
||||
}
|
||||
|
||||
void DeleteLocalRef(jobject) {}
|
||||
|
||||
void CallStaticVoidMethod(jclass, jmethodID method, ...) {
|
||||
va_list args;
|
||||
va_start(args, method);
|
||||
if (on_static_void_method) on_static_void_method(method, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
jsize GetArrayLength(jobjectArray array) { return static_cast<jsize>(array->objects.size()); }
|
||||
jobject GetObjectArrayElement(jobjectArray array, jsize index) { return array->objects.at(index); }
|
||||
jsize GetArrayLength(jbyteArray array) { return static_cast<jsize>(array->bytes.size()); }
|
||||
|
||||
void GetByteArrayRegion(jbyteArray array, jsize offset, jsize length, jbyte* destination) {
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
#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,7 +236,8 @@ class ExoPlayerPluginTest {
|
||||
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val exoCore = ExoPlayerCore(activity)
|
||||
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
|
||||
val plugin = ExoPlayerPlugin()
|
||||
val sink = RecordingEventSink()
|
||||
plugin.onListen(null, sink)
|
||||
@@ -273,15 +274,30 @@ class ExoPlayerPluginTest {
|
||||
assertEquals(1, superseded.completionCount)
|
||||
assertEquals("OPEN_SUPERSEDED", superseded.errorCode)
|
||||
assertEquals(0, active.completionCount)
|
||||
assertTrue(loads.isEmpty())
|
||||
|
||||
initializeCallback!!(true)
|
||||
mpvCore.delegate?.onEvent("file-loaded", null)
|
||||
awaitCompletion(active)
|
||||
|
||||
assertEquals(null, active.errorCode)
|
||||
assertEquals(true, getField(plugin, "usingMpvFallback"))
|
||||
assertEquals(false, getField(plugin, "fallbackInProgress"))
|
||||
assertNull(getField(plugin, "pendingOpen"))
|
||||
// mpv received exactly the newest open, with its play intent; neither the
|
||||
// failed Exo URI nor the superseded episode was ever loaded.
|
||||
assertEquals(listOf("https://example.test/episode-3.mkv"), loads.map { it[1] })
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
// The fallback owns subsequent opens: they reach mpv directly, without
|
||||
// another backend switch.
|
||||
val next = RecordingResult()
|
||||
plugin.onMethodCall(
|
||||
MethodCall("open", mapOf("uri" to "https://example.test/episode-4.mkv", "autoPlay" to false)),
|
||||
next
|
||||
)
|
||||
awaitCompletion(next)
|
||||
assertEquals(null, next.errorCode)
|
||||
assertEquals("https://example.test/episode-4.mkv", loads.last()[1])
|
||||
assertEquals("pause=yes", loads.last()[4].split(",")[1])
|
||||
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
|
||||
|
||||
val disposeResult = RecordingResult()
|
||||
@@ -643,7 +659,9 @@ class ExoPlayerPluginTest {
|
||||
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core that lost its surface mid-playback with a resume still deferred.
|
||||
core.setPrivateField("desiredPaused", false)
|
||||
core.setPrivateField("cachedPaused", false)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", false)
|
||||
@@ -664,11 +682,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(true, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(true, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
|
||||
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
|
||||
// The load itself carries the hold; the stale deferred resume must not
|
||||
// undo it with a late pause write.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=yes", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
core.dispose()
|
||||
}
|
||||
@@ -677,10 +694,10 @@ class ExoPlayerPluginTest {
|
||||
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
|
||||
core.setPrivateField("desiredPaused", true)
|
||||
core.setPrivateField("cachedPaused", true)
|
||||
core.setPrivateField("resumeBlockedByPublicPause", true)
|
||||
val loads = ConcurrentLinkedQueue<List<String>>()
|
||||
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
|
||||
// A reused core whose previous load was explicitly held.
|
||||
core.setPauseIntentForLoad(true)
|
||||
val plugin = reusedFallbackPlugin(activity, core)
|
||||
val result = RecordingResult()
|
||||
|
||||
@@ -695,9 +712,10 @@ class ExoPlayerPluginTest {
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertEquals(false, core.getPrivateField("desiredPaused"))
|
||||
assertEquals(false, core.getPrivateField("cachedPaused"))
|
||||
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
|
||||
// The new load carries the play intent; the earlier hold is not replayed
|
||||
// as a separate pause write before or after it.
|
||||
assertEquals("https://example.test/video.mkv", loads.single()[1])
|
||||
assertEquals("pause=no", loads.single()[4].split(",")[1])
|
||||
assertFalse(awaitPauseWriteCount(writes, 1))
|
||||
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
|
||||
core.dispose()
|
||||
@@ -955,6 +973,14 @@ class ExoPlayerPluginTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts every native command, recording each one; a `loadfile` yields playlist entry 1. */
|
||||
private fun recordingLoads(
|
||||
loads: ConcurrentLinkedQueue<List<String>>
|
||||
): suspend (Array<String>) -> Long? = { args ->
|
||||
loads += args.toList()
|
||||
if (args.firstOrNull() == "loadfile") 1L else null
|
||||
}
|
||||
|
||||
private fun awaitQueueEntry(
|
||||
queue: ConcurrentLinkedQueue<Pair<String, String>>,
|
||||
expected: Pair<String, String>
|
||||
|
||||
@@ -80,6 +80,91 @@ class GpuVoPolicyTest {
|
||||
assertNull(GpuVoPolicy.targetFor(emptySet()))
|
||||
}
|
||||
|
||||
@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)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hardware supported and unrelated streams retain the configured decoder`() {
|
||||
// A decoder that advertises the profile gets to try.
|
||||
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true, hardwareAv1 = false))
|
||||
// 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()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cheap render tier needs a GL vo on a driver without norm16`() {
|
||||
assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false))
|
||||
// The plane never scales in GL; a capable GPU keeps mpv's defaults.
|
||||
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = false, textureNorm16 = false))
|
||||
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cheap tier replaces only options still at their mpv default`() {
|
||||
for ((option, defaults) in GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS) {
|
||||
for (default in defaults) assertTrue(option, GpuVoPolicy.isDefaultRenderOption(option, default))
|
||||
// A user's mpv.conf value, or an unreadable option, is left alone.
|
||||
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, "ewa_lanczos"))
|
||||
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, null))
|
||||
}
|
||||
assertEquals(GpuVoPolicy.CHEAP_RENDER_OPTIONS.keys, GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS.keys)
|
||||
// cscale's default is "inherit", which mpv 0.41 reads back as empty.
|
||||
assertTrue(GpuVoPolicy.isDefaultRenderOption("cscale", ""))
|
||||
assertFalse(GpuVoPolicy.isDefaultRenderOption("scale", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dv reshaping targets gpu-next even alongside other reasons`() {
|
||||
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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,6 +49,22 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandWithoutNativePlayerReportsFailureInsteadOfSilentSuccess() {
|
||||
// A load that never reached mpv produces no source; answering success
|
||||
// would leave Dart waiting on a start-file that never comes.
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, testCore(null))
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("command", mapOf("args" to listOf("loadfile", "x", "replace"))), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals("COMMAND_FAILED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
|
||||
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
|
||||
@@ -758,15 +774,53 @@ class MpvPlayerPluginTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelReportsUnsupported() {
|
||||
fun setLogLevelWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "warn")), result)
|
||||
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLogLevelRejectsMissingOrNonStringLevel() {
|
||||
for (level in listOf(null, 42)) {
|
||||
val result = RecordingResult()
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(MethodCall("setLogLevel", mapOf("level" to level)), result)
|
||||
|
||||
assertEquals("INVALID_ARGS", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disposeCompletesQueuedLogLevelChangeOnceWithoutAnActiveNativePlayer() {
|
||||
val blockerStarted = CountDownLatch(1)
|
||||
val releaseBlocker = CountDownLatch(1)
|
||||
val core = testCore { _, _ ->
|
||||
blockerStarted.countDown()
|
||||
check(releaseBlocker.await(2, TimeUnit.SECONDS))
|
||||
}
|
||||
val plugin = MpvPlayerPlugin()
|
||||
installCore(plugin, core)
|
||||
val result = RecordingResult()
|
||||
try {
|
||||
core.setProperty("block", "value")
|
||||
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
|
||||
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "v")), result)
|
||||
core.dispose()
|
||||
} finally {
|
||||
releaseBlocker.countDown()
|
||||
}
|
||||
awaitCompletion(result)
|
||||
|
||||
MpvPlayerPlugin().onMethodCall(
|
||||
MethodCall("setLogLevel", mapOf("level" to "warn")),
|
||||
result
|
||||
)
|
||||
|
||||
assertEquals("UNSUPPORTED", result.errorCode)
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@@ -1040,6 +1094,39 @@ class MpvPlayerPluginTest {
|
||||
assertEquals("mediacodec", lastVo())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hwdecWritesParkWhileAPerFileHoldIsActive() {
|
||||
// While DV P5 reshaping or Hi10 routing holds hwdec at `no`, a session
|
||||
// write of a hardware value must not reach mpv (it would re-enable the
|
||||
// decoder that was just refused) but must be kept for the restore.
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
|
||||
writes.add(name to value)
|
||||
})
|
||||
setBoolean(core, "isInitialized", true)
|
||||
setBoolean(core, "hwdecHeld", true)
|
||||
|
||||
var outcome: Result<Unit>? = null
|
||||
core.setProperty("hwdec", "mediacodec,mediacodec-copy") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
assertTrue(outcome!!.isSuccess)
|
||||
assertTrue(writes.isEmpty())
|
||||
val parked = MpvPlayerCore::class.java.getDeclaredField("parkedHwdec").run {
|
||||
isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(get(core) as java.util.concurrent.atomic.AtomicReference<String?>).get()
|
||||
}
|
||||
assertEquals("mediacodec,mediacodec-copy", parked)
|
||||
|
||||
// Once the hold is gone, hwdec writes flow through again.
|
||||
setBoolean(core, "hwdecHeld", false)
|
||||
outcome = null
|
||||
core.setProperty("hwdec", "no") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
assertEquals(listOf("hwdec" to "no"), writes.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dvConversionModeMapsOntoForkDecoderOptions() {
|
||||
// The app-level `dv-conversion-mode` property must translate to the fork
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OsdPlanePolicyTest {
|
||||
@Test
|
||||
fun `full resolution leaves the plane tracking its view`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fractions shrink both dimensions by the same scale`() {
|
||||
assertEquals(OsdPlanePolicy.Size(1440, 810), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.75f))
|
||||
assertEquals(OsdPlanePolicy.Size(960, 540), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.5f))
|
||||
assertEquals(OsdPlanePolicy.Size(640, 360), OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odd results round down to even`() {
|
||||
// 1/3 of 1000 is 333.3: rounds to 333, then to the even 332.
|
||||
assertEquals(OsdPlanePolicy.Size(332, 250), OsdPlanePolicy.fixedSizeFor(1000, 750, 1f / 3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlaid-out views and nonsense scales are ignored`() {
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(0, 0, 0.5f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 0f))
|
||||
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1.5f))
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,13 @@
|
||||
# jni_utils.cpp caches MpvPlayer with FindClass and resolves these static callbacks
|
||||
# with GetStaticMethodID on the native event thread. R8 sees no reference to the
|
||||
# class name or the member names, so both must stay alive and un-renamed.
|
||||
# Match callback names, return type and static access without duplicating JNI
|
||||
# argument descriptors here: adding the session token made the old signatures
|
||||
# silently stop matching. Native initialization resolves every exact descriptor.
|
||||
-keep class com.edde746.plezy.libmpv.MpvPlayer {
|
||||
public static void onPropertyChanged(java.lang.String, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, boolean, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, long, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, double, long, boolean);
|
||||
public static void onPropertyChanged(java.lang.String, java.lang.String, long, boolean);
|
||||
public static void onEvent(int, long, boolean, double, boolean);
|
||||
public static void onEndFile(int, long, boolean);
|
||||
public static void onLogMessage(java.lang.String, int, java.lang.String);
|
||||
public static void onPropertyChanged(...);
|
||||
public static void onEvent(...);
|
||||
public static void onEndFile(...);
|
||||
public static void onLogMessage(...);
|
||||
public static void onHook(...);
|
||||
}
|
||||
|
||||
@@ -7,34 +7,50 @@
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
// The session every callback below names, bound once before the thread
|
||||
// starts (event_thread_bind). Kotlin drops a callback whose session is not
|
||||
// the wrapper it published for, so a retiring core's tail — end-file,
|
||||
// property changes, a hook raised just before teardown — can never be read
|
||||
// as the successor's. L keeps this binding immutable through join, including
|
||||
// the interval after admission is revoked; callbacks never acquire L.
|
||||
static mpv_handle* thread_mpv;
|
||||
static uint64_t thread_session;
|
||||
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session) {
|
||||
thread_mpv = mpv;
|
||||
thread_session = session;
|
||||
}
|
||||
|
||||
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop, int64_t source_id, bool has_source_id) {
|
||||
jstring jprop = new_java_string(env, prop->name);
|
||||
jstring jvalue = NULL;
|
||||
const jboolean jhas_source_id = has_source_id ? JNI_TRUE : JNI_FALSE;
|
||||
const jlong jsession = (jlong)thread_session;
|
||||
switch (prop->format) {
|
||||
case MPV_FORMAT_NONE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jprop, (jlong)source_id, jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jsession, jprop, (jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_FLAG:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jprop, (jboolean) * (int*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jsession, jprop, (jboolean) * (int*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_INT64:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jprop, (jlong) * (int64_t*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jsession, jprop, (jlong) * (int64_t*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_DOUBLE:
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jprop, (jdouble) * (double*)prop->data, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jsession, jprop, (jdouble) * (double*)prop->data,
|
||||
(jlong)source_id, jhas_source_id);
|
||||
break;
|
||||
case MPV_FORMAT_STRING:
|
||||
jvalue = new_java_string(env, *(const char**)prop->data);
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jprop, jvalue, (jlong)source_id, jhas_source_id);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jsession, jprop, jvalue, (jlong)source_id,
|
||||
jhas_source_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -47,28 +63,42 @@ static void sendEventToJava(
|
||||
JNIEnv* env, int event, int64_t source_id, bool has_source_id, double position_seconds = 0.0,
|
||||
bool has_position_seconds = false) {
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jint)event, (jlong)source_id, has_source_id ? JNI_TRUE : JNI_FALSE,
|
||||
(jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jlong)thread_session, (jint)event, (jlong)source_id,
|
||||
has_source_id ? JNI_TRUE : JNI_FALSE, (jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
|
||||
}
|
||||
|
||||
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
|
||||
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, (jint)reason, (jlong)source_id, end_file ? JNI_TRUE : JNI_FALSE);
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
|
||||
end_file ? JNI_TRUE : JNI_FALSE, (jint)error);
|
||||
}
|
||||
|
||||
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
|
||||
jstring jprefix = new_java_string(env, msg->prefix);
|
||||
jstring jtext = new_java_string(env, msg->text);
|
||||
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
|
||||
env->CallStaticVoidMethod(
|
||||
mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, (jlong)thread_session, jprefix, (jint)msg->log_level, jtext);
|
||||
|
||||
if (jprefix) env->DeleteLocalRef(jprefix);
|
||||
if (jtext) env->DeleteLocalRef(jtext);
|
||||
}
|
||||
|
||||
// A hook holds mpv (playback does not proceed) until Kotlin answers with
|
||||
// nativeHookContinue(session, id); MpvPlayer guarantees that answer for every
|
||||
// hook it is handed, and mpv itself continues any hook still held when the
|
||||
// handle is destroyed.
|
||||
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
|
||||
jstring jname = new_java_string(env, hook->name);
|
||||
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, (jlong)thread_session, jname, (jlong)hook->id);
|
||||
if (jname) env->DeleteLocalRef(jname);
|
||||
}
|
||||
|
||||
void* event_thread(void* arg) {
|
||||
JNIEnv* env = NULL;
|
||||
acquire_jni_env(g_vm, &env);
|
||||
@@ -82,7 +112,7 @@ void* event_thread(void* arg) {
|
||||
mpv_event_property* mp_property;
|
||||
mpv_event_log_message* msg;
|
||||
|
||||
mp_event = mpv_wait_event(g_mpv, -1.0);
|
||||
mp_event = mpv_wait_event(thread_mpv, -1.0);
|
||||
|
||||
if (g_event_thread_request_exit) break;
|
||||
|
||||
@@ -91,7 +121,6 @@ void* event_thread(void* arg) {
|
||||
switch (mp_event->event_id) {
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
msg = (mpv_event_log_message*)mp_event->data;
|
||||
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
|
||||
sendLogMessageToJava(env, msg);
|
||||
break;
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
@@ -105,20 +134,20 @@ 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:
|
||||
sendHookToJava(env, (mpv_event_hook*)mp_event->data);
|
||||
break;
|
||||
case MPV_EVENT_PLAYBACK_RESTART: {
|
||||
double position_seconds = 0.0;
|
||||
const bool has_position_seconds =
|
||||
mpv_get_property(g_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
|
||||
mpv_get_property(thread_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
|
||||
std::isfinite(position_seconds);
|
||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||
sendEventToJava(env, mp_event->event_id, source_id, has_source_id, position_seconds, has_position_seconds);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <mpv/client.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Names the session the next event_thread serves. Called under L and S before
|
||||
// pthread_create publishes the binding. L prevents rebinding until that thread
|
||||
// is joined; the thread never reads the live admission slots and can safely
|
||||
// finish a callback after revocation. Callbacks must never acquire L.
|
||||
void event_thread_bind(mpv_handle* mpv, uint64_t session);
|
||||
|
||||
void* event_thread(void* arg);
|
||||
|
||||
@@ -2,9 +2,46 @@
|
||||
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
extern JavaVM* g_vm;
|
||||
|
||||
// The process-global native session. One mpv_handle lives at a time; each is
|
||||
// identified by a monotonic session id handed to Kotlin by nativeCreate. Every
|
||||
// JNI entry names the session it was issued for, every callback carries the
|
||||
// session it originated from, so a retired wrapper can neither touch nor be
|
||||
// fed by its successor.
|
||||
//
|
||||
// g_session_lock (S) guards admission: write-held for publication, init and
|
||||
// revocation, read-held by every other JNI entry through its last handle use.
|
||||
// Retirement drains admitted readers and clears these slots under S, then
|
||||
// releases S BEFORE wake/join/terminate so callbacks can reenter and reject.
|
||||
// A separate lifecycle mutex (L, private to main.cpp) serializes create/init/
|
||||
// destroy through termination AND surface cleanup. Lock order is L -> S;
|
||||
// readers and event callbacks never acquire L. The event thread borrows its
|
||||
// immutable bound handle until joined, even after the admission slots clear.
|
||||
extern mpv_handle* g_mpv;
|
||||
extern uint64_t g_session;
|
||||
extern pthread_rwlock_t g_session_lock;
|
||||
extern std::atomic<bool> g_event_thread_request_exit;
|
||||
|
||||
// Read-locked admission of one JNI entry to the live session. `mpv` is NULL
|
||||
// when `session` is not the live one (retired, superseded, or never created);
|
||||
// the caller then reports MPV_ERROR_UNINITIALIZED / null and touches nothing.
|
||||
// A refused entry is an expected outcome of teardown racing in-flight work,
|
||||
// not a programming error, so it never throws into Java.
|
||||
class SessionGuard {
|
||||
public:
|
||||
explicit SessionGuard(jlong session) {
|
||||
pthread_rwlock_rdlock(&g_session_lock);
|
||||
mpv = (g_mpv && g_session == (uint64_t)session) ? g_mpv : NULL;
|
||||
}
|
||||
~SessionGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionGuard(const SessionGuard&) = delete;
|
||||
SessionGuard& operator=(const SessionGuard&) = delete;
|
||||
|
||||
mpv_handle* mpv;
|
||||
};
|
||||
|
||||
@@ -50,20 +50,22 @@ void init_methods_cache(JNIEnv* env) {
|
||||
|
||||
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
|
||||
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
|
||||
// Every callback leads with the origin session (J); see event.cpp.
|
||||
mpv_MpvPlayer_onPropertyChanged_SJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SZJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;ZJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SJJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SDJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;DJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;DJZ)V");
|
||||
mpv_MpvPlayer_onPropertyChanged_SSJZ =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(IJZDZ)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(IJZ)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;Ljava/lang/String;JZ)V");
|
||||
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(JIJZDZ)V");
|
||||
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZI)V");
|
||||
mpv_MpvPlayer_onLogMessage =
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
|
||||
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(JLjava/lang/String;ILjava/lang/String;)V");
|
||||
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(JLjava/lang/String;J)V");
|
||||
|
||||
methods_initialized = true;
|
||||
}
|
||||
|
||||
@@ -27,4 +27,4 @@ UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
|
||||
UTIL_EXTERN jclass mpv_MpvPlayer;
|
||||
UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_SJZ, mpv_MpvPlayer_onPropertyChanged_SZJZ,
|
||||
mpv_MpvPlayer_onPropertyChanged_SJJZ, mpv_MpvPlayer_onPropertyChanged_SDJZ, mpv_MpvPlayer_onPropertyChanged_SSJZ,
|
||||
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage;
|
||||
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage, mpv_MpvPlayer_onHook;
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define DEBUG 1
|
||||
|
||||
#define LOG_TAG "mpv"
|
||||
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#if DEBUG
|
||||
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define ALOGV(...) (void)0
|
||||
#endif
|
||||
|
||||
void die(const char* msg);
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -16,6 +16,7 @@ extern "C" {
|
||||
}
|
||||
|
||||
#include "event.h"
|
||||
#include "globals.h"
|
||||
#include "jni_utils.h"
|
||||
#include "log.h"
|
||||
|
||||
@@ -24,19 +25,48 @@ extern "C" {
|
||||
void render_cleanup(JNIEnv* env);
|
||||
|
||||
extern "C" {
|
||||
jni_func(void, nativeCreate, jobject appctx);
|
||||
jni_func(void, nativeInit);
|
||||
jni_func(void, nativeDestroy);
|
||||
jni_func(jlong, nativeCreate, jobject appctx);
|
||||
jni_func(jint, nativeInit, jlong session);
|
||||
jni_func(void, nativeDestroy, jlong session);
|
||||
|
||||
jni_func(void, nativeCommand, jobjectArray jarray);
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring level);
|
||||
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray);
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id);
|
||||
};
|
||||
|
||||
JavaVM* g_vm;
|
||||
mpv_handle* g_mpv;
|
||||
uint64_t g_session;
|
||||
pthread_rwlock_t g_session_lock = PTHREAD_RWLOCK_INITIALIZER;
|
||||
std::atomic<bool> g_event_thread_request_exit(false);
|
||||
|
||||
// Lifecycle serialization (L) is separate from session admission (S).
|
||||
// Only nativeCreate/nativeInit/nativeDestroy acquire L, always before S.
|
||||
static pthread_mutex_t lifecycle_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static uint64_t next_session = 0;
|
||||
static pthread_t event_thread_id;
|
||||
static std::mutex g_lifecycle_mutex;
|
||||
static bool event_thread_started = false;
|
||||
|
||||
// Held through retirement, termination and surface cleanup, so a successor
|
||||
// cannot overlap or rebind the retiring event thread. Callbacks never take L.
|
||||
class LifecycleGuard {
|
||||
public:
|
||||
LifecycleGuard() { pthread_mutex_lock(&lifecycle_lock); }
|
||||
~LifecycleGuard() { pthread_mutex_unlock(&lifecycle_lock); }
|
||||
LifecycleGuard(const LifecycleGuard&) = delete;
|
||||
LifecycleGuard& operator=(const LifecycleGuard&) = delete;
|
||||
};
|
||||
|
||||
// Bounded admission-write scope (S), always nested inside L. Never hold this
|
||||
// while joining or terminating: an event callback can reenter SessionGuard.
|
||||
class SessionWriteGuard {
|
||||
public:
|
||||
SessionWriteGuard() { pthread_rwlock_wrlock(&g_session_lock); }
|
||||
~SessionWriteGuard() { pthread_rwlock_unlock(&g_session_lock); }
|
||||
SessionWriteGuard(const SessionWriteGuard&) = delete;
|
||||
SessionWriteGuard& operator=(const SessionWriteGuard&) = delete;
|
||||
};
|
||||
|
||||
static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
setlocale(LC_NUMERIC, "C");
|
||||
@@ -49,78 +79,125 @@ static void prepare_environment(JNIEnv* env, jobject appctx) {
|
||||
init_methods_cache(env);
|
||||
}
|
||||
|
||||
jni_func(void, nativeCreate, jobject appctx) {
|
||||
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mpv_request_log_messages(g_mpv, "v");
|
||||
}
|
||||
|
||||
jni_func(void, nativeInit) {
|
||||
if (!g_mpv) {
|
||||
die("mpv is not created");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mpv_initialize(g_mpv) < 0) {
|
||||
die("mpv init failed");
|
||||
return;
|
||||
}
|
||||
|
||||
g_event_thread_request_exit = false;
|
||||
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
|
||||
die("thread create failed");
|
||||
return;
|
||||
}
|
||||
pthread_setname_np(event_thread_id, "event_thread");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// 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_event_thread_request_exit = true;
|
||||
mpv_wakeup(local_mpv);
|
||||
pthread_join(event_thread_id, NULL);
|
||||
|
||||
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(void, nativeCommand, jobjectArray jarray) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(jlong, nativeCreate, jobject appctx) {
|
||||
LifecycleGuard lock;
|
||||
mpv_handle* predecessor;
|
||||
{
|
||||
SessionWriteGuard admission;
|
||||
predecessor = revoke_locked();
|
||||
}
|
||||
if (predecessor) {
|
||||
ALOGE("destroying leaked mpv instance");
|
||||
destroy_locked(env, predecessor);
|
||||
}
|
||||
|
||||
// Do not rewrite process-wide JNI state while the predecessor can callback.
|
||||
prepare_environment(env, appctx);
|
||||
|
||||
SessionWriteGuard admission;
|
||||
g_mpv = mpv_create();
|
||||
if (!g_mpv) {
|
||||
die("context init failed");
|
||||
return 0;
|
||||
}
|
||||
g_session = ++next_session;
|
||||
|
||||
mpv_request_log_messages(g_mpv, "warn");
|
||||
return (jlong)g_session;
|
||||
}
|
||||
|
||||
jni_func(jint, nativeInit, jlong session) {
|
||||
LifecycleGuard lock;
|
||||
SessionWriteGuard admission;
|
||||
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const int result = mpv_initialize(g_mpv);
|
||||
if (result < 0) {
|
||||
ALOGE("mpv_initialize returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land
|
||||
// before mpv creates the decoder; file-loaded is already too late for the
|
||||
// MediaCodec path. on_preloaded runs after the demuxer opened the file and
|
||||
// holds playback until Kotlin continues it (MpvPlayer.onHook).
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
destroy_locked(env, local_mpv);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetLogLevel, jlong session, jstring jlevel) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const std::string level = java_string_to_utf8(env, jlevel);
|
||||
if (env->ExceptionCheck()) return MPV_ERROR_NOMEM;
|
||||
const int result = mpv_request_log_messages(guard.mpv, level.c_str());
|
||||
if (result < 0) ALOGE("mpv_request_log_messages returned error %s", mpv_error_string(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Runs a command synchronously. Returns the negative mpv error on failure,
|
||||
// the `playlist_entry_id` a `loadfile` created (always > 0), or 0 for a
|
||||
// command that succeeded without one. A retired session reports
|
||||
// MPV_ERROR_UNINITIALIZED like any other rejected command.
|
||||
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* arguments[128] = {0};
|
||||
int len = env->GetArrayLength(jarray);
|
||||
if (len >= (int)ARRAYLEN(arguments)) {
|
||||
die("too many command arguments");
|
||||
return;
|
||||
return MPV_ERROR_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
std::vector<std::string> storage;
|
||||
@@ -132,5 +209,32 @@ jni_func(void, nativeCommand, jobjectArray jarray) {
|
||||
env->DeleteLocalRef(jarg);
|
||||
}
|
||||
|
||||
mpv_command(g_mpv, arguments);
|
||||
mpv_node result{};
|
||||
const int status = mpv_command_ret(guard.mpv, arguments, &result);
|
||||
if (status < 0) {
|
||||
ALOGE("mpv_command(%s) returned error %s", len > 0 ? arguments[0] : "", mpv_error_string(status));
|
||||
return status;
|
||||
}
|
||||
|
||||
jlong playlist_entry_id = 0;
|
||||
const mpv_node_list* map = result.format == MPV_FORMAT_NODE_MAP ? result.u.list : nullptr;
|
||||
if (map && map->keys && map->values) {
|
||||
for (int i = 0; i < map->num; ++i) {
|
||||
if (map->keys[i] && strcmp(map->keys[i], "playlist_entry_id") == 0 && map->values[i].format == MPV_FORMAT_INT64) {
|
||||
playlist_entry_id = (jlong)map->values[i].u.int64;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
mpv_free_node_contents(&result);
|
||||
return playlist_entry_id;
|
||||
}
|
||||
|
||||
// A continuation for a revoked session is dropped, even while its handle is
|
||||
// still retiring. Destruction releases any outstanding hooks; the successor
|
||||
// must never receive an old hook id.
|
||||
jni_func(void, nativeHookContinue, jlong session, jlong id) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
mpv_hook_continue(guard.mpv, (uint64_t)id);
|
||||
}
|
||||
|
||||
@@ -9,49 +9,55 @@
|
||||
#include "log.h"
|
||||
|
||||
extern "C" {
|
||||
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
|
||||
jni_func(jint, nativeSetOptionString, jlong session, jstring option, jstring value);
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue);
|
||||
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring property, jint value);
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring property, jdouble value);
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring property);
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring property, jboolean value);
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty);
|
||||
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue);
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format);
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format);
|
||||
}
|
||||
|
||||
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
|
||||
CHECK_MPV_INIT_RET(0);
|
||||
jni_func(jint, nativeSetOptionString, jlong session, jstring joption, jstring jvalue) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* option = env->GetStringUTFChars(joption, NULL);
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
|
||||
int result = mpv_set_option_string(g_mpv, option, value.c_str());
|
||||
int result = mpv_set_option_string(guard.mpv, option, value.c_str());
|
||||
|
||||
env->ReleaseStringUTFChars(joption, option);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
// A retired session reads as "no value" and writes nowhere: the in-flight
|
||||
// hook handler of a torn-down player must not learn about, or reconfigure,
|
||||
// its successor.
|
||||
static int common_get_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* output) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_get_property(g_mpv, prop, format, output);
|
||||
int result = mpv_get_property(guard.mpv, prop, format, output);
|
||||
if (result < 0) ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
|
||||
CHECK_MPV_INIT_RET(-1);
|
||||
static int common_set_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* value) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
|
||||
|
||||
const char* prop = env->GetStringUTFChars(jproperty, NULL);
|
||||
int result = mpv_set_property(g_mpv, prop, format, value);
|
||||
int result = mpv_set_property(guard.mpv, prop, format, value);
|
||||
if (result < 0)
|
||||
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(jproperty, prop);
|
||||
@@ -59,57 +65,58 @@ static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format
|
||||
return result;
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring jproperty) {
|
||||
int64_t value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring jproperty) {
|
||||
double value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
|
||||
}
|
||||
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
|
||||
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring jproperty) {
|
||||
int value = 0;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
|
||||
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
|
||||
}
|
||||
|
||||
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
|
||||
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty) {
|
||||
char* value;
|
||||
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
if (common_get_property(env, session, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
|
||||
jstring jvalue = new_java_string(env, value);
|
||||
mpv_free(value);
|
||||
return jvalue;
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
|
||||
jni_func(void, nativeSetPropertyInt, jlong session, jstring jproperty, jint jvalue) {
|
||||
int64_t value = static_cast<int64_t>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_INT64, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
|
||||
jni_func(void, nativeSetPropertyDouble, jlong session, jstring jproperty, jdouble jvalue) {
|
||||
double value = static_cast<double>(jvalue);
|
||||
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
|
||||
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring jproperty, jboolean jvalue) {
|
||||
int value = jvalue == JNI_TRUE ? 1 : 0;
|
||||
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_FLAG, &value);
|
||||
}
|
||||
|
||||
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue) {
|
||||
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue) {
|
||||
const std::string value = java_string_to_utf8(env, jvalue);
|
||||
const char* value_ptr = value.c_str();
|
||||
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
common_set_property(env, session, jproperty, MPV_FORMAT_STRING, &value_ptr);
|
||||
}
|
||||
|
||||
jni_func(void, nativeObserveProperty, jstring property, jint format) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format) {
|
||||
SessionGuard guard(session);
|
||||
if (!guard.mpv) return;
|
||||
const char* prop = env->GetStringUTFChars(property, NULL);
|
||||
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
|
||||
int result = mpv_observe_property(guard.mpv, 0, prop, (mpv_format)format);
|
||||
if (result < 0) ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
|
||||
env->ReleaseStringUTFChars(property, prop);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,99 @@
|
||||
#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(void, nativeAttachSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachSurface);
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_);
|
||||
jni_func(void, nativeDetachOsdSurface);
|
||||
jni_func(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_);
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
jni_func(void, nativeAttachSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
|
||||
surface = env->NewGlobalRef(surface_);
|
||||
if (!surface) {
|
||||
die("invalid surface provided");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
// 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;
|
||||
|
||||
// The OSD plane of vo=mediacodec. Same lifetime rules as the video surface:
|
||||
// the global ref must outlive the VO, so detach only after vo has been unset.
|
||||
jni_func(void, nativeAttachOsdSurface, jobject surface_) {
|
||||
CHECK_MPV_INIT();
|
||||
jni_func(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;
|
||||
|
||||
osd_surface = env->NewGlobalRef(surface_);
|
||||
if (!osd_surface) {
|
||||
die("invalid osd surface provided");
|
||||
return;
|
||||
if (osd_surface_) {
|
||||
try {
|
||||
pending_osd_surfaces.reserve(pending_osd_surfaces.size() + 1);
|
||||
} catch (const std::bad_alloc&) {
|
||||
return MPV_ERROR_NOMEM;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 = NULL;
|
||||
surface = nullptr;
|
||||
}
|
||||
if (osd_surface) {
|
||||
env->DeleteGlobalRef(osd_surface);
|
||||
osd_surface = NULL;
|
||||
osd_surface = nullptr;
|
||||
}
|
||||
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
|
||||
pending_osd_surfaces.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,7 +6,9 @@ sealed interface MpvEvent {
|
||||
data class StartFile(override val sourceId: Long?) : MpvEvent
|
||||
data class EndFile(
|
||||
val reason: EndFileReason?,
|
||||
override val sourceId: Long?
|
||||
override val sourceId: Long?,
|
||||
/** mpv_error code when [reason] is [EndFileReason.Error]; null otherwise. */
|
||||
val error: MpvError? = null
|
||||
) : MpvEvent
|
||||
data class FileLoaded(override val sourceId: Long?) : MpvEvent
|
||||
data class PlaybackRestart(
|
||||
|
||||
@@ -18,10 +18,26 @@ import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
class MpvPlayer private constructor() : AutoCloseable {
|
||||
/**
|
||||
* Kotlin face of the process-global native player. Each instance is bound to
|
||||
* one immutable native [session]; the JNI layer refuses any call that names
|
||||
* a session it has retired and Kotlin drops any callback stamped with a
|
||||
* session other than the published wrapper's. Together they make a retired
|
||||
* wrapper's in-flight work (a hook handler still reshaping tracks, a queued
|
||||
* property write, a late hook continuation) inert against its successor,
|
||||
* and keep a retiring core's tail events out of the successor's flows.
|
||||
*/
|
||||
class MpvPlayer private constructor(
|
||||
/** Identity of the native session this wrapper owns; see nativeCreate. */
|
||||
val session: Long
|
||||
) : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** Upper bound for a hook handler; longer stalls playback start. */
|
||||
private const val HOOK_TIMEOUT_MS = 3_000L
|
||||
|
||||
init {
|
||||
System.loadLibrary("mpv")
|
||||
System.loadLibrary("player")
|
||||
@@ -39,64 +55,76 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
configure: MpvPlayerConfig.() -> Unit = {}
|
||||
): MpvPlayer = withContext(Dispatchers.IO) {
|
||||
checkNotMainThread("MPV initialization")
|
||||
val player = MpvPlayer()
|
||||
// Atomically replace; mark old as closed so its background close() skips nativeDestroy
|
||||
instance.getAndSet(player)?.also { it.closed = true }
|
||||
// nativeCreate's safety net handles any leaked native session
|
||||
// Retires any leaked predecessor natively and mints the new session.
|
||||
val player = MpvPlayer(nativeCreate(context.applicationContext))
|
||||
synchronized(instance) {
|
||||
val current = instance.get()
|
||||
// Sessions are monotonic: a concurrent create that already published a
|
||||
// newer one has retired this native session underneath us.
|
||||
if (current != null && current.session > player.session) {
|
||||
player.closed = true
|
||||
throw MpvException("MPV session ${player.session} was superseded before it initialized")
|
||||
}
|
||||
instance.set(player)
|
||||
// The predecessor's native session is gone; its close() finds nothing to destroy.
|
||||
current?.closed = true
|
||||
}
|
||||
try {
|
||||
nativeCreate(context.applicationContext)
|
||||
MpvPlayerConfig().apply(configure)
|
||||
nativeInit()
|
||||
MpvPlayerConfig(player.session).apply(configure)
|
||||
val result = nativeInit(player.session)
|
||||
if (result < 0) throw MpvException("Failed to initialize mpv: error $result")
|
||||
ensureActive()
|
||||
player
|
||||
} catch (e: Throwable) {
|
||||
instance.compareAndSet(player, null)
|
||||
try {
|
||||
nativeDestroy()
|
||||
} catch (_: Throwable) {}
|
||||
player.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// JNI callbacks — called from native event thread
|
||||
// JNI callbacks — called from the native event thread. Each names the
|
||||
// session it originated from; only the wrapper published for that
|
||||
// session may receive it.
|
||||
|
||||
private fun target(session: Long): MpvPlayer? = instance.get()?.takeIf { it.session == session }
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawPropertyChanges?.trySend(
|
||||
fun onPropertyChanged(session: Long, name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
|
||||
target(session)?.rawPropertyChanges?.trySend(
|
||||
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEvent(
|
||||
session: Long,
|
||||
eventId: Int,
|
||||
sourceId: Long,
|
||||
hasSourceId: Boolean,
|
||||
@@ -108,23 +136,34 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
sourceId.takeIf { hasSourceId },
|
||||
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
|
||||
) ?: return
|
||||
instance.get()?.rawEvents?.trySend(event)
|
||||
target(session)?.rawEvents?.trySend(event)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) {
|
||||
instance.get()?.rawEvents?.trySend(
|
||||
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean, error: Int) {
|
||||
target(session)?.rawEvents?.trySend(
|
||||
MpvEvent.EndFile(
|
||||
EndFileReason.fromId(reason),
|
||||
sourceId.takeIf { hasSourceId }
|
||||
sourceId.takeIf { hasSourceId },
|
||||
MpvError.fromCode(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onLogMessage(prefix: String, level: Int, text: String) {
|
||||
fun onLogMessage(session: Long, prefix: String, level: Int, text: String) {
|
||||
val logLevel = LogLevel.fromNative(level) ?: return
|
||||
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
target(session)?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onHook(session: Long, name: String, id: Long) {
|
||||
val player = target(session)
|
||||
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
|
||||
// Nobody will answer: release mpv rather than leave it waiting. The
|
||||
// native side drops this if the session has since been retired.
|
||||
nativeHookContinue(session, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotMainThread(operation: String) {
|
||||
@@ -133,45 +172,56 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native declarations — private to avoid internal name mangling
|
||||
// JNI native declarations — private to avoid internal name mangling.
|
||||
// Every entry after nativeCreate names the session it acts for; the
|
||||
// native side refuses a retired one.
|
||||
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context)
|
||||
/** Retires any leaked native session and returns the new session's identity. */
|
||||
@JvmStatic private external fun nativeCreate(appctx: Context): Long
|
||||
|
||||
@JvmStatic private external fun nativeInit()
|
||||
/** 0 on success, otherwise a negative mpv error. */
|
||||
@JvmStatic private external fun nativeInit(session: Long): Int
|
||||
|
||||
@JvmStatic private external fun nativeDestroy()
|
||||
@JvmStatic private external fun nativeDestroy(session: Long)
|
||||
|
||||
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
|
||||
/** Negative mpv error, the playlist entry id a `loadfile` created, or 0 when the command returned none. */
|
||||
@JvmStatic private external fun nativeCommand(session: Long, cmd: Array<out String>): Long
|
||||
|
||||
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
|
||||
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
|
||||
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
|
||||
|
||||
@JvmStatic private external fun nativeDetachSurface()
|
||||
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
|
||||
|
||||
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
|
||||
@JvmStatic private external fun nativeAttachSurfaces(session: Long, surface: Surface, osdSurface: Surface?): Int
|
||||
|
||||
@JvmStatic private external fun nativeDetachOsdSurface()
|
||||
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
|
||||
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
|
||||
|
||||
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
|
||||
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
|
||||
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
|
||||
|
||||
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
|
||||
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
|
||||
|
||||
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
|
||||
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
|
||||
|
||||
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
|
||||
internal fun requestLogMessages(session: Long, level: String) {
|
||||
checkNotMainThread("MPV log level change")
|
||||
val result = nativeSetLogLevel(session, level)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set log level: error $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The native event thread hands everything to unbounded channels: trySend
|
||||
@@ -182,6 +232,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
// buffer, which silently dropped whatever arrived during a burst; losing
|
||||
// e.g. the one cplayer log line that signals a failed video chain.
|
||||
private val rawEvents = Channel<MpvEvent>(Channel.UNLIMITED)
|
||||
private val rawHooks = Channel<Hook>(Channel.UNLIMITED)
|
||||
private val rawPropertyChanges = Channel<PropertyChange>(Channel.UNLIMITED)
|
||||
private val rawLogMessages = Channel<LogMessage>(Channel.UNLIMITED)
|
||||
|
||||
@@ -191,8 +242,36 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
private val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private class Hook(val name: String, val id: Long)
|
||||
|
||||
/**
|
||||
* Handler for mpv hooks the native side registered (`on_preloaded`). mpv
|
||||
* holds playback until the handler returns; a handler that throws or
|
||||
* overruns [HOOK_TIMEOUT_MS] is abandoned and playback continues. Set it
|
||||
* before loading a file; unset, hooks continue immediately.
|
||||
*/
|
||||
@Volatile var hookHandler: (suspend (name: String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
pumpScope.launch { for (e in rawEvents) events.emit(e) }
|
||||
pumpScope.launch {
|
||||
for (hook in rawHooks) {
|
||||
try {
|
||||
val handler = if (closed) null else hookHandler
|
||||
if (handler != null) {
|
||||
withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) }
|
||||
?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback")
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) }
|
||||
pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) }
|
||||
}
|
||||
@@ -203,89 +282,88 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
// Commands
|
||||
|
||||
suspend fun command(vararg args: String) {
|
||||
/**
|
||||
* Runs an mpv command. `loadfile` returns the id of the playlist entry it created — the
|
||||
* `sourceId` carried by that source's start-file / playback-restart / end-file events; every
|
||||
* other command returns null. A command mpv rejects throws [MpvException]: a rejected load
|
||||
* never produces a source, so the caller must not wait for one.
|
||||
*/
|
||||
suspend fun command(vararg args: String): Long? {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeCommand(args) }
|
||||
val status = withContext(Dispatchers.IO) { nativeCommand(session, args) }
|
||||
if (status < 0) throw MpvException("Command '${args.firstOrNull() ?: ""}' failed: error $status")
|
||||
return if (status > 0) status else null
|
||||
}
|
||||
|
||||
// Surface — not suspend, called from SurfaceHolder.Callback
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
/** Called on the core's ordered IO writer, without suspending between writes. */
|
||||
fun setLogLevel(level: String) {
|
||||
checkNotClosed()
|
||||
nativeAttachSurface(surface)
|
||||
requestLogMessages(session, level)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
/** Installs both planes and synchronously rebuilds the VO on the core's ordered IO writer. */
|
||||
fun attachSurfaces(surface: Surface, osdSurface: Surface?) {
|
||||
checkNotClosed()
|
||||
nativeDetachSurface()
|
||||
}
|
||||
|
||||
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
|
||||
fun attachOsdSurface(surface: Surface) {
|
||||
checkNotClosed()
|
||||
nativeAttachOsdSurface(surface)
|
||||
}
|
||||
|
||||
fun detachOsdSurface() {
|
||||
checkNotClosed()
|
||||
nativeDetachOsdSurface()
|
||||
checkNotMainThread("MPV surface handoff")
|
||||
val result = nativeAttachSurfaces(session, surface, osdSurface)
|
||||
if (result < 0) throw MpvException("Failed to attach MPV surfaces: error $result")
|
||||
}
|
||||
|
||||
// Property getters
|
||||
|
||||
suspend fun getInt(name: String): Int? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyInt(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getDouble(name: String): Double? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getFlag(name: String): Boolean? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(session, name) }
|
||||
}
|
||||
|
||||
suspend fun getString(name: String): String? {
|
||||
checkNotClosed()
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
|
||||
return withContext(Dispatchers.IO) { nativeGetPropertyString(session, name) }
|
||||
}
|
||||
|
||||
// Property setters
|
||||
|
||||
suspend fun setProperty(name: String, value: Int) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyInt(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Double) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyDouble(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: Boolean) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(session, name, value) }
|
||||
}
|
||||
|
||||
suspend fun setProperty(name: String, value: String) {
|
||||
checkNotClosed()
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
|
||||
withContext(Dispatchers.IO) { nativeSetPropertyString(session, name, value) }
|
||||
}
|
||||
|
||||
// Property observation
|
||||
|
||||
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, format.nativeValue)
|
||||
nativeObserveProperty(session, name, format.nativeValue)
|
||||
return propertyFlow.filter { it.name == name }
|
||||
}
|
||||
|
||||
fun observeFlag(name: String): Flow<Boolean> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Flag.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Flag>()
|
||||
.filter { it.name == name }
|
||||
@@ -294,7 +372,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeInt(name: String): Flow<Long> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Int64.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Int64>()
|
||||
.filter { it.name == name }
|
||||
@@ -303,7 +381,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeDouble(name: String): Flow<Double> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.Double.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Double>()
|
||||
.filter { it.name == name }
|
||||
@@ -312,7 +390,7 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
|
||||
fun observeString(name: String): Flow<String> {
|
||||
checkNotClosed()
|
||||
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
|
||||
nativeObserveProperty(session, name, PropertyFormat.String.nativeValue)
|
||||
return propertyFlow
|
||||
.filterIsInstance<PropertyChange.Str>()
|
||||
.filter { it.name == name }
|
||||
@@ -329,17 +407,20 @@ class MpvPlayer private constructor() : AutoCloseable {
|
||||
* Android main thread so a slow vendor decoder cannot stall the UI.
|
||||
*/
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
checkNotMainThread("MPV destruction")
|
||||
closed = true
|
||||
// Only destroy native if we're still the active player.
|
||||
// If create() already replaced us, nativeCreate's safety net handles native cleanup.
|
||||
if (instance.compareAndSet(this, null)) {
|
||||
nativeDestroy()
|
||||
synchronized(instance) {
|
||||
if (closed) return
|
||||
checkNotMainThread("MPV destruction")
|
||||
closed = true
|
||||
hookHandler = null
|
||||
instance.compareAndSet(this, null)
|
||||
}
|
||||
// After nativeDestroy no callback can produce: closing the channels
|
||||
// lets each pump drain what is already queued and then complete.
|
||||
// A no-op natively when a later create() already retired this session;
|
||||
// the successor is never ours to destroy.
|
||||
nativeDestroy(session)
|
||||
// After nativeDestroy no callback can produce for this session: closing
|
||||
// the channels lets each pump drain what is already queued and complete.
|
||||
rawEvents.close()
|
||||
rawHooks.close()
|
||||
rawPropertyChanges.close()
|
||||
rawLogMessages.close()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.edde746.plezy.libmpv
|
||||
|
||||
class MpvPlayerConfig internal constructor() {
|
||||
/** Pre-initialize configuration of one native session; every write names it. */
|
||||
class MpvPlayerConfig internal constructor(private val session: Long) {
|
||||
fun setOption(name: String, value: String) {
|
||||
val result = MpvPlayer.setOptionString(name, value)
|
||||
val result = MpvPlayer.setOptionString(session, name, value)
|
||||
if (result < 0) {
|
||||
throw MpvException("Failed to set option '$name' to '$value': error $result")
|
||||
}
|
||||
}
|
||||
|
||||
fun setLogLevel(level: String) {
|
||||
MpvPlayer.requestLogMessages(session, level)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- os_media_controls (0.2.4):
|
||||
- Flutter
|
||||
- universal_gamepad (1.5.8):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
universal_gamepad:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
|
||||
|
||||
PODFILE CHECKSUM: 0adb115f74a3cde46cf0e15649428c9b4e6975a0
|
||||
|
||||
@@ -802,7 +802,7 @@
|
||||
repositoryURL = "https://github.com/edde746/mpv-build";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = dafa7762af20052031a7b512c9761a5d8bde327d;
|
||||
revision = 6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/mpv-build",
|
||||
"state" : {
|
||||
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
|
||||
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 ./scripts/upload-symbols.sh ios")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store BUGS_APPLE_ARCHIVE=build/ios/archive/Runner.xcarchive ./scripts/upload-symbols.sh ios")
|
||||
|
||||
upload_to_app_store(
|
||||
ipa: "../build/ios/ipa/Plezy.ipa",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -232,6 +234,14 @@ KeyEventResult _handleInputKey({
|
||||
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
|
||||
}
|
||||
|
||||
// A TV back press this field already claimed on KeyDown (its own onBack
|
||||
// below, or the host closing a native session) leaves the matching KeyUp
|
||||
// in flight: swallow it here regardless of onBack, so a field without one
|
||||
// never lets the KeyUp bubble to an ancestor that acts on KeyUp.
|
||||
if (event.logicalKey.isBackKey && PlatformDetector.isTV() && BackKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
return finish(KeyEventResult.handled, 'suppressed-back');
|
||||
}
|
||||
|
||||
if (onBack != null && event.logicalKey.isBackKey) {
|
||||
// On TV the native text-input path can swallow the matching KeyUp (the
|
||||
// closing IME session eats it), so back fires on KeyDown — the same
|
||||
@@ -239,7 +249,6 @@ KeyEventResult _handleInputKey({
|
||||
// mark included so a parallel back dispatch still dedupes. Elsewhere the
|
||||
// shared handler's KeyUp semantics apply.
|
||||
if (PlatformDetector.isTV()) {
|
||||
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
|
||||
if (event is KeyDownEvent) {
|
||||
BackKeyCoordinator.markHandled();
|
||||
onBack();
|
||||
@@ -792,7 +801,29 @@ class _FocusableTextInputHost extends StatefulWidget {
|
||||
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
enum _NativeTextInputEndReason {
|
||||
dismissed,
|
||||
completed,
|
||||
explicitClose,
|
||||
restart,
|
||||
focusLost,
|
||||
configurationChanged,
|
||||
disposed,
|
||||
}
|
||||
|
||||
/// Kept after dismissal so an IME action already in flight can still complete
|
||||
/// this session, but never a replacement session on the same field.
|
||||
class _NativeTextInputSession {
|
||||
final FocusNode focusNode;
|
||||
_NativeTextInputEndReason? endReason;
|
||||
bool keyboardWasVisible = false;
|
||||
|
||||
_NativeTextInputSession(this.focusNode);
|
||||
|
||||
bool get isActive => endReason == null;
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with WidgetsBindingObserver {
|
||||
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
@@ -806,10 +837,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
bool _suppressTvKeyboardAutoOpen = false;
|
||||
bool _hasSeenTvKeyboardFocus = false;
|
||||
bool _suppressTvKeyboardForCurrentFocus = false;
|
||||
bool _nativeTextInputActivated = false;
|
||||
_NativeTextInputSession? _nativeTextInputSession;
|
||||
int _nativeTextInputGeneration = 0;
|
||||
ui.FlutterView? _nativeTextInputView;
|
||||
bool _hasSeenNativeTextInputFocus = false;
|
||||
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||
bool _nativeTextInputCompletionHandled = false;
|
||||
|
||||
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
|
||||
|
||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
@@ -820,6 +854,40 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
widget.input.tvTextInputController?._attach(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_nativeTextInputView != null) {
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
_nativeTextInputSession?.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final session = _nativeTextInputSession;
|
||||
if (!mounted || session == null || !session.isActive || !session.focusNode.hasFocus) return;
|
||||
final view = View.of(context);
|
||||
if (!identical(view, _nativeTextInputView)) {
|
||||
_nativeTextInputView = view;
|
||||
session.keyboardWasVisible = view.viewInsets.bottom > 0;
|
||||
_nativeTextInputGeneration++;
|
||||
return;
|
||||
}
|
||||
if (view.viewInsets.bottom > 0) {
|
||||
session.keyboardWasVisible = true;
|
||||
} else if (session.keyboardWasVisible) {
|
||||
// Leanback can consume Back and hide without closing the connection or
|
||||
// sending performAction. Zero before any visible metrics is not a hide:
|
||||
// the IME may still be opening, or this may be a hardware keyboard.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -836,13 +904,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_hasSeenTvKeyboardFocus = false;
|
||||
_suppressTvKeyboardForCurrentFocus = false;
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
|
||||
_nativeTextInputActivated = false;
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior ||
|
||||
oldWidget.input.enabled != widget.input.enabled ||
|
||||
oldWidget.input.controller != widget.input.controller ||
|
||||
oldWidget.input.keyboardType != widget.input.keyboardType ||
|
||||
oldWidget.input.maxLines != widget.input.maxLines) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
@@ -854,6 +926,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.input.tvTextInputController?._detach(this);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.disposed, rebuild: false);
|
||||
_restoreInstalledHandler();
|
||||
// The keyboard is a navigator route — it must not outlive the field that
|
||||
// opened it (e.g. a form section swapped out while the keyboard is up).
|
||||
@@ -895,11 +968,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final scope = node.enclosingScope;
|
||||
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
final generation = _nativeTextInputGeneration;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || generation != _nativeTextInputGeneration) return;
|
||||
final target = _installedFocusNode;
|
||||
if (target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||
if (!identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||
// Set before requesting focus so the resulting focus-change callback
|
||||
// cannot reopen the keyboard we were just dismissed out of.
|
||||
@@ -913,7 +987,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
||||
if (!focused) {
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
|
||||
return;
|
||||
}
|
||||
if (_suppressNativeTextInputForCurrentFocus) return;
|
||||
@@ -929,47 +1003,109 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
case TvTextInputAutoOpenBehavior.automatic:
|
||||
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenNativeTextInputFocus) {
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
return;
|
||||
}
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
case TvTextInputAutoOpenBehavior.never:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputActivated(bool activated) {
|
||||
if (_nativeTextInputActivated == activated) return;
|
||||
if (activated) _nativeTextInputCompletionHandled = false;
|
||||
if (!mounted) {
|
||||
_nativeTextInputActivated = activated;
|
||||
void _beginNativeTextInput() {
|
||||
final node = _installedFocusNode;
|
||||
if (_nativeTextInputActivated || node == null || !node.hasFocus) return;
|
||||
_nativeTextInputGeneration++;
|
||||
_nativeTextInputSession = _NativeTextInputSession(node);
|
||||
if (!PlatformDetector.isAppleTV() && defaultTargetPlatform == TargetPlatform.android) {
|
||||
// Observe the owning view, not MediaQuery: Scaffold removes the bottom
|
||||
// inset before building its body. A focus handoff can keep the same IME
|
||||
// visible, so sample the current view rather than wait for another show.
|
||||
_nativeTextInputView = View.of(context);
|
||||
_nativeTextInputSession!.keyboardWasVisible = _nativeTextInputView!.viewInsets.bottom > 0;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
_syncNativeTextInputFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _endNativeTextInput(_NativeTextInputEndReason reason, {bool rebuild = true}) {
|
||||
final session = _nativeTextInputSession;
|
||||
if (session == null) {
|
||||
if (reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.configurationChanged ||
|
||||
reason == _NativeTextInputEndReason.disposed) {
|
||||
_nativeTextInputGeneration++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() => _nativeTextInputActivated = activated);
|
||||
if (session.endReason == reason) return;
|
||||
final wasActive = session.isActive;
|
||||
session.endReason = reason;
|
||||
session.keyboardWasVisible = false;
|
||||
_nativeTextInputGeneration++;
|
||||
if (_nativeTextInputView != null) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_nativeTextInputView = null;
|
||||
}
|
||||
if (reason == _NativeTextInputEndReason.dismissed ||
|
||||
reason == _NativeTextInputEndReason.completed ||
|
||||
reason == _NativeTextInputEndReason.explicitClose ||
|
||||
reason == _NativeTextInputEndReason.restart) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
}
|
||||
_setNativeTextInputFocused(false);
|
||||
if (wasActive && rebuild && mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _scheduleNativeTextInputReactivation() {
|
||||
final generation = _nativeTextInputGeneration;
|
||||
final node = _installedFocusNode;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(node, _installedFocusNode) ||
|
||||
node?.hasFocus != true) {
|
||||
return;
|
||||
}
|
||||
_activateNativeTextInput();
|
||||
});
|
||||
}
|
||||
|
||||
void _activateNativeTextInput() {
|
||||
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(true);
|
||||
_beginNativeTextInput();
|
||||
}
|
||||
|
||||
VoidCallback? get _effectiveOnEditingComplete {
|
||||
final input = widget.input;
|
||||
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
|
||||
return _handleNativeEditingComplete;
|
||||
final session = _nativeTextInputSession;
|
||||
return () => _handleNativeEditingComplete(session);
|
||||
}
|
||||
|
||||
void _handleNativeEditingComplete() {
|
||||
if (_nativeTextInputCompletionHandled) return;
|
||||
_nativeTextInputCompletionHandled = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
void _handleNativeEditingComplete(_NativeTextInputSession? session) {
|
||||
if (!mounted ||
|
||||
session == null ||
|
||||
!identical(session, _nativeTextInputSession) ||
|
||||
!session.focusNode.hasFocus ||
|
||||
!widget.input.enabled ||
|
||||
!widget.input._usesNativeTvKeyboard) {
|
||||
return;
|
||||
}
|
||||
// Hide and performAction are separate platform notifications. A hide is
|
||||
// not completion, but must not consume a real Done/Previous delivered for
|
||||
// this same connection before EditableText detaches it at rebuild. Accept
|
||||
// that action once even if metrics won the race; every other terminal
|
||||
// reason (or a successor session) makes the callback stale.
|
||||
if (!session.isActive && session.endReason != _NativeTextInputEndReason.dismissed) return;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.completed);
|
||||
|
||||
final input = widget.input;
|
||||
final callback = input._effectiveOnEditingComplete;
|
||||
@@ -1139,9 +1275,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
/// that must show results instead of the keyboard). Suppresses auto-reopen
|
||||
/// so a field that keeps or regains focus does not relaunch it.
|
||||
void _dismissTvKeyboard() {
|
||||
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
|
||||
// No-op for the Flutter presentation when no overlay is up: setting its
|
||||
@@ -1161,12 +1296,20 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
|
||||
}
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
final generation = _nativeTextInputGeneration;
|
||||
focusNode.requestFocus();
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
||||
if (!mounted ||
|
||||
generation != _nativeTextInputGeneration ||
|
||||
!identical(focusNode, _installedFocusNode) ||
|
||||
focusNode.hasFocus ||
|
||||
_tvKeyboardOpen ||
|
||||
_tvKeyboardOpenScheduled) {
|
||||
return;
|
||||
}
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
});
|
||||
@@ -1184,11 +1327,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
return;
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
final generation = _nativeTextInputGeneration;
|
||||
// Focus lands in FocusManager's microtask. Activating before that would be
|
||||
// undone by the focus sync this frame's build already scheduled, which
|
||||
// deactivates an unfocused field.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
|
||||
if (mounted &&
|
||||
generation == _nativeTextInputGeneration &&
|
||||
identical(focusNode, _installedFocusNode) &&
|
||||
focusNode.hasFocus) {
|
||||
_openTextInputForFocusedField();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1238,8 +1387,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
// keyboard while Flutter focus stayed on the field. Restore the
|
||||
// read-only gate so this press navigates Flutter instead of reopening
|
||||
// the input connection.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
_endNativeTextInput(
|
||||
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
|
||||
);
|
||||
activateNativeTextInput = true;
|
||||
if (event.logicalKey.isBackKey) {
|
||||
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
||||
@@ -1247,30 +1397,28 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.isTvSelectEvent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.logicalKey.isBackKey) {
|
||||
// Android: a healthy IME consumes Back to dismiss itself before the
|
||||
// app ever sees it. One arriving here means the keyboard is already
|
||||
// gone (or its key session is broken and MainActivity's repair budget
|
||||
// ran out): close the session and consume the press so it cannot also
|
||||
// pop the route underneath.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
// ran out): close the session and claim the whole press — coordinator
|
||||
// mark so a same-press platform popRoute dedupes, suppressor armed so
|
||||
// the matching KeyUp (delivered to this node with the session closed,
|
||||
// or to an ancestor acting on KeyUp) cannot pop the route underneath.
|
||||
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
|
||||
BackKeyCoordinator.markHandled();
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.isTvSelectEvent) {
|
||||
// Android: Select on a field whose keyboard was dismissed re-raises
|
||||
// it (EditText parity). Toggle the connection so the engine issues a
|
||||
// fresh TextInput.show; MainActivity's show-retry covers the
|
||||
// served-view race (#1051/#1079).
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
_endNativeTextInput(_NativeTextInputEndReason.restart);
|
||||
_scheduleNativeTextInputReactivation();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Android arrows fall through deliberately: a healthy visible IME
|
||||
@@ -1311,7 +1459,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
void _restoreInstalledHandler() {
|
||||
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
||||
_setNativeTextInputFocused(false);
|
||||
_nativeTextInputActivated = false;
|
||||
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final node = _installedFocusNode;
|
||||
if (node != null) {
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Yan menyu genişlənmiş qalır və məzmun sahəsi buna uyğunlaşır",
|
||||
"showUnwatchedCount": "Baxılmamış sayını göstər",
|
||||
"showUnwatchedCountDescription": "Seriallarda və mövsümlərdə baxılmamış seriya sayını göstər",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Kartlarda seriya nömrəsini göstər",
|
||||
"showEpisodeNumberOnCardsDescription": "Seriya kartlarında mövsüm və seriya nömrəsini göstər",
|
||||
"showSeasonPostersOnTabs": "Mərhələlərdə mövsüm posterlərini göstər",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Bir seriya bitdikdə növbətisini avtomatik başlat",
|
||||
"playNextCountdown": "Növbəti seriya geri sayımı",
|
||||
"playNextCountdownImmediate": "Dərhal oynat",
|
||||
"autoSkipIntro": "Girişi avtomatik ötür",
|
||||
"autoSkipIntroDescription": "Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür",
|
||||
"autoSkipCredits": "Titrləri avtomatik ötür",
|
||||
"autoSkipCreditsDescription": "Titrləri avtomatik ötür və növbəti seriyanı oynat",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Ehtiyat işarələri məcburi et",
|
||||
"forceSkipMarkerFallbackDescription": "Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et",
|
||||
"autoSkipDelay": "Avtomatik ötürmə ləngiməsi",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Kitabxananızda yoxdur",
|
||||
"inTheseLibraries": "Bu kitabxanalarda var",
|
||||
"checkingLibrary": "Kitabxananız yoxlanılır...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hələlik burada heç nə yoxdur",
|
||||
"emptyMessage": "${source} mənbəsindən olan sətirlər burada görünəcək.",
|
||||
"searchHint": "${source} daxilində axtar",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Rele serveri vaxtında cavab vermədi",
|
||||
"connectionLost": "Bağlantı seans hazır olmamış kəsildi",
|
||||
"invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi",
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi"
|
||||
"sessionEnded": "Təşkilatçı seansı bitirdi",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr sessiya kukisi yaratmadı",
|
||||
"freshCookieRejected": "Seerr yeni sessiya kukisini rədd etdi",
|
||||
"noUserInformation": "Seerr istifadəçi məlumatlarını qaytarmadı",
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
|
||||
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Xidmətlər",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Страничната лента остава разгъната и зоната със съдържание се наглася да пасне",
|
||||
"showUnwatchedCount": "Показвай броя негледани",
|
||||
"showUnwatchedCountDescription": "Показвай броя негледани епизоди при сериали и сезони",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Показвай номера на епизода върху картите",
|
||||
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
|
||||
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
|
||||
"playNextCountdown": "Отброяване до следващия епизод",
|
||||
"playNextCountdownImmediate": "Пусни веднага",
|
||||
"autoSkipIntro": "Автоматично прескачане на интро",
|
||||
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
|
||||
"autoSkipCredits": "Автоматично прескачане на финални надписи",
|
||||
"autoSkipCreditsDescription": "Автоматично прескачай финалните надписи и пускай следващия епизод",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Принуди резервни маркери",
|
||||
"forceSkipMarkerFallbackDescription": "Използвай шаблони в заглавията на главите дори когато Plex има маркери",
|
||||
"autoSkipDelay": "Забавяне за автоматично прескачане",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
|
||||
"playbackCancelled": "Възпроизвеждането беше отменено.",
|
||||
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
|
||||
"errorLoadingSeries": "Грешка при зареждане на сериала",
|
||||
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Не е в твоята библиотека",
|
||||
"inTheseLibraries": "В тези библиотеки",
|
||||
"checkingLibrary": "Проверка на твоята библиотека...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Тук все още няма нищо",
|
||||
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
|
||||
"searchHint": "Търсене в ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Релейният сървър не отговори навреме",
|
||||
"connectionLost": "Връзката се затвори, преди сесията да е готова",
|
||||
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
|
||||
"sessionEnded": "Организаторът прекрати сесията"
|
||||
"sessionEnded": "Организаторът прекрати сесията",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
|
||||
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
|
||||
"noUserInformation": "Seerr не върна информация за потребителя",
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
|
||||
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig",
|
||||
"showUnwatchedCount": "Vis antal usete",
|
||||
"showUnwatchedCountDescription": "Vis antal usete episoder på serier og sæsoner",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
|
||||
"showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort",
|
||||
"showSeasonPostersOnTabs": "Vis sæsonplakater på faner",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start automatisk næste afsnit, når et afsnit slutter",
|
||||
"playNextCountdown": "Nedtælling til næste afsnit",
|
||||
"playNextCountdownImmediate": "Afspil med det samme",
|
||||
"autoSkipIntro": "Spring intro over automatisk",
|
||||
"autoSkipIntroDescription": "Spring automatisk intromarkører over efter få sekunder",
|
||||
"autoSkipCredits": "Spring rulletekster over automatisk",
|
||||
"autoSkipCreditsDescription": "Spring automatisk rulleteksterne over, og afspil næste episode",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tving reservemarkører",
|
||||
"forceSkipMarkerFallbackDescription": "Brug mønstre i kapiteltitler, selv når Plex har markører",
|
||||
"autoSkipDelay": "Forsinkelse ved automatisk spring",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Ikke i dit bibliotek",
|
||||
"inTheseLibraries": "I disse biblioteker",
|
||||
"checkingLibrary": "Tjekker dit bibliotek...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Der er ikke noget her endnu",
|
||||
"emptyMessage": "Indholdsrækker fra ${source} vises her, når de har indhold.",
|
||||
"searchHint": "Søg i ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Relayserveren svarede ikke i tide",
|
||||
"connectionLost": "Forbindelsen blev lukket, før sessionen var klar",
|
||||
"invalidRelayResponse": "Relayserveren sendte et uventet svar",
|
||||
"sessionEnded": "Værten afsluttede sessionen"
|
||||
"sessionEnded": "Værten afsluttede sessionen",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr udstedte ikke en sessionscookie",
|
||||
"freshCookieRejected": "Seerr afviste den nye sessionscookie",
|
||||
"noUserInformation": "Seerr returnerede ikke brugeroplysninger",
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
|
||||
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
|
||||
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
|
||||
"showUnwatchedCountDescription": "Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Episodennummer auf Karten anzeigen",
|
||||
"showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen",
|
||||
"showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
|
||||
"playNextCountdown": "Countdown bis zur nächsten Episode",
|
||||
"playNextCountdownImmediate": "Sofort abspielen",
|
||||
"autoSkipIntro": "Intro automatisch überspringen",
|
||||
"autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen",
|
||||
"autoSkipCredits": "Abspann automatisch überspringen",
|
||||
"autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Ersatzmarkierungen erzwingen",
|
||||
"forceSkipMarkerFallbackDescription": "Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt",
|
||||
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Nicht in deiner Mediathek",
|
||||
"inTheseLibraries": "In diesen Mediatheken",
|
||||
"checkingLibrary": "Deine Mediathek wird überprüft …",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hier ist noch nichts",
|
||||
"emptyMessage": "Zeilen aus ${source} erscheinen hier, sobald sie Inhalte enthalten.",
|
||||
"searchHint": "${source} durchsuchen",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
|
||||
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
|
||||
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet"
|
||||
"sessionEnded": "Der Host hat die Sitzung beendet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr hat kein Sitzungscookie ausgestellt",
|
||||
"freshCookieRejected": "Seerr hat das neue Sitzungscookie abgelehnt",
|
||||
"noUserInformation": "Seerr hat keine Benutzerinformationen zurückgegeben",
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
|
||||
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Dienste",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
|
||||
"showUnwatchedCount": "Show Unwatched Count",
|
||||
"showUnwatchedCountDescription": "Display unwatched episode count on shows and seasons",
|
||||
"showWatchedIndicators": "Show Watched Indicators",
|
||||
"showWatchedIndicatorsDescription": "Display a checkmark on watched movies, shows, and episodes",
|
||||
"showEpisodeNumberOnCards": "Show Episode Number on Cards",
|
||||
"showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards",
|
||||
"showSeasonPostersOnTabs": "Show Season Posters on Tabs",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
|
||||
"playNextCountdown": "Play Next Countdown",
|
||||
"playNextCountdownImmediate": "Play immediately",
|
||||
"autoSkipIntro": "Auto Skip Intro",
|
||||
"autoSkipIntroDescription": "Automatically skip intro markers after a few seconds",
|
||||
"autoSkipCredits": "Auto Skip Credits",
|
||||
"autoSkipCreditsDescription": "Automatically skip credits and play next episode",
|
||||
"skipIntroMode": "Skip Intro",
|
||||
"skipIntroModeOffDescription": "Play intros normally without a skip button",
|
||||
"skipIntroModeButtonDescription": "Show a skip button when an intro starts",
|
||||
"skipIntroModeAutoDescription": "Skip intros automatically after the delay below",
|
||||
"skipCreditsMode": "Skip Credits",
|
||||
"skipCreditsModeOffDescription": "Play credits normally without a skip button",
|
||||
"skipCreditsModeButtonDescription": "Show a skip button when credits start",
|
||||
"skipCreditsModeAutoDescription": "Skip credits automatically and play the next episode",
|
||||
"skipMarkerModeOff": "Off",
|
||||
"skipMarkerModeButton": "Show button",
|
||||
"skipMarkerModeAuto": "Automatic",
|
||||
"forceSkipMarkerFallback": "Force Fallback Markers",
|
||||
"forceSkipMarkerFallbackDescription": "Use chapter title patterns even when Plex has markers",
|
||||
"autoSkipDelay": "Auto Skip Delay",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Not in your library",
|
||||
"inTheseLibraries": "In these libraries",
|
||||
"checkingLibrary": "Checking your library...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "Couldn't check ${n} server",
|
||||
"other": "Couldn't check ${n} servers"
|
||||
},
|
||||
"emptyTitle": "Nothing here yet",
|
||||
"emptyMessage": "Rows from ${source} will appear here once they have content.",
|
||||
"searchHint": "Search ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "The relay did not respond in time",
|
||||
"connectionLost": "The connection closed before the session was ready",
|
||||
"invalidRelayResponse": "The relay sent an unexpected response",
|
||||
"sessionEnded": "The host ended the session"
|
||||
"sessionEnded": "The host ended the session",
|
||||
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr did not issue a session cookie",
|
||||
"freshCookieRejected": "Seerr rejected the new session cookie",
|
||||
"noUserInformation": "Seerr did not return user information",
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
|
||||
"sessionRejectedAfterReauth": "The session was rejected after signing in again",
|
||||
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
|
||||
"permissionRevoked": "You no longer have permission to request this"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
|
||||
"showUnwatchedCount": "Mostrar el número de elementos no vistos",
|
||||
"showUnwatchedCountDescription": "Mostrar el número de episodios no vistos en series y temporadas",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Mostrar número de episodio en las tarjetas",
|
||||
"showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio",
|
||||
"showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
|
||||
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
|
||||
"playNextCountdownImmediate": "Reproducir de inmediato",
|
||||
"autoSkipIntro": "Saltar introducción automáticamente",
|
||||
"autoSkipIntroDescription": "Saltar automáticamente los marcadores de introducción después de unos segundos",
|
||||
"autoSkipCredits": "Saltar créditos automáticamente",
|
||||
"autoSkipCreditsDescription": "Saltar automáticamente los créditos y reproducir el episodio siguiente",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forzar marcadores alternativos",
|
||||
"forceSkipMarkerFallbackDescription": "Usar patrones de títulos de capítulos aunque Plex tenga marcadores",
|
||||
"autoSkipDelay": "Retraso del salto automático",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "No está en tu biblioteca",
|
||||
"inTheseLibraries": "En estas bibliotecas",
|
||||
"checkingLibrary": "Comprobando tu biblioteca...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Aquí no hay nada todavía",
|
||||
"emptyMessage": "Las filas de ${source} aparecerán aquí cuando tengan contenido.",
|
||||
"searchHint": "Buscar en ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "El servidor de retransmisión no respondió a tiempo",
|
||||
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
|
||||
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
|
||||
"sessionEnded": "El anfitrión finalizó la sesión"
|
||||
"sessionEnded": "El anfitrión finalizó la sesión",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr no proporcionó una cookie de sesión",
|
||||
"freshCookieRejected": "Seerr rechazó la nueva cookie de sesión",
|
||||
"noUserInformation": "Seerr no devolvió información del usuario",
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
|
||||
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
|
||||
"showUnwatchedCount": "Afficher le nombre d’éléments non vus",
|
||||
"showUnwatchedCountDescription": "Afficher le nombre d’épisodes non vus pour les séries et les saisons",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Afficher le numéro de l’épisode sur les cartes",
|
||||
"showEpisodeNumberOnCardsDescription": "Afficher les numéros de saison et d’épisode sur les cartes d’épisode",
|
||||
"showSeasonPostersOnTabs": "Afficher les affiches de saison sur les onglets",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
|
||||
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
|
||||
"playNextCountdownImmediate": "Lire immédiatement",
|
||||
"autoSkipIntro": "Passer automatiquement l’introduction",
|
||||
"autoSkipIntroDescription": "Passer automatiquement les marqueurs d’introduction après quelques secondes",
|
||||
"autoSkipCredits": "Passer automatiquement le générique",
|
||||
"autoSkipCreditsDescription": "Passer automatiquement le générique et lire l’épisode suivant",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forcer les marqueurs de secours",
|
||||
"forceSkipMarkerFallbackDescription": "Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs",
|
||||
"autoSkipDelay": "Délai avant le saut automatique",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Absent de votre bibliothèque",
|
||||
"inTheseLibraries": "Dans ces bibliothèques",
|
||||
"checkingLibrary": "Vérification de votre bibliothèque...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Rien ici pour l'instant",
|
||||
"emptyMessage": "Les lignes de ${source} apparaîtront ici dès qu’elles contiendront des éléments.",
|
||||
"searchHint": "Rechercher dans ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Le serveur relais n’a pas répondu à temps",
|
||||
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
|
||||
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
|
||||
"sessionEnded": "L’hôte a mis fin à la session"
|
||||
"sessionEnded": "L’hôte a mis fin à la session",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr n’a pas fourni de cookie de session",
|
||||
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
|
||||
"noUserInformation": "Seerr n’a renvoyé aucune information sur l’utilisateur",
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
|
||||
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Az oldalsáv kibontva marad, a tartalom területe igazodik hozzá",
|
||||
"showUnwatchedCount": "Nem látott elemek számának megjelenítése",
|
||||
"showUnwatchedCountDescription": "Megjeleníti a még nem látott epizódok számát a sorozatoknál és évadoknál",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Epizódszám megjelenítése a kártyákon",
|
||||
"showEpisodeNumberOnCardsDescription": "Megjeleníti az évad- és epizódszámot az epizódkártyákon",
|
||||
"showSeasonPostersOnTabs": "Évadborítók megjelenítése a füleken",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "A következő epizód automatikus elindítása, amikor az aktuális véget ér",
|
||||
"playNextCountdown": "Visszaszámlálás a következő epizódig",
|
||||
"playNextCountdownImmediate": "Azonnali lejátszás",
|
||||
"autoSkipIntro": "Intró automatikus átugrása",
|
||||
"autoSkipIntroDescription": "Az intrójelölők automatikus átugrása néhány másodperc után",
|
||||
"autoSkipCredits": "Stáblista automatikus átugrása",
|
||||
"autoSkipCreditsDescription": "A stáblista automatikus átugrása és a következő epizód lejátszása",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tartalék jelölők kényszerítése",
|
||||
"forceSkipMarkerFallbackDescription": "Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel",
|
||||
"autoSkipDelay": "Automatikus átugrás késleltetése",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Nincs a könyvtáradban",
|
||||
"inTheseLibraries": "Ezekben a könyvtárakban",
|
||||
"checkingLibrary": "Könyvtár ellenőrzése...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Még nincs itt semmi",
|
||||
"emptyMessage": "A(z) ${source} forrásból származó sorok itt fognak megjelenni, amint van tartalmuk.",
|
||||
"searchHint": "Keresés itt: ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "A relészerver nem válaszolt időben",
|
||||
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
|
||||
"invalidRelayResponse": "A relészerver váratlan választ küldött",
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet"
|
||||
"sessionEnded": "A házigazda befejezte a munkamenetet",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "A Seerr nem adott ki munkamenet-sütit",
|
||||
"freshCookieRejected": "A Seerr elutasította az új munkamenet-sütit",
|
||||
"noUserInformation": "A Seerr nem adott vissza felhasználói adatokat",
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
|
||||
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Szolgáltatások",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
|
||||
"showUnwatchedCount": "Mostra il numero di episodi non visti",
|
||||
"showUnwatchedCountDescription": "Mostra il numero di episodi non visti per serie e stagioni",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Mostra il numero dell'episodio sulle schede",
|
||||
"showEpisodeNumberOnCardsDescription": "Mostra il numero della stagione e dell'episodio sulle schede degli episodi",
|
||||
"showSeasonPostersOnTabs": "Mostra i poster delle stagioni nelle schede",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
|
||||
"playNextCountdown": "Conto alla rovescia del successivo",
|
||||
"playNextCountdownImmediate": "Riproduci subito",
|
||||
"autoSkipIntro": "Salta automaticamente la sigla iniziale",
|
||||
"autoSkipIntroDescription": "Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi",
|
||||
"autoSkipCredits": "Salta automaticamente i titoli di coda",
|
||||
"autoSkipCreditsDescription": "Salta automaticamente i titoli di coda e riproduce l'episodio successivo",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forza i marcatori di ripiego",
|
||||
"forceSkipMarkerFallbackDescription": "Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori",
|
||||
"autoSkipDelay": "Ritardo del salto automatico",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Non è nella tua libreria",
|
||||
"inTheseLibraries": "In queste librerie",
|
||||
"checkingLibrary": "Ricerca nella tua libreria...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Ancora niente qui",
|
||||
"emptyMessage": "Le sezioni di ${source} appariranno qui quando saranno disponibili dei contenuti.",
|
||||
"searchHint": "Cerca su ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Il server relay non ha risposto in tempo",
|
||||
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
|
||||
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
|
||||
"sessionEnded": "L’host ha terminato la sessione"
|
||||
"sessionEnded": "L’host ha terminato la sessione",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr non ha generato un cookie di sessione",
|
||||
"freshCookieRejected": "Seerr ha rifiutato il nuovo cookie di sessione",
|
||||
"noUserInformation": "Seerr non ha restituito le informazioni sull'utente",
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
|
||||
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servizi",
|
||||
|
||||
+23
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "サイドバーを展開したままにし、コンテンツ領域を幅に合わせて調整します",
|
||||
"showUnwatchedCount": "未視聴数を表示",
|
||||
"showUnwatchedCountDescription": "番組とシーズンに未視聴エピソード数を表示",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "カードにエピソード番号を表示",
|
||||
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
|
||||
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
|
||||
"playNextCountdown": "次回再生のカウントダウン",
|
||||
"playNextCountdownImmediate": "すぐに再生",
|
||||
"autoSkipIntro": "イントロを自動スキップ",
|
||||
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
|
||||
"autoSkipCredits": "クレジットを自動スキップ",
|
||||
"autoSkipCreditsDescription": "クレジットを自動的にスキップして次のエピソードを再生",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "フォールバックマーカーを強制",
|
||||
"forceSkipMarkerFallbackDescription": "Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します",
|
||||
"autoSkipDelay": "自動スキップの遅延",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
|
||||
"playbackCancelled": "再生がキャンセルされました。",
|
||||
"playbackFailed": "再生を開始できませんでした。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
|
||||
"errorLoadingSeries": "シリーズの読み込みエラー",
|
||||
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
||||
@@ -1248,6 +1259,9 @@
|
||||
"notInLibrary": "ライブラリにありません",
|
||||
"inTheseLibraries": "これらのライブラリにあります",
|
||||
"checkingLibrary": "ライブラリを確認中…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "まだ何もありません",
|
||||
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
|
||||
"searchHint": "${source}を検索",
|
||||
@@ -1637,7 +1651,8 @@
|
||||
"timedOut": "リレーサーバーが時間内に応答しませんでした",
|
||||
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
|
||||
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
|
||||
"sessionEnded": "ホストがセッションを終了しました"
|
||||
"sessionEnded": "ホストがセッションを終了しました",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2093,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
|
||||
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
|
||||
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
|
||||
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "サービス",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Жүйелік мәзір ашық күйінде қалады",
|
||||
"showUnwatchedCount": "Көрілмегендер санын көрсету",
|
||||
"showUnwatchedCountDescription": "Сериалдар мен маусымдарда көрілмеген бөлімдер санын көрсету",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Карточкаларда бөлім нөмірін көрсету",
|
||||
"showEpisodeNumberOnCardsDescription": "Бөлім карточкаларында маусым мен бөлім нөмірін көрсету",
|
||||
"showSeasonPostersOnTabs": "Қойындыларда маусым постерлерін көрсету",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
|
||||
"playNextCountdown": "Келесіні ойнату санағы",
|
||||
"playNextCountdownImmediate": "Дереу ойнату",
|
||||
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
|
||||
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
|
||||
"autoSkipCredits": "Титрлерді автоматты өткізу",
|
||||
"autoSkipCreditsDescription": "Титрлерді автоматты өткізіп, келесі бөлімді ойнату",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Қосалқы белгілерді мәжбүрлеу",
|
||||
"forceSkipMarkerFallbackDescription": "Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану",
|
||||
"autoSkipDelay": "Автоматты өткізу кідірісі",
|
||||
@@ -770,6 +779,8 @@
|
||||
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
|
||||
"playbackCancelled": "Ойнатудан бас тартылды.",
|
||||
"playbackFailed": "Ойнатуды іске қосу қатесі.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
|
||||
"errorLoadingSeries": "Сериалды жүктеу қатесі",
|
||||
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Кітапханаңызда жоқ",
|
||||
"inTheseLibraries": "Осы кітапханаларда бар",
|
||||
"checkingLibrary": "Кітапхана тексерілуде...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
|
||||
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
|
||||
"searchHint": "${source} ішінен іздеу",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Реле сервері уақытында жауап бермеді",
|
||||
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
|
||||
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
|
||||
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
|
||||
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
|
||||
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
|
||||
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Қызметтер",
|
||||
|
||||
+23
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
|
||||
"showUnwatchedCount": "미시청 수 표시",
|
||||
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "카드에 에피소드 번호 표시",
|
||||
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
|
||||
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "에피소드가 끝나면 다음 에피소드를 자동으로 재생",
|
||||
"playNextCountdown": "다음 재생 카운트다운",
|
||||
"playNextCountdownImmediate": "즉시 재생",
|
||||
"autoSkipIntro": "자동으로 오프닝 건너뛰기",
|
||||
"autoSkipIntroDescription": "몇 초 후 오프닝을 자동으로 건너뛰기",
|
||||
"autoSkipCredits": "자동으로 엔딩 건너뛰기",
|
||||
"autoSkipCreditsDescription": "엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "대체 마커 강제 사용",
|
||||
"forceSkipMarkerFallbackDescription": "Plex에 마커가 있어도 챕터 제목 패턴 사용",
|
||||
"autoSkipDelay": "자동 건너뛰기 지연",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
|
||||
"playbackCancelled": "재생이 취소되었습니다.",
|
||||
"playbackFailed": "재생을 시작할 수 없습니다.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
|
||||
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
||||
"musicNotSupported": "음악 재생 미지원",
|
||||
@@ -1248,6 +1259,9 @@
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"inTheseLibraries": "이 라이브러리에 있음",
|
||||
"checkingLibrary": "라이브러리 확인 중...",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "아직 아무것도 없습니다",
|
||||
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
|
||||
"searchHint": "${source}에서 검색",
|
||||
@@ -1637,7 +1651,8 @@
|
||||
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
|
||||
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
|
||||
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다"
|
||||
"sessionEnded": "호스트가 세션을 종료했습니다",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2093,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
|
||||
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
|
||||
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
|
||||
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "서비스",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidefeltet forblir utvidet og innholdsområdet tilpasser seg",
|
||||
"showUnwatchedCount": "Vis antall usette",
|
||||
"showUnwatchedCountDescription": "Vis antall usette episoder på serier og sesonger",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
|
||||
"showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort",
|
||||
"showSeasonPostersOnTabs": "Vis sesongplakater på faner",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start neste episode automatisk når en episode er ferdig",
|
||||
"playNextCountdown": "Nedtelling for neste episode",
|
||||
"playNextCountdownImmediate": "Spill umiddelbart",
|
||||
"autoSkipIntro": "Hopp over intro automatisk",
|
||||
"autoSkipIntroDescription": "Hopp automatisk over intromarkører etter noen sekunder",
|
||||
"autoSkipCredits": "Hopp over rulletekst automatisk",
|
||||
"autoSkipCreditsDescription": "Hopp automatisk over rulletekst og spill neste episode",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tving reservemarkører",
|
||||
"forceSkipMarkerFallbackDescription": "Bruk mønstre i kapiteltitler selv når Plex har markører",
|
||||
"autoSkipDelay": "Forsinkelse for automatisk hopp",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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å",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Ikke i biblioteket ditt",
|
||||
"inTheseLibraries": "I disse bibliotekene",
|
||||
"checkingLibrary": "Sjekker biblioteket ditt...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Ingenting her ennå",
|
||||
"emptyMessage": "Rader fra ${source} vises her når de har innhold.",
|
||||
"searchHint": "Søk i ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Reléserveren svarte ikke i tide",
|
||||
"connectionLost": "Tilkoblingen ble lukket før økten var klar",
|
||||
"invalidRelayResponse": "Reléserveren sendte et uventet svar",
|
||||
"sessionEnded": "Verten avsluttet økten"
|
||||
"sessionEnded": "Verten avsluttet økten",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
|
||||
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
|
||||
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
|
||||
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjenester",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan",
|
||||
"showUnwatchedCount": "Aantal ongekeken tonen",
|
||||
"showUnwatchedCountDescription": "Toon aantal ongekeken afleveringen bij series en seizoenen",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Afleveringsnummer op kaarten tonen",
|
||||
"showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten",
|
||||
"showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Start de volgende aflevering automatisch wanneer een aflevering eindigt",
|
||||
"playNextCountdown": "Aftellen voor volgende aflevering",
|
||||
"playNextCountdownImmediate": "Direct afspelen",
|
||||
"autoSkipIntro": "Intro automatisch overslaan",
|
||||
"autoSkipIntroDescription": "Intromarkeringen na enkele seconden automatisch overslaan",
|
||||
"autoSkipCredits": "Aftiteling automatisch overslaan",
|
||||
"autoSkipCreditsDescription": "Aftiteling automatisch overslaan en de volgende aflevering afspelen",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Reservemarkeringen afdwingen",
|
||||
"forceSkipMarkerFallbackDescription": "Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft",
|
||||
"autoSkipDelay": "Vertraging voor automatisch overslaan",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Niet in je bibliotheek",
|
||||
"inTheseLibraries": "In deze bibliotheken",
|
||||
"checkingLibrary": "Je bibliotheek controleren...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hier is nog niets",
|
||||
"emptyMessage": "Rijen van ${source} verschijnen hier zodra ze inhoud hebben.",
|
||||
"searchHint": "Zoeken in ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "De relayserver heeft niet op tijd gereageerd",
|
||||
"connectionLost": "De verbinding is verbroken voordat de sessie gereed was",
|
||||
"invalidRelayResponse": "De relayserver heeft een onverwacht antwoord verzonden",
|
||||
"sessionEnded": "De host heeft de sessie beëindigd"
|
||||
"sessionEnded": "De host heeft de sessie beëindigd",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr heeft geen sessiecookie verstrekt",
|
||||
"freshCookieRejected": "Seerr heeft de nieuwe sessiecookie geweigerd",
|
||||
"noUserInformation": "Seerr heeft geen gebruikersgegevens geretourneerd",
|
||||
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd"
|
||||
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Diensten",
|
||||
|
||||
+26
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Panel boczny jest rozwinięty, a obszar treści dostosowuje się",
|
||||
"showUnwatchedCount": "Pokaż liczbę nieobejrzanych",
|
||||
"showUnwatchedCountDescription": "Wyświetl liczbę nieobejrzanych odcinków w serialach i sezonach",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Pokaż numer odcinka na kartach",
|
||||
"showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków",
|
||||
"showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy",
|
||||
"playNextCountdown": "Odliczanie do następnego odcinka",
|
||||
"playNextCountdownImmediate": "Odtwarzaj natychmiast",
|
||||
"autoSkipIntro": "Automatyczne pomijanie intro",
|
||||
"autoSkipIntroDescription": "Automatycznie pomijaj znaczniki intro po kilku sekundach",
|
||||
"autoSkipCredits": "Automatyczne pomijanie napisów końcowych",
|
||||
"autoSkipCreditsDescription": "Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Wymuś znaczniki awaryjne",
|
||||
"forceSkipMarkerFallbackDescription": "Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki",
|
||||
"autoSkipDelay": "Opóźnienie automatycznego pomijania",
|
||||
@@ -778,6 +787,8 @@
|
||||
"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",
|
||||
@@ -1266,6 +1277,12 @@
|
||||
"notInLibrary": "Nie ma tego w Twojej bibliotece",
|
||||
"inTheseLibraries": "W tych bibliotekach",
|
||||
"checkingLibrary": "Sprawdzanie Twojej biblioteki...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"few": "",
|
||||
"many": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Jeszcze nic tu nie ma",
|
||||
"emptyMessage": "Wiersze z ${source} pojawią się tutaj, gdy będą zawierać treści.",
|
||||
"searchHint": "Szukaj w ${source}",
|
||||
@@ -1667,7 +1684,8 @@
|
||||
"timedOut": "Serwer pośredniczący nie odpowiedział w wymaganym czasie",
|
||||
"connectionLost": "Połączenie zostało zamknięte, zanim sesja była gotowa",
|
||||
"invalidRelayResponse": "Serwer pośredniczący wysłał nieoczekiwaną odpowiedź",
|
||||
"sessionEnded": "Gospodarz zakończył sesję"
|
||||
"sessionEnded": "Gospodarz zakończył sesję",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2123,7 +2141,9 @@
|
||||
"noSessionCookie": "Seerr nie utworzył pliku cookie sesji",
|
||||
"freshCookieRejected": "Seerr odrzucił nowy plik cookie sesji",
|
||||
"noUserInformation": "Seerr nie zwrócił informacji o użytkowniku",
|
||||
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu"
|
||||
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Usługi",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "A barra lateral fica expandida e a área de conteúdo se ajusta",
|
||||
"showUnwatchedCount": "Mostrar Contagem de Não Assistidos",
|
||||
"showUnwatchedCountDescription": "Exibir contagem de episódios não assistidos em séries e temporadas",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Mostrar Número do Episódio nos Cards",
|
||||
"showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio",
|
||||
"showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Iniciar o próximo episódio automaticamente quando um terminar",
|
||||
"playNextCountdown": "Contagem regressiva do próximo episódio",
|
||||
"playNextCountdownImmediate": "Reproduzir imediatamente",
|
||||
"autoSkipIntro": "Pular introdução automaticamente",
|
||||
"autoSkipIntroDescription": "Pular marcadores de introdução automaticamente após alguns segundos",
|
||||
"autoSkipCredits": "Pular créditos automaticamente",
|
||||
"autoSkipCreditsDescription": "Pular os créditos automaticamente e reproduzir o próximo episódio",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Forçar marcadores alternativos",
|
||||
"forceSkipMarkerFallbackDescription": "Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores",
|
||||
"autoSkipDelay": "Atraso para pular automaticamente",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Não está na sua biblioteca",
|
||||
"inTheseLibraries": "Nestas bibliotecas",
|
||||
"checkingLibrary": "Verificando sua biblioteca...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Ainda não há nada aqui",
|
||||
"emptyMessage": "As linhas de ${source} aparecerão aqui quando tiverem conteúdo.",
|
||||
"searchHint": "Buscar em ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "O servidor de retransmissão não respondeu a tempo",
|
||||
"connectionLost": "A conexão foi encerrada antes de a sessão ficar pronta",
|
||||
"invalidRelayResponse": "O servidor de retransmissão enviou uma resposta inesperada",
|
||||
"sessionEnded": "O anfitrião encerrou a sessão"
|
||||
"sessionEnded": "O anfitrião encerrou a sessão",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "O Seerr não forneceu um cookie de sessão",
|
||||
"freshCookieRejected": "O Seerr rejeitou o novo cookie de sessão",
|
||||
"noUserInformation": "O Seerr não retornou informações do usuário",
|
||||
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente"
|
||||
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Serviços",
|
||||
|
||||
+26
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Боковая панель остаётся развёрнутой, область контента подстраивается",
|
||||
"showUnwatchedCount": "Показывать количество непросмотренных",
|
||||
"showUnwatchedCountDescription": "Отображать количество непросмотренных эпизодов для сериалов и сезонов",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Показывать номер эпизода на карточках",
|
||||
"showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий",
|
||||
"showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Автоматически запускать следующий эпизод, когда заканчивается текущий",
|
||||
"playNextCountdown": "Обратный отсчёт перед следующим",
|
||||
"playNextCountdownImmediate": "Воспроизводить сразу",
|
||||
"autoSkipIntro": "Автопропуск вступления",
|
||||
"autoSkipIntroDescription": "Автоматически пропускать маркеры вступления через несколько секунд",
|
||||
"autoSkipCredits": "Автопропуск титров",
|
||||
"autoSkipCreditsDescription": "Автоматически пропускать титры и воспроизводить следующий эпизод",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Всегда использовать резервные маркеры",
|
||||
"forceSkipMarkerFallbackDescription": "Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры",
|
||||
"autoSkipDelay": "Задержка автопропуска",
|
||||
@@ -778,6 +787,8 @@
|
||||
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
|
||||
"playbackCancelled": "Воспроизведение отменено.",
|
||||
"playbackFailed": "Не удалось начать воспроизведение.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
|
||||
"errorLoadingSeries": "Ошибка загрузки сериала",
|
||||
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
|
||||
@@ -1266,6 +1277,12 @@
|
||||
"notInLibrary": "Нет в вашей библиотеке",
|
||||
"inTheseLibraries": "В этих библиотеках",
|
||||
"checkingLibrary": "Проверка вашей библиотеки...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"few": "",
|
||||
"many": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Здесь пока ничего нет",
|
||||
"emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.",
|
||||
"searchHint": "Поиск в ${source}",
|
||||
@@ -1667,7 +1684,8 @@
|
||||
"timedOut": "Сервер ретрансляции не ответил вовремя",
|
||||
"connectionLost": "Соединение закрылось до того, как сессия была готова",
|
||||
"invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ",
|
||||
"sessionEnded": "Организатор завершил сессию"
|
||||
"sessionEnded": "Организатор завершил сессию",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2123,7 +2141,9 @@
|
||||
"noSessionCookie": "Seerr не выдал cookie-файл сеанса",
|
||||
"freshCookieRejected": "Seerr отклонил новый cookie-файл сеанса",
|
||||
"noUserInformation": "Seerr не вернул сведения о пользователе",
|
||||
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа"
|
||||
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Сервисы",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 43756 (1988 per locale)
|
||||
/// Strings: 43688 (1985 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$az extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Bir seriya bitdikdə növbətisini avtomatik başlat';
|
||||
@override String get playNextCountdown => 'Növbəti seriya geri sayımı';
|
||||
@override String get playNextCountdownImmediate => 'Dərhal oynat';
|
||||
@override String get autoSkipIntro => 'Girişi avtomatik ötür';
|
||||
@override String get autoSkipIntroDescription => 'Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür';
|
||||
@override String get autoSkipCredits => 'Titrləri avtomatik ötür';
|
||||
@override String get autoSkipCreditsDescription => 'Titrləri avtomatik ötür və növbəti seriyanı oynat';
|
||||
@override String get forceSkipMarkerFallback => 'Ehtiyat işarələri məcburi et';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et';
|
||||
@override String get autoSkipDelay => 'Avtomatik ötürmə ləngiməsi';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsAz {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Bir seriya bitdikdə növbətisini avtomatik başlat',
|
||||
'settings.playNextCountdown' => 'Növbəti seriya geri sayımı',
|
||||
'settings.playNextCountdownImmediate' => 'Dərhal oynat',
|
||||
'settings.autoSkipIntro' => 'Girişi avtomatik ötür',
|
||||
'settings.autoSkipIntroDescription' => 'Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür',
|
||||
'settings.autoSkipCredits' => 'Titrləri avtomatik ötür',
|
||||
'settings.autoSkipCreditsDescription' => 'Titrləri avtomatik ötür və növbəti seriyanı oynat',
|
||||
'settings.forceSkipMarkerFallback' => 'Ehtiyat işarələri məcburi et',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et',
|
||||
'settings.autoSkipDelay' => 'Avtomatik ötürmə ləngiməsi',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsAz {
|
||||
'fileInfo.has64bitOffsets' => '64-bit ofsetlər',
|
||||
'fileInfo.protocol' => 'Protokol',
|
||||
'fileInfo.mediaType' => 'Media növü',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Mənbə növü',
|
||||
'fileInfo.optimizedVersion' => 'Optimallaşdırılmış versiya',
|
||||
'fileInfo.optimizationTarget' => 'Optimallaşdırma hədəfi',
|
||||
'fileInfo.deletedAt' => 'Silinib',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Uzaq mənbə',
|
||||
'fileInfo.infiniteStream' => 'Sonsuz axın',
|
||||
'fileInfo.directPlay' => 'Birbaşa oynatma',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsAz {
|
||||
'libraries.filterCategories.contentRating' => 'Məzmun reytinqi',
|
||||
'libraries.filterCategories.tag' => 'Teq',
|
||||
'libraries.filterCategories.unwatched' => 'Baxılmayıb',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Oynadılmayıb',
|
||||
'libraries.filterCategories.favorites' => 'Sevimlilər',
|
||||
'libraries.sortLabels.title' => 'Ad',
|
||||
'libraries.sortLabels.dateAdded' => 'Əlavə olunma tarixi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Buraxılış tarixi',
|
||||
'libraries.sortLabels.rating' => 'Reytinq',
|
||||
'libraries.sortLabels.communityRating' => 'İcma reytinqi',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsAz {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Əlaqəli yükləmələr təhlükəsiz şəkildə müəyyən edilə bilmədi. Serverə yenidən qoşulub cəhd edin və ya qaydanı yükləmələri silmədən silin.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} üçün ${count} yeni seriya eyniləşdirildi',
|
||||
'downloads.activeSyncRules' => 'Eyniləşdirmə qaydaları',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Eyniləşdirmə qaydası yoxdur',
|
||||
'downloads.manageSyncRule' => 'Eyniləşdirməni idarə et',
|
||||
'downloads.editEpisodeCount' => 'Seriya sayı',
|
||||
'downloads.editSyncFilter' => 'Eyniləşdirmə filtri',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Bütün elementlər eyniləşdirilir',
|
||||
'downloads.syncUnwatchedItems' => 'Baxılmayan elementlər eyniləşdirilir',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$bg extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Пускай следващия епизод автоматично, когато текущият свърши';
|
||||
@override String get playNextCountdown => 'Отброяване до следващия епизод';
|
||||
@override String get playNextCountdownImmediate => 'Пусни веднага';
|
||||
@override String get autoSkipIntro => 'Автоматично прескачане на интро';
|
||||
@override String get autoSkipIntroDescription => 'Автоматично прескачай интро маркери след няколко секунди';
|
||||
@override String get autoSkipCredits => 'Автоматично прескачане на финални надписи';
|
||||
@override String get autoSkipCreditsDescription => 'Автоматично прескачай финалните надписи и пускай следващия епизод';
|
||||
@override String get forceSkipMarkerFallback => 'Принуди резервни маркери';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Използвай шаблони в заглавията на главите дори когато Plex има маркери';
|
||||
@override String get autoSkipDelay => 'Забавяне за автоматично прескачане';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsBg {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Пускай следващия епизод автоматично, когато текущият свърши',
|
||||
'settings.playNextCountdown' => 'Отброяване до следващия епизод',
|
||||
'settings.playNextCountdownImmediate' => 'Пусни веднага',
|
||||
'settings.autoSkipIntro' => 'Автоматично прескачане на интро',
|
||||
'settings.autoSkipIntroDescription' => 'Автоматично прескачай интро маркери след няколко секунди',
|
||||
'settings.autoSkipCredits' => 'Автоматично прескачане на финални надписи',
|
||||
'settings.autoSkipCreditsDescription' => 'Автоматично прескачай финалните надписи и пускай следващия епизод',
|
||||
'settings.forceSkipMarkerFallback' => 'Принуди резервни маркери',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Използвай шаблони в заглавията на главите дори когато Plex има маркери',
|
||||
'settings.autoSkipDelay' => 'Забавяне за автоматично прескачане',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsBg {
|
||||
'fileInfo.has64bitOffsets' => '64-битови отмествания',
|
||||
'fileInfo.protocol' => 'Протокол',
|
||||
'fileInfo.mediaType' => 'Тип медия',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Вид източник',
|
||||
'fileInfo.optimizedVersion' => 'Оптимизирана версия',
|
||||
'fileInfo.optimizationTarget' => 'Цел на оптимизацията',
|
||||
'fileInfo.deletedAt' => 'Изтрит',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Отдалечен източник',
|
||||
'fileInfo.infiniteStream' => 'Безкраен поток',
|
||||
'fileInfo.directPlay' => 'Директно възпроизвеждане',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsBg {
|
||||
'libraries.filterCategories.contentRating' => 'Възрастов рейтинг',
|
||||
'libraries.filterCategories.tag' => 'Таг',
|
||||
'libraries.filterCategories.unwatched' => 'Негледани',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Непускани',
|
||||
'libraries.filterCategories.favorites' => 'Любими',
|
||||
'libraries.sortLabels.title' => 'Заглавие',
|
||||
'libraries.sortLabels.dateAdded' => 'Дата на добавяне',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Дата на излизане',
|
||||
'libraries.sortLabels.rating' => 'Рейтинг',
|
||||
'libraries.sortLabels.communityRating' => 'Оценка от общността',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsBg {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Свързаните изтегляния не можаха да бъдат идентифицирани безопасно. Свържете се отново със сървъра и опитайте отново, или премахнете правилото, без да изтривате изтеглянията.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Синхронизирани са ${count} нови епизода за ${title}',
|
||||
'downloads.activeSyncRules' => 'Правила за синхронизация',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Няма правила за синхронизация',
|
||||
'downloads.manageSyncRule' => 'Управление на синхронизацията',
|
||||
'downloads.editEpisodeCount' => 'Брой епизоди',
|
||||
'downloads.editSyncFilter' => 'Филтър за синхронизация',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Синхронизират се всички елементи',
|
||||
'downloads.syncUnwatchedItems' => 'Синхронизират се негледаните елементи',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сървър: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$da extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Start automatisk næste afsnit, når et afsnit slutter';
|
||||
@override String get playNextCountdown => 'Nedtælling til næste afsnit';
|
||||
@override String get playNextCountdownImmediate => 'Afspil med det samme';
|
||||
@override String get autoSkipIntro => 'Spring intro over automatisk';
|
||||
@override String get autoSkipIntroDescription => 'Spring automatisk intromarkører over efter få sekunder';
|
||||
@override String get autoSkipCredits => 'Spring rulletekster over automatisk';
|
||||
@override String get autoSkipCreditsDescription => 'Spring automatisk rulleteksterne over, og afspil næste episode';
|
||||
@override String get forceSkipMarkerFallback => 'Tving reservemarkører';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Brug mønstre i kapiteltitler, selv når Plex har markører';
|
||||
@override String get autoSkipDelay => 'Forsinkelse ved automatisk spring';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsDa {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Start automatisk næste afsnit, når et afsnit slutter',
|
||||
'settings.playNextCountdown' => 'Nedtælling til næste afsnit',
|
||||
'settings.playNextCountdownImmediate' => 'Afspil med det samme',
|
||||
'settings.autoSkipIntro' => 'Spring intro over automatisk',
|
||||
'settings.autoSkipIntroDescription' => 'Spring automatisk intromarkører over efter få sekunder',
|
||||
'settings.autoSkipCredits' => 'Spring rulletekster over automatisk',
|
||||
'settings.autoSkipCreditsDescription' => 'Spring automatisk rulleteksterne over, og afspil næste episode',
|
||||
'settings.forceSkipMarkerFallback' => 'Tving reservemarkører',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Brug mønstre i kapiteltitler, selv når Plex har markører',
|
||||
'settings.autoSkipDelay' => 'Forsinkelse ved automatisk spring',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsDa {
|
||||
'fileInfo.has64bitOffsets' => '64-bit-forskydninger',
|
||||
'fileInfo.protocol' => 'Protokol',
|
||||
'fileInfo.mediaType' => 'Medietype',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Kildetype',
|
||||
'fileInfo.optimizedVersion' => 'Optimeret version',
|
||||
'fileInfo.optimizationTarget' => 'Optimeringsmål',
|
||||
'fileInfo.deletedAt' => 'Slettet',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Fjernkilde',
|
||||
'fileInfo.infiniteStream' => 'Uendelig strøm',
|
||||
'fileInfo.directPlay' => 'Direkte afspilning',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsDa {
|
||||
'libraries.filterCategories.contentRating' => 'Aldersvurdering',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Usete',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Ikke afspillet',
|
||||
'libraries.filterCategories.favorites' => 'Favoritter',
|
||||
'libraries.sortLabels.title' => 'Titel',
|
||||
'libraries.sortLabels.dateAdded' => 'Tilføjet dato',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Udgivelsesdato',
|
||||
'libraries.sortLabels.rating' => 'Vurdering',
|
||||
'libraries.sortLabels.communityRating' => 'Fællesskabsvurdering',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsDa {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Tilknyttede downloads kunne ikke identificeres sikkert. Genopret forbindelse til serveren og prøv igen, eller fjern reglen uden at slette downloads.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserede ${count} nye episoder for ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkroniseringsregler',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
|
||||
'downloads.manageSyncRule' => 'Administrer synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antal episoder',
|
||||
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Synkroniserer alle elementer',
|
||||
'downloads.syncUnwatchedItems' => 'Synkroniserer usete elementer',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$de extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Die nächste Episode automatisch starten, wenn die aktuelle endet';
|
||||
@override String get playNextCountdown => 'Countdown bis zur nächsten Episode';
|
||||
@override String get playNextCountdownImmediate => 'Sofort abspielen';
|
||||
@override String get autoSkipIntro => 'Intro automatisch überspringen';
|
||||
@override String get autoSkipIntroDescription => 'Intro-Marker nach wenigen Sekunden automatisch überspringen';
|
||||
@override String get autoSkipCredits => 'Abspann automatisch überspringen';
|
||||
@override String get autoSkipCreditsDescription => 'Abspann automatisch überspringen und nächste Episode abspielen';
|
||||
@override String get forceSkipMarkerFallback => 'Ersatzmarkierungen erzwingen';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt';
|
||||
@override String get autoSkipDelay => 'Verzögerung für automatisches Überspringen';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsDe {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Die nächste Episode automatisch starten, wenn die aktuelle endet',
|
||||
'settings.playNextCountdown' => 'Countdown bis zur nächsten Episode',
|
||||
'settings.playNextCountdownImmediate' => 'Sofort abspielen',
|
||||
'settings.autoSkipIntro' => 'Intro automatisch überspringen',
|
||||
'settings.autoSkipIntroDescription' => 'Intro-Marker nach wenigen Sekunden automatisch überspringen',
|
||||
'settings.autoSkipCredits' => 'Abspann automatisch überspringen',
|
||||
'settings.autoSkipCreditsDescription' => 'Abspann automatisch überspringen und nächste Episode abspielen',
|
||||
'settings.forceSkipMarkerFallback' => 'Ersatzmarkierungen erzwingen',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt',
|
||||
'settings.autoSkipDelay' => 'Verzögerung für automatisches Überspringen',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsDe {
|
||||
'fileInfo.has64bitOffsets' => '64-Bit-Offsets',
|
||||
'fileInfo.protocol' => 'Protokoll',
|
||||
'fileInfo.mediaType' => 'Medientyp',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Quellenart',
|
||||
'fileInfo.optimizedVersion' => 'Optimierte Version',
|
||||
'fileInfo.optimizationTarget' => 'Optimierungsziel',
|
||||
'fileInfo.deletedAt' => 'Gelöscht',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Remote-Quelle',
|
||||
'fileInfo.infiniteStream' => 'Endlos-Stream',
|
||||
'fileInfo.directPlay' => 'Direct Play',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsDe {
|
||||
'libraries.filterCategories.contentRating' => 'Altersfreigabe',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Ungesehene',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Nicht abgespielt',
|
||||
'libraries.filterCategories.favorites' => 'Favoriten',
|
||||
'libraries.sortLabels.title' => 'Titel',
|
||||
'libraries.sortLabels.dateAdded' => 'Hinzugefügt am',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Erscheinungsdatum',
|
||||
'libraries.sortLabels.rating' => 'Bewertung',
|
||||
'libraries.sortLabels.communityRating' => 'Communitybewertung',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsDe {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Zugehörige Downloads konnten nicht sicher ermittelt werden. Verbinde den Server erneut und versuche es noch einmal, oder entferne die Regel, ohne die Downloads zu löschen.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} neue Episoden für ${title} synchronisiert',
|
||||
'downloads.activeSyncRules' => 'Synchronisierungsregeln',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Keine Synchronisierungsregeln',
|
||||
'downloads.manageSyncRule' => 'Synchronisierung verwalten',
|
||||
'downloads.editEpisodeCount' => 'Episodenanzahl',
|
||||
'downloads.editSyncFilter' => 'Synchronisierungsfilter',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Alle Elemente synchronisieren',
|
||||
'downloads.syncUnwatchedItems' => 'Ungesehene Elemente synchronisieren',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
+81
-18
@@ -546,6 +546,12 @@ class Translations$settings$en {
|
||||
/// en: 'Display unwatched episode count on shows and seasons'
|
||||
String get showUnwatchedCountDescription => 'Display unwatched episode count on shows and seasons';
|
||||
|
||||
/// en: 'Show Watched Indicators'
|
||||
String get showWatchedIndicators => 'Show Watched Indicators';
|
||||
|
||||
/// en: 'Display a checkmark on watched movies, shows, and episodes'
|
||||
String get showWatchedIndicatorsDescription => 'Display a checkmark on watched movies, shows, and episodes';
|
||||
|
||||
/// en: 'Show Episode Number on Cards'
|
||||
String get showEpisodeNumberOnCards => 'Show Episode Number on Cards';
|
||||
|
||||
@@ -858,17 +864,38 @@ class Translations$settings$en {
|
||||
/// en: 'Play immediately'
|
||||
String get playNextCountdownImmediate => 'Play immediately';
|
||||
|
||||
/// en: 'Auto Skip Intro'
|
||||
String get autoSkipIntro => 'Auto Skip Intro';
|
||||
/// en: 'Skip Intro'
|
||||
String get skipIntroMode => 'Skip Intro';
|
||||
|
||||
/// en: 'Automatically skip intro markers after a few seconds'
|
||||
String get autoSkipIntroDescription => 'Automatically skip intro markers after a few seconds';
|
||||
/// en: 'Play intros normally without a skip button'
|
||||
String get skipIntroModeOffDescription => 'Play intros normally without a skip button';
|
||||
|
||||
/// en: 'Auto Skip Credits'
|
||||
String get autoSkipCredits => 'Auto Skip Credits';
|
||||
/// en: 'Show a skip button when an intro starts'
|
||||
String get skipIntroModeButtonDescription => 'Show a skip button when an intro starts';
|
||||
|
||||
/// en: 'Automatically skip credits and play next episode'
|
||||
String get autoSkipCreditsDescription => 'Automatically skip credits and play next episode';
|
||||
/// en: 'Skip intros automatically after the delay below'
|
||||
String get skipIntroModeAutoDescription => 'Skip intros automatically after the delay below';
|
||||
|
||||
/// en: 'Skip Credits'
|
||||
String get skipCreditsMode => 'Skip Credits';
|
||||
|
||||
/// en: 'Play credits normally without a skip button'
|
||||
String get skipCreditsModeOffDescription => 'Play credits normally without a skip button';
|
||||
|
||||
/// en: 'Show a skip button when credits start'
|
||||
String get skipCreditsModeButtonDescription => 'Show a skip button when credits start';
|
||||
|
||||
/// en: 'Skip credits automatically and play the next episode'
|
||||
String get skipCreditsModeAutoDescription => 'Skip credits automatically and play the next episode';
|
||||
|
||||
/// en: 'Off'
|
||||
String get skipMarkerModeOff => 'Off';
|
||||
|
||||
/// en: 'Show button'
|
||||
String get skipMarkerModeButton => 'Show button';
|
||||
|
||||
/// en: 'Automatic'
|
||||
String get skipMarkerModeAuto => 'Automatic';
|
||||
|
||||
/// en: 'Force Fallback Markers'
|
||||
String get forceSkipMarkerFallback => 'Force Fallback Markers';
|
||||
@@ -2245,6 +2272,12 @@ 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}';
|
||||
|
||||
@@ -3420,6 +3453,12 @@ class Translations$explore$en {
|
||||
/// en: 'Checking your library...'
|
||||
String get checkingLibrary => 'Checking your library...';
|
||||
|
||||
/// en: '(one) {Couldn't check ${n} server} (other) {Couldn't check ${n} servers}'
|
||||
String libraryCheckFailed({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
|
||||
one: 'Couldn\'t check ${n} server',
|
||||
other: 'Couldn\'t check ${n} servers',
|
||||
);
|
||||
|
||||
/// en: 'Nothing here yet'
|
||||
String get emptyTitle => 'Nothing here yet';
|
||||
|
||||
@@ -5302,6 +5341,12 @@ class Translations$seerr$en {
|
||||
|
||||
/// en: 'The session was rejected after signing in again'
|
||||
String get sessionRejectedAfterReauth => 'The session was rejected after signing in again';
|
||||
|
||||
/// en: 'Seerr denied this action: your account no longer has the required permission'
|
||||
String get permissionDenied => 'Seerr denied this action: your account no longer has the required permission';
|
||||
|
||||
/// en: 'You no longer have permission to request this'
|
||||
String get permissionRevoked => 'You no longer have permission to request this';
|
||||
}
|
||||
|
||||
// Path: services
|
||||
@@ -6526,6 +6571,9 @@ class Translations$watchTogether$errors$en {
|
||||
|
||||
/// en: 'The host ended the session'
|
||||
String get sessionEnded => 'The host ended the session';
|
||||
|
||||
/// en: 'Unable to resume this session. Join or create a room to continue.'
|
||||
String get sessionUnavailable => 'Unable to resume this session. Join or create a room to continue.';
|
||||
}
|
||||
|
||||
// Path: downloads.backgroundWarning
|
||||
@@ -7129,6 +7177,8 @@ extension on Translations {
|
||||
'settings.alwaysKeepSidebarOpenDescription' => 'Sidebar stays expanded and content area adjusts to fit',
|
||||
'settings.showUnwatchedCount' => 'Show Unwatched Count',
|
||||
'settings.showUnwatchedCountDescription' => 'Display unwatched episode count on shows and seasons',
|
||||
'settings.showWatchedIndicators' => 'Show Watched Indicators',
|
||||
'settings.showWatchedIndicatorsDescription' => 'Display a checkmark on watched movies, shows, and episodes',
|
||||
'settings.showEpisodeNumberOnCards' => 'Show Episode Number on Cards',
|
||||
'settings.showEpisodeNumberOnCardsDescription' => 'Show season and episode number on episode cards',
|
||||
'settings.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs',
|
||||
@@ -7233,10 +7283,17 @@ extension on Translations {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Start the next episode automatically when one ends',
|
||||
'settings.playNextCountdown' => 'Play Next Countdown',
|
||||
'settings.playNextCountdownImmediate' => 'Play immediately',
|
||||
'settings.autoSkipIntro' => 'Auto Skip Intro',
|
||||
'settings.autoSkipIntroDescription' => 'Automatically skip intro markers after a few seconds',
|
||||
'settings.autoSkipCredits' => 'Auto Skip Credits',
|
||||
'settings.autoSkipCreditsDescription' => 'Automatically skip credits and play next episode',
|
||||
'settings.skipIntroMode' => 'Skip Intro',
|
||||
'settings.skipIntroModeOffDescription' => 'Play intros normally without a skip button',
|
||||
'settings.skipIntroModeButtonDescription' => 'Show a skip button when an intro starts',
|
||||
'settings.skipIntroModeAutoDescription' => 'Skip intros automatically after the delay below',
|
||||
'settings.skipCreditsMode' => 'Skip Credits',
|
||||
'settings.skipCreditsModeOffDescription' => 'Play credits normally without a skip button',
|
||||
'settings.skipCreditsModeButtonDescription' => 'Show a skip button when credits start',
|
||||
'settings.skipCreditsModeAutoDescription' => 'Skip credits automatically and play the next episode',
|
||||
'settings.skipMarkerModeOff' => 'Off',
|
||||
'settings.skipMarkerModeButton' => 'Show button',
|
||||
'settings.skipMarkerModeAuto' => 'Automatic',
|
||||
'settings.forceSkipMarkerFallback' => 'Force Fallback Markers',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Use chapter title patterns even when Plex has markers',
|
||||
'settings.autoSkipDelay' => 'Auto Skip Delay',
|
||||
@@ -7477,6 +7534,8 @@ extension on Translations {
|
||||
'fileInfo.path' => 'Path',
|
||||
'fileInfo.fileName' => 'File Name',
|
||||
'fileInfo.size' => 'Size',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.totalSize' => 'Total Size',
|
||||
'fileInfo.container' => 'Container',
|
||||
'fileInfo.duration' => 'Duration',
|
||||
@@ -7486,8 +7545,6 @@ extension on Translations {
|
||||
'fileInfo.filePresent' => 'File Present',
|
||||
'fileInfo.fileReadable' => 'Readable by Server',
|
||||
'fileInfo.streamPath' => 'Stream Path',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.optimizedForStreaming' => 'Optimized for Streaming',
|
||||
'fileInfo.has64bitOffsets' => '64-bit Offsets',
|
||||
'fileInfo.protocol' => 'Protocol',
|
||||
@@ -7695,6 +7752,8 @@ 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',
|
||||
@@ -7989,6 +8048,8 @@ 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',
|
||||
'libraries.tabs.playlists' => 'Playlists',
|
||||
@@ -8000,8 +8061,6 @@ extension on Translations {
|
||||
'libraries.groupings.episodes' => 'Episodes',
|
||||
'libraries.groupings.artists' => 'Artists',
|
||||
'libraries.groupings.albums' => 'Albums',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.groupings.tracks' => 'Tracks',
|
||||
'libraries.groupings.folders' => 'Folders',
|
||||
'libraries.filterCategories.genre' => 'Genre',
|
||||
@@ -8117,6 +8176,7 @@ extension on Translations {
|
||||
'explore.notInLibrary' => 'Not in your library',
|
||||
'explore.inTheseLibraries' => 'In these libraries',
|
||||
'explore.checkingLibrary' => 'Checking your library...',
|
||||
'explore.libraryCheckFailed' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Couldn\'t check ${n} server', other: 'Couldn\'t check ${n} servers', ),
|
||||
'explore.emptyTitle' => 'Nothing here yet',
|
||||
'explore.emptyMessage' => ({required Object source}) => 'Rows from ${source} will appear here once they have content.',
|
||||
'explore.searchHint' => ({required Object source}) => 'Search ${source}',
|
||||
@@ -8468,6 +8528,7 @@ extension on Translations {
|
||||
'watchTogether.errors.connectionLost' => 'The connection closed before the session was ready',
|
||||
'watchTogether.errors.invalidRelayResponse' => 'The relay sent an unexpected response',
|
||||
'watchTogether.errors.sessionEnded' => 'The host ended the session',
|
||||
'watchTogether.errors.sessionUnavailable' => 'Unable to resume this session. Join or create a room to continue.',
|
||||
'downloads.title' => 'Downloads',
|
||||
'downloads.manage' => 'Manage',
|
||||
'downloads.tvShows' => 'TV Shows',
|
||||
@@ -8501,6 +8562,8 @@ 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',
|
||||
@@ -8514,8 +8577,6 @@ extension on Translations {
|
||||
'downloads.invalidEpisodeCount' => 'Enter a valid episode count.',
|
||||
'downloads.keepSynced' => 'Keep synced',
|
||||
'downloads.downloadOnce' => 'Download once',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched',
|
||||
'downloads.editSyncRule' => 'Edit sync rule',
|
||||
'downloads.removeSyncRule' => 'Remove sync rule',
|
||||
@@ -8893,6 +8954,8 @@ extension on Translations {
|
||||
'seerr.freshCookieRejected' => 'Seerr rejected the new session cookie',
|
||||
'seerr.noUserInformation' => 'Seerr did not return user information',
|
||||
'seerr.sessionRejectedAfterReauth' => 'The session was rejected after signing in again',
|
||||
'seerr.permissionDenied' => 'Seerr denied this action: your account no longer has the required permission',
|
||||
'seerr.permissionRevoked' => 'You no longer have permission to request this',
|
||||
'services.title' => 'Services',
|
||||
'services.hubSubtitle' => 'Sync watch progress and request new titles.',
|
||||
'services.integrations' => 'Integrations',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$es extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Iniciar automáticamente el siguiente episodio cuando termine el actual';
|
||||
@override String get playNextCountdown => 'Cuenta atrás para el siguiente episodio';
|
||||
@override String get playNextCountdownImmediate => 'Reproducir de inmediato';
|
||||
@override String get autoSkipIntro => 'Saltar introducción automáticamente';
|
||||
@override String get autoSkipIntroDescription => 'Saltar automáticamente los marcadores de introducción después de unos segundos';
|
||||
@override String get autoSkipCredits => 'Saltar créditos automáticamente';
|
||||
@override String get autoSkipCreditsDescription => 'Saltar automáticamente los créditos y reproducir el episodio siguiente';
|
||||
@override String get forceSkipMarkerFallback => 'Forzar marcadores alternativos';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Usar patrones de títulos de capítulos aunque Plex tenga marcadores';
|
||||
@override String get autoSkipDelay => 'Retraso del salto automático';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsEs {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Iniciar automáticamente el siguiente episodio cuando termine el actual',
|
||||
'settings.playNextCountdown' => 'Cuenta atrás para el siguiente episodio',
|
||||
'settings.playNextCountdownImmediate' => 'Reproducir de inmediato',
|
||||
'settings.autoSkipIntro' => 'Saltar introducción automáticamente',
|
||||
'settings.autoSkipIntroDescription' => 'Saltar automáticamente los marcadores de introducción después de unos segundos',
|
||||
'settings.autoSkipCredits' => 'Saltar créditos automáticamente',
|
||||
'settings.autoSkipCreditsDescription' => 'Saltar automáticamente los créditos y reproducir el episodio siguiente',
|
||||
'settings.forceSkipMarkerFallback' => 'Forzar marcadores alternativos',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Usar patrones de títulos de capítulos aunque Plex tenga marcadores',
|
||||
'settings.autoSkipDelay' => 'Retraso del salto automático',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsEs {
|
||||
'fileInfo.has64bitOffsets' => 'Desplazamientos de 64 bits',
|
||||
'fileInfo.protocol' => 'Protocolo',
|
||||
'fileInfo.mediaType' => 'Tipo de medio',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Tipo de origen',
|
||||
'fileInfo.optimizedVersion' => 'Versión optimizada',
|
||||
'fileInfo.optimizationTarget' => 'Destino de optimización',
|
||||
'fileInfo.deletedAt' => 'Eliminado',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Origen remoto',
|
||||
'fileInfo.infiniteStream' => 'Flujo infinito',
|
||||
'fileInfo.directPlay' => 'Reproducción directa',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsEs {
|
||||
'libraries.filterCategories.contentRating' => 'Clasificación por edad',
|
||||
'libraries.filterCategories.tag' => 'Etiqueta',
|
||||
'libraries.filterCategories.unwatched' => 'No vistos',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'No reproducidos',
|
||||
'libraries.filterCategories.favorites' => 'Favoritos',
|
||||
'libraries.sortLabels.title' => 'Título',
|
||||
'libraries.sortLabels.dateAdded' => 'Fecha de adición',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Fecha de estreno',
|
||||
'libraries.sortLabels.rating' => 'Valoración',
|
||||
'libraries.sortLabels.communityRating' => 'Valoración de la comunidad',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsEs {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'No se pudieron identificar las descargas asociadas de forma segura. Vuelve a conectar el servidor e inténtalo de nuevo, o elimina la regla sin borrar las descargas.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuevos episodios sincronizados para ${title}',
|
||||
'downloads.activeSyncRules' => 'Reglas de sincronización',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Sin reglas de sincronización',
|
||||
'downloads.manageSyncRule' => 'Gestionar sincronización',
|
||||
'downloads.editEpisodeCount' => 'Número de episodios',
|
||||
'downloads.editSyncFilter' => 'Filtro de sincronización',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Sincronizando todos los elementos',
|
||||
'downloads.syncUnwatchedItems' => 'Sincronizando elementos no vistos',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$fr extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Lancer automatiquement l\'épisode suivant lorsqu\'un épisode se termine';
|
||||
@override String get playNextCountdown => 'Compte à rebours avant l\'épisode suivant';
|
||||
@override String get playNextCountdownImmediate => 'Lire immédiatement';
|
||||
@override String get autoSkipIntro => 'Passer automatiquement l’introduction';
|
||||
@override String get autoSkipIntroDescription => 'Passer automatiquement les marqueurs d’introduction après quelques secondes';
|
||||
@override String get autoSkipCredits => 'Passer automatiquement le générique';
|
||||
@override String get autoSkipCreditsDescription => 'Passer automatiquement le générique et lire l’épisode suivant';
|
||||
@override String get forceSkipMarkerFallback => 'Forcer les marqueurs de secours';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs';
|
||||
@override String get autoSkipDelay => 'Délai avant le saut automatique';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsFr {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Lancer automatiquement l\'épisode suivant lorsqu\'un épisode se termine',
|
||||
'settings.playNextCountdown' => 'Compte à rebours avant l\'épisode suivant',
|
||||
'settings.playNextCountdownImmediate' => 'Lire immédiatement',
|
||||
'settings.autoSkipIntro' => 'Passer automatiquement l’introduction',
|
||||
'settings.autoSkipIntroDescription' => 'Passer automatiquement les marqueurs d’introduction après quelques secondes',
|
||||
'settings.autoSkipCredits' => 'Passer automatiquement le générique',
|
||||
'settings.autoSkipCreditsDescription' => 'Passer automatiquement le générique et lire l’épisode suivant',
|
||||
'settings.forceSkipMarkerFallback' => 'Forcer les marqueurs de secours',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs',
|
||||
'settings.autoSkipDelay' => 'Délai avant le saut automatique',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsFr {
|
||||
'fileInfo.has64bitOffsets' => 'Décalages 64 bits',
|
||||
'fileInfo.protocol' => 'Protocole',
|
||||
'fileInfo.mediaType' => 'Type de média',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Type de source',
|
||||
'fileInfo.optimizedVersion' => 'Version optimisée',
|
||||
'fileInfo.optimizationTarget' => 'Cible d\'optimisation',
|
||||
'fileInfo.deletedAt' => 'Supprimé',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Source distante',
|
||||
'fileInfo.infiniteStream' => 'Flux infini',
|
||||
'fileInfo.directPlay' => 'Lecture directe',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsFr {
|
||||
'libraries.filterCategories.contentRating' => 'Classification',
|
||||
'libraries.filterCategories.tag' => 'Étiquette',
|
||||
'libraries.filterCategories.unwatched' => 'Non vus',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Non lus',
|
||||
'libraries.filterCategories.favorites' => 'Favoris',
|
||||
'libraries.sortLabels.title' => 'Titre',
|
||||
'libraries.sortLabels.dateAdded' => 'Date d\'ajout',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Date de sortie',
|
||||
'libraries.sortLabels.rating' => 'Note',
|
||||
'libraries.sortLabels.communityRating' => 'Note communautaire',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsFr {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Les téléchargements associés n\'ont pas pu être identifiés en toute sécurité. Reconnectez le serveur et réessayez, ou retirez la règle sans supprimer les téléchargements.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nouveaux épisodes synchronisés pour ${title}',
|
||||
'downloads.activeSyncRules' => 'Règles de synchronisation',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Aucune règle de synchronisation',
|
||||
'downloads.manageSyncRule' => 'Gérer la synchronisation',
|
||||
'downloads.editEpisodeCount' => 'Nombre d’épisodes',
|
||||
'downloads.editSyncFilter' => 'Filtre de synchronisation',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Synchronisation de tous les éléments',
|
||||
'downloads.syncUnwatchedItems' => 'Synchronisation des éléments non vus',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serveur : ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$hu extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'A következő epizód automatikus elindítása, amikor az aktuális véget ér';
|
||||
@override String get playNextCountdown => 'Visszaszámlálás a következő epizódig';
|
||||
@override String get playNextCountdownImmediate => 'Azonnali lejátszás';
|
||||
@override String get autoSkipIntro => 'Intró automatikus átugrása';
|
||||
@override String get autoSkipIntroDescription => 'Az intrójelölők automatikus átugrása néhány másodperc után';
|
||||
@override String get autoSkipCredits => 'Stáblista automatikus átugrása';
|
||||
@override String get autoSkipCreditsDescription => 'A stáblista automatikus átugrása és a következő epizód lejátszása';
|
||||
@override String get forceSkipMarkerFallback => 'Tartalék jelölők kényszerítése';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel';
|
||||
@override String get autoSkipDelay => 'Automatikus átugrás késleltetése';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsHu {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'A következő epizód automatikus elindítása, amikor az aktuális véget ér',
|
||||
'settings.playNextCountdown' => 'Visszaszámlálás a következő epizódig',
|
||||
'settings.playNextCountdownImmediate' => 'Azonnali lejátszás',
|
||||
'settings.autoSkipIntro' => 'Intró automatikus átugrása',
|
||||
'settings.autoSkipIntroDescription' => 'Az intrójelölők automatikus átugrása néhány másodperc után',
|
||||
'settings.autoSkipCredits' => 'Stáblista automatikus átugrása',
|
||||
'settings.autoSkipCreditsDescription' => 'A stáblista automatikus átugrása és a következő epizód lejátszása',
|
||||
'settings.forceSkipMarkerFallback' => 'Tartalék jelölők kényszerítése',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel',
|
||||
'settings.autoSkipDelay' => 'Automatikus átugrás késleltetése',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsHu {
|
||||
'fileInfo.has64bitOffsets' => '64 bites eltolások',
|
||||
'fileInfo.protocol' => 'Protokoll',
|
||||
'fileInfo.mediaType' => 'Médiatípus',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Forrás típusa',
|
||||
'fileInfo.optimizedVersion' => 'Optimalizált verzió',
|
||||
'fileInfo.optimizationTarget' => 'Optimalizálási cél',
|
||||
'fileInfo.deletedAt' => 'Törölve',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Távoli forrás',
|
||||
'fileInfo.infiniteStream' => 'Végtelen adatfolyam',
|
||||
'fileInfo.directPlay' => 'Közvetlen lejátszás',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsHu {
|
||||
'libraries.filterCategories.contentRating' => 'Korhatár-besorolás',
|
||||
'libraries.filterCategories.tag' => 'Címke',
|
||||
'libraries.filterCategories.unwatched' => 'Nem látott',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Nem lejátszott',
|
||||
'libraries.filterCategories.favorites' => 'Kedvencek',
|
||||
'libraries.sortLabels.title' => 'Cím',
|
||||
'libraries.sortLabels.dateAdded' => 'Hozzáadás dátuma',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Bemutató dátuma',
|
||||
'libraries.sortLabels.rating' => 'Értékelés',
|
||||
'libraries.sortLabels.communityRating' => 'Közösségi értékelés',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsHu {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'A kapcsolódó letöltések nem azonosíthatók biztonságosan. Csatlakoztassa újra a szervert, és próbálja újra, vagy távolítsa el a szabályt a letöltések törlése nélkül.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} új epizód szinkronizálva a következőhöz: ${title}',
|
||||
'downloads.activeSyncRules' => 'Szinkronizálási szabályok',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Nincsenek szinkronizálási szabályok',
|
||||
'downloads.manageSyncRule' => 'Szinkronizálás kezelése',
|
||||
'downloads.editEpisodeCount' => 'Epizódszám',
|
||||
'downloads.editSyncFilter' => 'Szinkronizálási szűrő',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Minden elem szinkronizálása',
|
||||
'downloads.syncUnwatchedItems' => 'Nem látott elemek szinkronizálása',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Szerver: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$it extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Avvia automaticamente l\'episodio successivo quando termina quello in riproduzione';
|
||||
@override String get playNextCountdown => 'Conto alla rovescia del successivo';
|
||||
@override String get playNextCountdownImmediate => 'Riproduci subito';
|
||||
@override String get autoSkipIntro => 'Salta automaticamente la sigla iniziale';
|
||||
@override String get autoSkipIntroDescription => 'Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi';
|
||||
@override String get autoSkipCredits => 'Salta automaticamente i titoli di coda';
|
||||
@override String get autoSkipCreditsDescription => 'Salta automaticamente i titoli di coda e riproduce l\'episodio successivo';
|
||||
@override String get forceSkipMarkerFallback => 'Forza i marcatori di ripiego';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori';
|
||||
@override String get autoSkipDelay => 'Ritardo del salto automatico';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsIt {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Avvia automaticamente l\'episodio successivo quando termina quello in riproduzione',
|
||||
'settings.playNextCountdown' => 'Conto alla rovescia del successivo',
|
||||
'settings.playNextCountdownImmediate' => 'Riproduci subito',
|
||||
'settings.autoSkipIntro' => 'Salta automaticamente la sigla iniziale',
|
||||
'settings.autoSkipIntroDescription' => 'Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi',
|
||||
'settings.autoSkipCredits' => 'Salta automaticamente i titoli di coda',
|
||||
'settings.autoSkipCreditsDescription' => 'Salta automaticamente i titoli di coda e riproduce l\'episodio successivo',
|
||||
'settings.forceSkipMarkerFallback' => 'Forza i marcatori di ripiego',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori',
|
||||
'settings.autoSkipDelay' => 'Ritardo del salto automatico',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsIt {
|
||||
'fileInfo.has64bitOffsets' => 'Offset a 64 bit',
|
||||
'fileInfo.protocol' => 'Protocollo',
|
||||
'fileInfo.mediaType' => 'Tipo di supporto',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Tipo di origine',
|
||||
'fileInfo.optimizedVersion' => 'Versione ottimizzata',
|
||||
'fileInfo.optimizationTarget' => 'Obiettivo ottimizzazione',
|
||||
'fileInfo.deletedAt' => 'Eliminato',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Origine remota',
|
||||
'fileInfo.infiniteStream' => 'Flusso infinito',
|
||||
'fileInfo.directPlay' => 'Riproduzione diretta',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsIt {
|
||||
'libraries.filterCategories.contentRating' => 'Classificazione per età',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Non visti',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Non riprodotti',
|
||||
'libraries.filterCategories.favorites' => 'Preferiti',
|
||||
'libraries.sortLabels.title' => 'Titolo',
|
||||
'libraries.sortLabels.dateAdded' => 'Data di aggiunta',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Data di uscita',
|
||||
'libraries.sortLabels.rating' => 'Valutazione',
|
||||
'libraries.sortLabels.communityRating' => 'Valutazione della comunità',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsIt {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Non è stato possibile identificare in sicurezza i download associati. Riconnetti il server e riprova, oppure rimuovi la regola senza eliminare i download.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuovi episodi sincronizzati per ${title}',
|
||||
'downloads.activeSyncRules' => 'Regole di sincronizzazione',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Nessuna regola di sincronizzazione',
|
||||
'downloads.manageSyncRule' => 'Gestisci sincronizzazione',
|
||||
'downloads.editEpisodeCount' => 'Numero di episodi',
|
||||
'downloads.editSyncFilter' => 'Filtro di sincronizzazione',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Sincronizzazione di tutti gli elementi',
|
||||
'downloads.syncUnwatchedItems' => 'Sincronizzazione degli elementi non visti',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$ja extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'エピソードが終了すると、次のエピソードを自動的に再生します';
|
||||
@override String get playNextCountdown => '次回再生のカウントダウン';
|
||||
@override String get playNextCountdownImmediate => 'すぐに再生';
|
||||
@override String get autoSkipIntro => 'イントロを自動スキップ';
|
||||
@override String get autoSkipIntroDescription => '数秒後にイントロマーカーを自動的にスキップ';
|
||||
@override String get autoSkipCredits => 'クレジットを自動スキップ';
|
||||
@override String get autoSkipCreditsDescription => 'クレジットを自動的にスキップして次のエピソードを再生';
|
||||
@override String get forceSkipMarkerFallback => 'フォールバックマーカーを強制';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します';
|
||||
@override String get autoSkipDelay => '自動スキップの遅延';
|
||||
@@ -3184,10 +3180,6 @@ extension on TranslationsJa {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'エピソードが終了すると、次のエピソードを自動的に再生します',
|
||||
'settings.playNextCountdown' => '次回再生のカウントダウン',
|
||||
'settings.playNextCountdownImmediate' => 'すぐに再生',
|
||||
'settings.autoSkipIntro' => 'イントロを自動スキップ',
|
||||
'settings.autoSkipIntroDescription' => '数秒後にイントロマーカーを自動的にスキップ',
|
||||
'settings.autoSkipCredits' => 'クレジットを自動スキップ',
|
||||
'settings.autoSkipCreditsDescription' => 'クレジットを自動的にスキップして次のエピソードを再生',
|
||||
'settings.forceSkipMarkerFallback' => 'フォールバックマーカーを強制',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します',
|
||||
'settings.autoSkipDelay' => '自動スキップの遅延',
|
||||
@@ -3439,12 +3431,12 @@ extension on TranslationsJa {
|
||||
'fileInfo.has64bitOffsets' => '64ビットオフセット',
|
||||
'fileInfo.protocol' => 'プロトコル',
|
||||
'fileInfo.mediaType' => 'メディアタイプ',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'ソース種別',
|
||||
'fileInfo.optimizedVersion' => '最適化バージョン',
|
||||
'fileInfo.optimizationTarget' => '最適化ターゲット',
|
||||
'fileInfo.deletedAt' => '削除済み',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'リモートソース',
|
||||
'fileInfo.infiniteStream' => '無限ストリーム',
|
||||
'fileInfo.directPlay' => 'ダイレクト再生',
|
||||
@@ -3953,12 +3945,12 @@ extension on TranslationsJa {
|
||||
'libraries.filterCategories.contentRating' => '視聴年齢区分',
|
||||
'libraries.filterCategories.tag' => 'タグ',
|
||||
'libraries.filterCategories.unwatched' => '未視聴',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => '未再生',
|
||||
'libraries.filterCategories.favorites' => 'お気に入り',
|
||||
'libraries.sortLabels.title' => 'タイトル',
|
||||
'libraries.sortLabels.dateAdded' => '追加日',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'リリース日',
|
||||
'libraries.sortLabels.rating' => '評価',
|
||||
'libraries.sortLabels.communityRating' => 'コミュニティ評価',
|
||||
@@ -4467,12 +4459,12 @@ extension on TranslationsJa {
|
||||
'downloads.syncRuleCleanupUnavailable' => '関連するダウンロードを安全に特定できませんでした。サーバーに再接続して再試行するか、ダウンロードを削除せずにルールを削除してください。',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}の新しいエピソードを${count}件同期しました',
|
||||
'downloads.activeSyncRules' => '同期ルール',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => '同期ルールなし',
|
||||
'downloads.manageSyncRule' => '同期を管理',
|
||||
'downloads.editEpisodeCount' => 'エピソード数',
|
||||
'downloads.editSyncFilter' => '同期フィルター',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'すべてのアイテムを同期中',
|
||||
'downloads.syncUnwatchedItems' => '未視聴のアイテムを同期中',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'サーバー: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$kk extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау';
|
||||
@override String get playNextCountdown => 'Келесіні ойнату санағы';
|
||||
@override String get playNextCountdownImmediate => 'Дереу ойнату';
|
||||
@override String get autoSkipIntro => 'Киріс бөлімді (Intro) автоматты өткізу';
|
||||
@override String get autoSkipIntroDescription => 'Бірнеше секундтан кейін киріс белгілерін автоматты өткізу';
|
||||
@override String get autoSkipCredits => 'Титрлерді автоматты өткізу';
|
||||
@override String get autoSkipCreditsDescription => 'Титрлерді автоматты өткізіп, келесі бөлімді ойнату';
|
||||
@override String get forceSkipMarkerFallback => 'Қосалқы белгілерді мәжбүрлеу';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану';
|
||||
@override String get autoSkipDelay => 'Автоматты өткізу кідірісі';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsKk {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау',
|
||||
'settings.playNextCountdown' => 'Келесіні ойнату санағы',
|
||||
'settings.playNextCountdownImmediate' => 'Дереу ойнату',
|
||||
'settings.autoSkipIntro' => 'Киріс бөлімді (Intro) автоматты өткізу',
|
||||
'settings.autoSkipIntroDescription' => 'Бірнеше секундтан кейін киріс белгілерін автоматты өткізу',
|
||||
'settings.autoSkipCredits' => 'Титрлерді автоматты өткізу',
|
||||
'settings.autoSkipCreditsDescription' => 'Титрлерді автоматты өткізіп, келесі бөлімді ойнату',
|
||||
'settings.forceSkipMarkerFallback' => 'Қосалқы белгілерді мәжбүрлеу',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану',
|
||||
'settings.autoSkipDelay' => 'Автоматты өткізу кідірісі',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsKk {
|
||||
'fileInfo.has64bitOffsets' => '64-биттік ығысулар',
|
||||
'fileInfo.protocol' => 'Протокол',
|
||||
'fileInfo.mediaType' => 'Медиа түрі',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Дереккөз түрі',
|
||||
'fileInfo.optimizedVersion' => 'Оңтайландырылған нұсқа',
|
||||
'fileInfo.optimizationTarget' => 'Оңтайландыру мақсаты',
|
||||
'fileInfo.deletedAt' => 'Өшірілген',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Қашықтағы дереккөз',
|
||||
'fileInfo.infiniteStream' => 'Шексіз ағын',
|
||||
'fileInfo.directPlay' => 'Тікелей ойнату',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsKk {
|
||||
'libraries.filterCategories.contentRating' => 'Мазмұн рейтингі',
|
||||
'libraries.filterCategories.tag' => 'Тег',
|
||||
'libraries.filterCategories.unwatched' => 'Көрілмеген',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Ойнатылмаған',
|
||||
'libraries.filterCategories.favorites' => 'Таңдаулылар',
|
||||
'libraries.sortLabels.title' => 'Атауы',
|
||||
'libraries.sortLabels.dateAdded' => 'Қосылған күні',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Шыққан күні',
|
||||
'libraries.sortLabels.rating' => 'Рейтинг',
|
||||
'libraries.sortLabels.communityRating' => 'Қауымдастық рейтингі',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsKk {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Байланысты жүктеулерді қауіпсіз анықтау мүмкін болмады. Серверге қайта қосылып көріңіз немесе ережені жүктеулерді жоймай өшіріңіз.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} үшін ${count} жаңа бөлім синхрондалды',
|
||||
'downloads.activeSyncRules' => 'Белсенді синхрондау ережелері',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Синхрондау ережелері жоқ',
|
||||
'downloads.manageSyncRule' => 'Синхрондауды басқару',
|
||||
'downloads.editEpisodeCount' => 'Бөлімдер саны',
|
||||
'downloads.editSyncFilter' => 'Синхрондау фильтрі',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Барлық элементтер синхрондалады',
|
||||
'downloads.syncUnwatchedItems' => 'Көрілмеген элементтер синхрондалады',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$ko extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => '에피소드가 끝나면 다음 에피소드를 자동으로 재생';
|
||||
@override String get playNextCountdown => '다음 재생 카운트다운';
|
||||
@override String get playNextCountdownImmediate => '즉시 재생';
|
||||
@override String get autoSkipIntro => '자동으로 오프닝 건너뛰기';
|
||||
@override String get autoSkipIntroDescription => '몇 초 후 오프닝을 자동으로 건너뛰기';
|
||||
@override String get autoSkipCredits => '자동으로 엔딩 건너뛰기';
|
||||
@override String get autoSkipCreditsDescription => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생';
|
||||
@override String get forceSkipMarkerFallback => '대체 마커 강제 사용';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plex에 마커가 있어도 챕터 제목 패턴 사용';
|
||||
@override String get autoSkipDelay => '자동 건너뛰기 지연';
|
||||
@@ -3184,10 +3180,6 @@ extension on TranslationsKo {
|
||||
'settings.autoPlayNextEpisodeDescription' => '에피소드가 끝나면 다음 에피소드를 자동으로 재생',
|
||||
'settings.playNextCountdown' => '다음 재생 카운트다운',
|
||||
'settings.playNextCountdownImmediate' => '즉시 재생',
|
||||
'settings.autoSkipIntro' => '자동으로 오프닝 건너뛰기',
|
||||
'settings.autoSkipIntroDescription' => '몇 초 후 오프닝을 자동으로 건너뛰기',
|
||||
'settings.autoSkipCredits' => '자동으로 엔딩 건너뛰기',
|
||||
'settings.autoSkipCreditsDescription' => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생',
|
||||
'settings.forceSkipMarkerFallback' => '대체 마커 강제 사용',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plex에 마커가 있어도 챕터 제목 패턴 사용',
|
||||
'settings.autoSkipDelay' => '자동 건너뛰기 지연',
|
||||
@@ -3439,12 +3431,12 @@ extension on TranslationsKo {
|
||||
'fileInfo.has64bitOffsets' => '64비트 오프셋',
|
||||
'fileInfo.protocol' => '프로토콜',
|
||||
'fileInfo.mediaType' => '미디어 유형',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => '소스 종류',
|
||||
'fileInfo.optimizedVersion' => '최적화 버전',
|
||||
'fileInfo.optimizationTarget' => '최적화 대상',
|
||||
'fileInfo.deletedAt' => '삭제됨',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => '원격 소스',
|
||||
'fileInfo.infiniteStream' => '무한 스트림',
|
||||
'fileInfo.directPlay' => 'Direct Play',
|
||||
@@ -3953,12 +3945,12 @@ extension on TranslationsKo {
|
||||
'libraries.filterCategories.contentRating' => '시청 등급',
|
||||
'libraries.filterCategories.tag' => '태그',
|
||||
'libraries.filterCategories.unwatched' => '미시청',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => '재생하지 않음',
|
||||
'libraries.filterCategories.favorites' => '즐겨찾기',
|
||||
'libraries.sortLabels.title' => '제목',
|
||||
'libraries.sortLabels.dateAdded' => '추가된 날짜',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => '출시일',
|
||||
'libraries.sortLabels.rating' => '평점',
|
||||
'libraries.sortLabels.communityRating' => '커뮤니티 평점',
|
||||
@@ -4467,12 +4459,12 @@ extension on TranslationsKo {
|
||||
'downloads.syncRuleCleanupUnavailable' => '연결된 다운로드를 안전하게 확인할 수 없습니다. 서버에 다시 연결한 후 시도하거나, 다운로드를 삭제하지 않고 규칙만 제거하세요.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}의 새 에피소드 ${count}개 동기화됨',
|
||||
'downloads.activeSyncRules' => '동기화 규칙',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => '동기화 규칙 없음',
|
||||
'downloads.manageSyncRule' => '동기화 관리',
|
||||
'downloads.editEpisodeCount' => '에피소드 수',
|
||||
'downloads.editSyncFilter' => '동기화 필터',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => '모든 항목 동기화 중',
|
||||
'downloads.syncUnwatchedItems' => '시청하지 않은 항목 동기화 중',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '서버: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$nb extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Start neste episode automatisk når en episode er ferdig';
|
||||
@override String get playNextCountdown => 'Nedtelling for neste episode';
|
||||
@override String get playNextCountdownImmediate => 'Spill umiddelbart';
|
||||
@override String get autoSkipIntro => 'Hopp over intro automatisk';
|
||||
@override String get autoSkipIntroDescription => 'Hopp automatisk over intromarkører etter noen sekunder';
|
||||
@override String get autoSkipCredits => 'Hopp over rulletekst automatisk';
|
||||
@override String get autoSkipCreditsDescription => 'Hopp automatisk over rulletekst og spill neste episode';
|
||||
@override String get forceSkipMarkerFallback => 'Tving reservemarkører';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Bruk mønstre i kapiteltitler selv når Plex har markører';
|
||||
@override String get autoSkipDelay => 'Forsinkelse for automatisk hopp';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsNb {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Start neste episode automatisk når en episode er ferdig',
|
||||
'settings.playNextCountdown' => 'Nedtelling for neste episode',
|
||||
'settings.playNextCountdownImmediate' => 'Spill umiddelbart',
|
||||
'settings.autoSkipIntro' => 'Hopp over intro automatisk',
|
||||
'settings.autoSkipIntroDescription' => 'Hopp automatisk over intromarkører etter noen sekunder',
|
||||
'settings.autoSkipCredits' => 'Hopp over rulletekst automatisk',
|
||||
'settings.autoSkipCreditsDescription' => 'Hopp automatisk over rulletekst og spill neste episode',
|
||||
'settings.forceSkipMarkerFallback' => 'Tving reservemarkører',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Bruk mønstre i kapiteltitler selv når Plex har markører',
|
||||
'settings.autoSkipDelay' => 'Forsinkelse for automatisk hopp',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsNb {
|
||||
'fileInfo.has64bitOffsets' => '64-biters forskyvninger',
|
||||
'fileInfo.protocol' => 'Protokoll',
|
||||
'fileInfo.mediaType' => 'Mediatype',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Kildetype',
|
||||
'fileInfo.optimizedVersion' => 'Optimalisert versjon',
|
||||
'fileInfo.optimizationTarget' => 'Optimaliseringsmål',
|
||||
'fileInfo.deletedAt' => 'Slettet',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Ekstern kilde',
|
||||
'fileInfo.infiniteStream' => 'Uendelig strøm',
|
||||
'fileInfo.directPlay' => 'Direct Play',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsNb {
|
||||
'libraries.filterCategories.contentRating' => 'Aldersgrense',
|
||||
'libraries.filterCategories.tag' => 'Tagg',
|
||||
'libraries.filterCategories.unwatched' => 'Usette',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Ikke avspilt',
|
||||
'libraries.filterCategories.favorites' => 'Favoritter',
|
||||
'libraries.sortLabels.title' => 'Tittel',
|
||||
'libraries.sortLabels.dateAdded' => 'Dato lagt til',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Utgivelsesdato',
|
||||
'libraries.sortLabels.rating' => 'Vurdering',
|
||||
'libraries.sortLabels.communityRating' => 'Fellesskapsvurdering',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsNb {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Tilknyttede nedlastinger kunne ikke identifiseres på en trygg måte. Koble til serveren på nytt og prøv igjen, eller fjern regelen uten å slette nedlastingene.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserte ${count} nye episoder for ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkroniseringsregler',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
|
||||
'downloads.manageSyncRule' => 'Administrer synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antall episoder',
|
||||
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Synkroniserer alle elementer',
|
||||
'downloads.syncUnwatchedItems' => 'Synkroniserer usette elementer',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$nl extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Start de volgende aflevering automatisch wanneer een aflevering eindigt';
|
||||
@override String get playNextCountdown => 'Aftellen voor volgende aflevering';
|
||||
@override String get playNextCountdownImmediate => 'Direct afspelen';
|
||||
@override String get autoSkipIntro => 'Intro automatisch overslaan';
|
||||
@override String get autoSkipIntroDescription => 'Intromarkeringen na enkele seconden automatisch overslaan';
|
||||
@override String get autoSkipCredits => 'Aftiteling automatisch overslaan';
|
||||
@override String get autoSkipCreditsDescription => 'Aftiteling automatisch overslaan en de volgende aflevering afspelen';
|
||||
@override String get forceSkipMarkerFallback => 'Reservemarkeringen afdwingen';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft';
|
||||
@override String get autoSkipDelay => 'Vertraging voor automatisch overslaan';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsNl {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Start de volgende aflevering automatisch wanneer een aflevering eindigt',
|
||||
'settings.playNextCountdown' => 'Aftellen voor volgende aflevering',
|
||||
'settings.playNextCountdownImmediate' => 'Direct afspelen',
|
||||
'settings.autoSkipIntro' => 'Intro automatisch overslaan',
|
||||
'settings.autoSkipIntroDescription' => 'Intromarkeringen na enkele seconden automatisch overslaan',
|
||||
'settings.autoSkipCredits' => 'Aftiteling automatisch overslaan',
|
||||
'settings.autoSkipCreditsDescription' => 'Aftiteling automatisch overslaan en de volgende aflevering afspelen',
|
||||
'settings.forceSkipMarkerFallback' => 'Reservemarkeringen afdwingen',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft',
|
||||
'settings.autoSkipDelay' => 'Vertraging voor automatisch overslaan',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsNl {
|
||||
'fileInfo.has64bitOffsets' => '64-bits offsets',
|
||||
'fileInfo.protocol' => 'Protocol',
|
||||
'fileInfo.mediaType' => 'Mediatype',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Soort bron',
|
||||
'fileInfo.optimizedVersion' => 'Geoptimaliseerde versie',
|
||||
'fileInfo.optimizationTarget' => 'Optimalisatiedoel',
|
||||
'fileInfo.deletedAt' => 'Verwijderd',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Externe bron',
|
||||
'fileInfo.infiniteStream' => 'Oneindige stream',
|
||||
'fileInfo.directPlay' => 'Direct Play',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsNl {
|
||||
'libraries.filterCategories.contentRating' => 'Leeftijdsclassificatie',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Onbekeken',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Niet afgespeeld',
|
||||
'libraries.filterCategories.favorites' => 'Favorieten',
|
||||
'libraries.sortLabels.title' => 'Titel',
|
||||
'libraries.sortLabels.dateAdded' => 'Toegevoegd op',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Uitgavedatum',
|
||||
'libraries.sortLabels.rating' => 'Beoordeling',
|
||||
'libraries.sortLabels.communityRating' => 'Beoordeling door community',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsNl {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Gekoppelde downloads konden niet veilig worden geïdentificeerd. Maak opnieuw verbinding met de server en probeer het opnieuw, of verwijder de regel zonder de downloads te verwijderen.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nieuwe afleveringen gesynchroniseerd voor ${title}',
|
||||
'downloads.activeSyncRules' => 'Synchronisatieregels',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Geen synchronisatieregels',
|
||||
'downloads.manageSyncRule' => 'Synchronisatie beheren',
|
||||
'downloads.editEpisodeCount' => 'Aantal afleveringen',
|
||||
'downloads.editSyncFilter' => 'Synchronisatiefilter',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Alle items synchroniseren',
|
||||
'downloads.syncUnwatchedItems' => 'Ongekeken items synchroniseren',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$pl extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy';
|
||||
@override String get playNextCountdown => 'Odliczanie do następnego odcinka';
|
||||
@override String get playNextCountdownImmediate => 'Odtwarzaj natychmiast';
|
||||
@override String get autoSkipIntro => 'Automatyczne pomijanie intro';
|
||||
@override String get autoSkipIntroDescription => 'Automatycznie pomijaj znaczniki intro po kilku sekundach';
|
||||
@override String get autoSkipCredits => 'Automatyczne pomijanie napisów końcowych';
|
||||
@override String get autoSkipCreditsDescription => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek';
|
||||
@override String get forceSkipMarkerFallback => 'Wymuś znaczniki awaryjne';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki';
|
||||
@override String get autoSkipDelay => 'Opóźnienie automatycznego pomijania';
|
||||
@@ -3214,10 +3210,6 @@ extension on TranslationsPl {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy',
|
||||
'settings.playNextCountdown' => 'Odliczanie do następnego odcinka',
|
||||
'settings.playNextCountdownImmediate' => 'Odtwarzaj natychmiast',
|
||||
'settings.autoSkipIntro' => 'Automatyczne pomijanie intro',
|
||||
'settings.autoSkipIntroDescription' => 'Automatycznie pomijaj znaczniki intro po kilku sekundach',
|
||||
'settings.autoSkipCredits' => 'Automatyczne pomijanie napisów końcowych',
|
||||
'settings.autoSkipCreditsDescription' => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek',
|
||||
'settings.forceSkipMarkerFallback' => 'Wymuś znaczniki awaryjne',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki',
|
||||
'settings.autoSkipDelay' => 'Opóźnienie automatycznego pomijania',
|
||||
@@ -3469,12 +3461,12 @@ extension on TranslationsPl {
|
||||
'fileInfo.has64bitOffsets' => 'Przesunięcia 64-bitowe',
|
||||
'fileInfo.protocol' => 'Protokół',
|
||||
'fileInfo.mediaType' => 'Typ multimediów',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Rodzaj źródła',
|
||||
'fileInfo.optimizedVersion' => 'Zoptymalizowana wersja',
|
||||
'fileInfo.optimizationTarget' => 'Cel optymalizacji',
|
||||
'fileInfo.deletedAt' => 'Usunięto',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Źródło zdalne',
|
||||
'fileInfo.infiniteStream' => 'Nieskończony strumień',
|
||||
'fileInfo.directPlay' => 'Odtwarzanie bezpośrednie',
|
||||
@@ -3983,12 +3975,12 @@ extension on TranslationsPl {
|
||||
'libraries.filterCategories.contentRating' => 'Klasyfikacja wiekowa',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Nieobejrzane',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Nieodtworzone',
|
||||
'libraries.filterCategories.favorites' => 'Ulubione',
|
||||
'libraries.sortLabels.title' => 'Tytuł',
|
||||
'libraries.sortLabels.dateAdded' => 'Data dodania',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Data premiery',
|
||||
'libraries.sortLabels.rating' => 'Ocena',
|
||||
'libraries.sortLabels.communityRating' => 'Ocena społeczności',
|
||||
@@ -4497,12 +4489,12 @@ extension on TranslationsPl {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Nie udało się bezpiecznie zidentyfikować powiązanych pobrań. Połącz się ponownie z serwerem i spróbuj ponownie albo usuń regułę bez usuwania pobrań.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Zsynchronizowano ${count} nowych odcinków dla ${title}',
|
||||
'downloads.activeSyncRules' => 'Reguły synchronizacji',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Brak reguł synchronizacji',
|
||||
'downloads.manageSyncRule' => 'Zarządzaj synchronizacją',
|
||||
'downloads.editEpisodeCount' => 'Liczba odcinków',
|
||||
'downloads.editSyncFilter' => 'Filtr synchronizacji',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Synchronizacja wszystkich elementów',
|
||||
'downloads.syncUnwatchedItems' => 'Synchronizacja nieobejrzanych elementów',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serwer: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$pt extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Iniciar o próximo episódio automaticamente quando um terminar';
|
||||
@override String get playNextCountdown => 'Contagem regressiva do próximo episódio';
|
||||
@override String get playNextCountdownImmediate => 'Reproduzir imediatamente';
|
||||
@override String get autoSkipIntro => 'Pular introdução automaticamente';
|
||||
@override String get autoSkipIntroDescription => 'Pular marcadores de introdução automaticamente após alguns segundos';
|
||||
@override String get autoSkipCredits => 'Pular créditos automaticamente';
|
||||
@override String get autoSkipCreditsDescription => 'Pular os créditos automaticamente e reproduzir o próximo episódio';
|
||||
@override String get forceSkipMarkerFallback => 'Forçar marcadores alternativos';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores';
|
||||
@override String get autoSkipDelay => 'Atraso para pular automaticamente';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsPt {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Iniciar o próximo episódio automaticamente quando um terminar',
|
||||
'settings.playNextCountdown' => 'Contagem regressiva do próximo episódio',
|
||||
'settings.playNextCountdownImmediate' => 'Reproduzir imediatamente',
|
||||
'settings.autoSkipIntro' => 'Pular introdução automaticamente',
|
||||
'settings.autoSkipIntroDescription' => 'Pular marcadores de introdução automaticamente após alguns segundos',
|
||||
'settings.autoSkipCredits' => 'Pular créditos automaticamente',
|
||||
'settings.autoSkipCreditsDescription' => 'Pular os créditos automaticamente e reproduzir o próximo episódio',
|
||||
'settings.forceSkipMarkerFallback' => 'Forçar marcadores alternativos',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores',
|
||||
'settings.autoSkipDelay' => 'Atraso para pular automaticamente',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsPt {
|
||||
'fileInfo.has64bitOffsets' => 'Deslocamentos de 64 bits',
|
||||
'fileInfo.protocol' => 'Protocolo',
|
||||
'fileInfo.mediaType' => 'Tipo de mídia',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Tipo de origem',
|
||||
'fileInfo.optimizedVersion' => 'Versão otimizada',
|
||||
'fileInfo.optimizationTarget' => 'Alvo de otimização',
|
||||
'fileInfo.deletedAt' => 'Excluído',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Origem remota',
|
||||
'fileInfo.infiniteStream' => 'Fluxo infinito',
|
||||
'fileInfo.directPlay' => 'Reprodução direta',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsPt {
|
||||
'libraries.filterCategories.contentRating' => 'Classificação indicativa',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Não assistidos',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Não reproduzidos',
|
||||
'libraries.filterCategories.favorites' => 'Favoritos',
|
||||
'libraries.sortLabels.title' => 'Título',
|
||||
'libraries.sortLabels.dateAdded' => 'Data de adição',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Data de lançamento',
|
||||
'libraries.sortLabels.rating' => 'Avaliação',
|
||||
'libraries.sortLabels.communityRating' => 'Avaliação da comunidade',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsPt {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Os downloads associados não puderam ser identificados com segurança. Reconecte o servidor e tente novamente, ou remova a regra sem excluir os downloads.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} novos episódios sincronizados para ${title}',
|
||||
'downloads.activeSyncRules' => 'Regras de sincronização',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Nenhuma regra de sincronização',
|
||||
'downloads.manageSyncRule' => 'Gerenciar sincronização',
|
||||
'downloads.editEpisodeCount' => 'Número de episódios',
|
||||
'downloads.editSyncFilter' => 'Filtro de sincronização',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Sincronizando todos os itens',
|
||||
'downloads.syncUnwatchedItems' => 'Sincronizando itens não assistidos',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$ru extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Автоматически запускать следующий эпизод, когда заканчивается текущий';
|
||||
@override String get playNextCountdown => 'Обратный отсчёт перед следующим';
|
||||
@override String get playNextCountdownImmediate => 'Воспроизводить сразу';
|
||||
@override String get autoSkipIntro => 'Автопропуск вступления';
|
||||
@override String get autoSkipIntroDescription => 'Автоматически пропускать маркеры вступления через несколько секунд';
|
||||
@override String get autoSkipCredits => 'Автопропуск титров';
|
||||
@override String get autoSkipCreditsDescription => 'Автоматически пропускать титры и воспроизводить следующий эпизод';
|
||||
@override String get forceSkipMarkerFallback => 'Всегда использовать резервные маркеры';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры';
|
||||
@override String get autoSkipDelay => 'Задержка автопропуска';
|
||||
@@ -3214,10 +3210,6 @@ extension on TranslationsRu {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Автоматически запускать следующий эпизод, когда заканчивается текущий',
|
||||
'settings.playNextCountdown' => 'Обратный отсчёт перед следующим',
|
||||
'settings.playNextCountdownImmediate' => 'Воспроизводить сразу',
|
||||
'settings.autoSkipIntro' => 'Автопропуск вступления',
|
||||
'settings.autoSkipIntroDescription' => 'Автоматически пропускать маркеры вступления через несколько секунд',
|
||||
'settings.autoSkipCredits' => 'Автопропуск титров',
|
||||
'settings.autoSkipCreditsDescription' => 'Автоматически пропускать титры и воспроизводить следующий эпизод',
|
||||
'settings.forceSkipMarkerFallback' => 'Всегда использовать резервные маркеры',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры',
|
||||
'settings.autoSkipDelay' => 'Задержка автопропуска',
|
||||
@@ -3469,12 +3461,12 @@ extension on TranslationsRu {
|
||||
'fileInfo.has64bitOffsets' => '64-битные смещения',
|
||||
'fileInfo.protocol' => 'Протокол',
|
||||
'fileInfo.mediaType' => 'Тип медиа',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Тип источника',
|
||||
'fileInfo.optimizedVersion' => 'Оптимизированная версия',
|
||||
'fileInfo.optimizationTarget' => 'Цель оптимизации',
|
||||
'fileInfo.deletedAt' => 'Удалено',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Удалённый источник',
|
||||
'fileInfo.infiniteStream' => 'Бесконечный поток',
|
||||
'fileInfo.directPlay' => 'Прямое воспроизведение',
|
||||
@@ -3983,12 +3975,12 @@ extension on TranslationsRu {
|
||||
'libraries.filterCategories.contentRating' => 'Возрастной рейтинг',
|
||||
'libraries.filterCategories.tag' => 'Тег',
|
||||
'libraries.filterCategories.unwatched' => 'Непросмотренные',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Не прослушано',
|
||||
'libraries.filterCategories.favorites' => 'Избранное',
|
||||
'libraries.sortLabels.title' => 'Название',
|
||||
'libraries.sortLabels.dateAdded' => 'Дата добавления',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Дата выхода',
|
||||
'libraries.sortLabels.rating' => 'Рейтинг',
|
||||
'libraries.sortLabels.communityRating' => 'Оценка сообщества',
|
||||
@@ -4497,12 +4489,12 @@ extension on TranslationsRu {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Не удалось надёжно определить связанные загрузки. Переподключите сервер и повторите попытку либо удалите правило без удаления загрузок.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Синхронизировано ${count} новых эпизодов для ${title}',
|
||||
'downloads.activeSyncRules' => 'Правила синхронизации',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Нет правил синхронизации',
|
||||
'downloads.manageSyncRule' => 'Управление синхронизацией',
|
||||
'downloads.editEpisodeCount' => 'Количество эпизодов',
|
||||
'downloads.editSyncFilter' => 'Фильтр синхронизации',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Синхронизация всех элементов',
|
||||
'downloads.syncUnwatchedItems' => 'Синхронизация непросмотренных элементов',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$sv extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar';
|
||||
@override String get playNextCountdown => 'Nedräkning för nästa avsnitt';
|
||||
@override String get playNextCountdownImmediate => 'Spela direkt';
|
||||
@override String get autoSkipIntro => 'Hoppa över intro automatiskt';
|
||||
@override String get autoSkipIntroDescription => 'Hoppa automatiskt över intromarkörer efter några sekunder';
|
||||
@override String get autoSkipCredits => 'Hoppa över eftertexter automatiskt';
|
||||
@override String get autoSkipCreditsDescription => 'Hoppa automatiskt över eftertexterna och spela nästa avsnitt';
|
||||
@override String get forceSkipMarkerFallback => 'Tvinga reservmarkörer';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Använd mönster i kapiteltitlar även när Plex har markörer';
|
||||
@override String get autoSkipDelay => 'Fördröjning före automatiskt hopp';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsSv {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar',
|
||||
'settings.playNextCountdown' => 'Nedräkning för nästa avsnitt',
|
||||
'settings.playNextCountdownImmediate' => 'Spela direkt',
|
||||
'settings.autoSkipIntro' => 'Hoppa över intro automatiskt',
|
||||
'settings.autoSkipIntroDescription' => 'Hoppa automatiskt över intromarkörer efter några sekunder',
|
||||
'settings.autoSkipCredits' => 'Hoppa över eftertexter automatiskt',
|
||||
'settings.autoSkipCreditsDescription' => 'Hoppa automatiskt över eftertexterna och spela nästa avsnitt',
|
||||
'settings.forceSkipMarkerFallback' => 'Tvinga reservmarkörer',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Använd mönster i kapiteltitlar även när Plex har markörer',
|
||||
'settings.autoSkipDelay' => 'Fördröjning före automatiskt hopp',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsSv {
|
||||
'fileInfo.has64bitOffsets' => '64-bitars offsetvärden',
|
||||
'fileInfo.protocol' => 'Protokoll',
|
||||
'fileInfo.mediaType' => 'Mediatyp',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Källtyp',
|
||||
'fileInfo.optimizedVersion' => 'Optimerad version',
|
||||
'fileInfo.optimizationTarget' => 'Optimeringsmål',
|
||||
'fileInfo.deletedAt' => 'Raderad',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Fjärrkälla',
|
||||
'fileInfo.infiniteStream' => 'Oändlig ström',
|
||||
'fileInfo.directPlay' => 'Direktuppspelning',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsSv {
|
||||
'libraries.filterCategories.contentRating' => 'Åldersgräns',
|
||||
'libraries.filterCategories.tag' => 'Tagg',
|
||||
'libraries.filterCategories.unwatched' => 'Osedda',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Ospelat',
|
||||
'libraries.filterCategories.favorites' => 'Favoriter',
|
||||
'libraries.sortLabels.title' => 'Titel',
|
||||
'libraries.sortLabels.dateAdded' => 'Tillagd',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Releasedatum',
|
||||
'libraries.sortLabels.rating' => 'Betyg',
|
||||
'libraries.sortLabels.communityRating' => 'Användarbetyg',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsSv {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Associerade nedladdningar kunde inte identifieras på ett säkert sätt. Återanslut servern och försök igen, eller ta bort regeln utan att ta bort nedladdningar.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserade ${count} nya avsnitt för ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkregler',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Inga synkregler',
|
||||
'downloads.manageSyncRule' => 'Hantera synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antal avsnitt',
|
||||
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Synkroniserar alla objekt',
|
||||
'downloads.syncUnwatchedItems' => 'Synkroniserar osedda objekt',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$tr extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Bir bölüm bittiğinde sonraki bölümü otomatik başlat';
|
||||
@override String get playNextCountdown => 'Sonraki Bölüm Geri Sayımı';
|
||||
@override String get playNextCountdownImmediate => 'Hemen oynat';
|
||||
@override String get autoSkipIntro => 'Jeneriği Otomatik Atla';
|
||||
@override String get autoSkipIntroDescription => 'Kaç saniye sonra jenerik işaretçilerini otomatik atla';
|
||||
@override String get autoSkipCredits => 'Emeği Geçenleri Otomatik Atla';
|
||||
@override String get autoSkipCreditsDescription => 'Emeği geçenleri otomatik atla ve sonraki bölümü oynat';
|
||||
@override String get forceSkipMarkerFallback => 'Yedek İşaretçileri Zorla';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan';
|
||||
@override String get autoSkipDelay => 'Otomatik Atlama Gecikmesi';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsTr {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Bir bölüm bittiğinde sonraki bölümü otomatik başlat',
|
||||
'settings.playNextCountdown' => 'Sonraki Bölüm Geri Sayımı',
|
||||
'settings.playNextCountdownImmediate' => 'Hemen oynat',
|
||||
'settings.autoSkipIntro' => 'Jeneriği Otomatik Atla',
|
||||
'settings.autoSkipIntroDescription' => 'Kaç saniye sonra jenerik işaretçilerini otomatik atla',
|
||||
'settings.autoSkipCredits' => 'Emeği Geçenleri Otomatik Atla',
|
||||
'settings.autoSkipCreditsDescription' => 'Emeği geçenleri otomatik atla ve sonraki bölümü oynat',
|
||||
'settings.forceSkipMarkerFallback' => 'Yedek İşaretçileri Zorla',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan',
|
||||
'settings.autoSkipDelay' => 'Otomatik Atlama Gecikmesi',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsTr {
|
||||
'fileInfo.has64bitOffsets' => '64-bit Ofsetler',
|
||||
'fileInfo.protocol' => 'Protokol',
|
||||
'fileInfo.mediaType' => 'Medya Türü',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Kaynak Türü',
|
||||
'fileInfo.optimizedVersion' => 'Optimize Edilmiş Sürüm',
|
||||
'fileInfo.optimizationTarget' => 'Optimizasyon Hedefi',
|
||||
'fileInfo.deletedAt' => 'Silindi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Uzak Kaynak',
|
||||
'fileInfo.infiniteStream' => 'Sonsuz Akış',
|
||||
'fileInfo.directPlay' => 'Doğrudan Oynatma',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsTr {
|
||||
'libraries.filterCategories.contentRating' => 'İçerik Derecelendirmesi',
|
||||
'libraries.filterCategories.tag' => 'Etiket',
|
||||
'libraries.filterCategories.unwatched' => 'İzlenmemiş',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Oynatılmamış',
|
||||
'libraries.filterCategories.favorites' => 'Favoriler',
|
||||
'libraries.sortLabels.title' => 'Başlık',
|
||||
'libraries.sortLabels.dateAdded' => 'Ekleme Tarihi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Yayınlanma Tarihi',
|
||||
'libraries.sortLabels.rating' => 'Puan',
|
||||
'libraries.sortLabels.communityRating' => 'Topluluk Puanı',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsTr {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'İlişkili indirmeler güvenli biçimde belirlenemedi. Sunucuya yeniden bağlanıp tekrar deneyin veya kuralı indirmeleri silmeden kaldırın.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} için ${count} yeni bölüm eşitlendi',
|
||||
'downloads.activeSyncRules' => 'Eşitleme kuralları',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Eşitleme kuralı yok',
|
||||
'downloads.manageSyncRule' => 'Eşitlemeyi yönet',
|
||||
'downloads.editEpisodeCount' => 'Bölüm sayısı',
|
||||
'downloads.editSyncFilter' => 'Eşitleme filtresi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Tüm ögeler eşitleniyor',
|
||||
'downloads.syncUnwatchedItems' => 'İzlenmeyen ögeler eşitleniyor',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Sunucu: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class _Translations$settings$uz extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => 'Qism tugagach, keyingisini avtomatik ijro etish';
|
||||
@override String get playNextCountdown => 'Keyingi qism sanogʻi';
|
||||
@override String get playNextCountdownImmediate => 'Darhol ijro etish';
|
||||
@override String get autoSkipIntro => 'Kirish qismini (Intro) avtomatik oʻtkazish';
|
||||
@override String get autoSkipIntroDescription => 'Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish';
|
||||
@override String get autoSkipCredits => 'Titrlarni avtomatik oʻtkazish';
|
||||
@override String get autoSkipCreditsDescription => 'Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish';
|
||||
@override String get forceSkipMarkerFallback => 'Zahira belgilarini majburlash';
|
||||
@override String get forceSkipMarkerFallbackDescription => 'Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish';
|
||||
@override String get autoSkipDelay => 'Avtomatik oʻtkazish kechikishi';
|
||||
@@ -3194,10 +3190,6 @@ extension on TranslationsUz {
|
||||
'settings.autoPlayNextEpisodeDescription' => 'Qism tugagach, keyingisini avtomatik ijro etish',
|
||||
'settings.playNextCountdown' => 'Keyingi qism sanogʻi',
|
||||
'settings.playNextCountdownImmediate' => 'Darhol ijro etish',
|
||||
'settings.autoSkipIntro' => 'Kirish qismini (Intro) avtomatik oʻtkazish',
|
||||
'settings.autoSkipIntroDescription' => 'Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish',
|
||||
'settings.autoSkipCredits' => 'Titrlarni avtomatik oʻtkazish',
|
||||
'settings.autoSkipCreditsDescription' => 'Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish',
|
||||
'settings.forceSkipMarkerFallback' => 'Zahira belgilarini majburlash',
|
||||
'settings.forceSkipMarkerFallbackDescription' => 'Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish',
|
||||
'settings.autoSkipDelay' => 'Avtomatik oʻtkazish kechikishi',
|
||||
@@ -3449,12 +3441,12 @@ extension on TranslationsUz {
|
||||
'fileInfo.has64bitOffsets' => '64-bitli siljishlar',
|
||||
'fileInfo.protocol' => 'Protokol',
|
||||
'fileInfo.mediaType' => 'Media turi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Manba turi',
|
||||
'fileInfo.optimizedVersion' => 'Optimallashtirilgan versiya',
|
||||
'fileInfo.optimizationTarget' => 'Optimallashtirish maqsadi',
|
||||
'fileInfo.deletedAt' => 'Oʻchirilgan',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => 'Masofaviy manba',
|
||||
'fileInfo.infiniteStream' => 'Cheksiz oqim',
|
||||
'fileInfo.directPlay' => 'Toʻgʻridan-toʻgʻri ijro',
|
||||
@@ -3963,12 +3955,12 @@ extension on TranslationsUz {
|
||||
'libraries.filterCategories.contentRating' => 'Kontent reytingi',
|
||||
'libraries.filterCategories.tag' => 'Teg',
|
||||
'libraries.filterCategories.unwatched' => 'Koʻrilmagan',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Eshitilmagan',
|
||||
'libraries.filterCategories.favorites' => 'Tanlanganlar',
|
||||
'libraries.sortLabels.title' => 'Nomi',
|
||||
'libraries.sortLabels.dateAdded' => 'Qoʻshilgan sanasi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => 'Chıqqan sanasi',
|
||||
'libraries.sortLabels.rating' => 'Reyting',
|
||||
'libraries.sortLabels.communityRating' => 'Hamjamiyat reytingi',
|
||||
@@ -4477,12 +4469,12 @@ extension on TranslationsUz {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Bogʻliq yuklamalarni xavfsiz aniqlab boʻlmadi. Serverga qayta ulanib koʻring yoki yuklamalarni oʻchirmasdan qoidani olib tashlang.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} uchun ${count} yangi qism sinxronlandi',
|
||||
'downloads.activeSyncRules' => 'Faol sinxronlash qoidalari',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'Sinxronlash qoidalari yoʻq',
|
||||
'downloads.manageSyncRule' => 'Sinxronlashni boshqarish',
|
||||
'downloads.editEpisodeCount' => 'Qismlar soni',
|
||||
'downloads.editSyncFilter' => 'Sinxronlash filtri',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => 'Barcha elementlar sinxronlanadi',
|
||||
'downloads.syncUnwatchedItems' => 'Koʻrilmagan elementlar sinxronlanadi',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}',
|
||||
|
||||
@@ -380,10 +380,6 @@ class Translations$settings$zh extends Translations$settings$en {
|
||||
@override String get autoPlayNextEpisodeDescription => '当前剧集结束时自动播放下一集';
|
||||
@override String get playNextCountdown => '播放下一集倒计时';
|
||||
@override String get playNextCountdownImmediate => '立即播放';
|
||||
@override String get autoSkipIntro => '自动跳过片头';
|
||||
@override String get autoSkipIntroDescription => '几秒钟后自动跳过片头标记';
|
||||
@override String get autoSkipCredits => '自动跳过片尾';
|
||||
@override String get autoSkipCreditsDescription => '自动跳过片尾并播放下一集';
|
||||
@override String get forceSkipMarkerFallback => '强制使用备用标记';
|
||||
@override String get forceSkipMarkerFallbackDescription => '即使 Plex 有标记,也使用章节标题模式';
|
||||
@override String get autoSkipDelay => '自动跳过延迟';
|
||||
@@ -3184,10 +3180,6 @@ extension on TranslationsZh {
|
||||
'settings.autoPlayNextEpisodeDescription' => '当前剧集结束时自动播放下一集',
|
||||
'settings.playNextCountdown' => '播放下一集倒计时',
|
||||
'settings.playNextCountdownImmediate' => '立即播放',
|
||||
'settings.autoSkipIntro' => '自动跳过片头',
|
||||
'settings.autoSkipIntroDescription' => '几秒钟后自动跳过片头标记',
|
||||
'settings.autoSkipCredits' => '自动跳过片尾',
|
||||
'settings.autoSkipCreditsDescription' => '自动跳过片尾并播放下一集',
|
||||
'settings.forceSkipMarkerFallback' => '强制使用备用标记',
|
||||
'settings.forceSkipMarkerFallbackDescription' => '即使 Plex 有标记,也使用章节标题模式',
|
||||
'settings.autoSkipDelay' => '自动跳过延迟',
|
||||
@@ -3439,12 +3431,12 @@ extension on TranslationsZh {
|
||||
'fileInfo.has64bitOffsets' => '64 位偏移量',
|
||||
'fileInfo.protocol' => '协议',
|
||||
'fileInfo.mediaType' => '媒体类型',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => '来源类型',
|
||||
'fileInfo.optimizedVersion' => '优化版本',
|
||||
'fileInfo.optimizationTarget' => '优化目标',
|
||||
'fileInfo.deletedAt' => '已删除',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => '远程来源',
|
||||
'fileInfo.infiniteStream' => '无限流',
|
||||
'fileInfo.directPlay' => '直接播放',
|
||||
@@ -3953,12 +3945,12 @@ extension on TranslationsZh {
|
||||
'libraries.filterCategories.contentRating' => '内容分级',
|
||||
'libraries.filterCategories.tag' => '标签',
|
||||
'libraries.filterCategories.unwatched' => '未观看',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => '未播放',
|
||||
'libraries.filterCategories.favorites' => '收藏夹',
|
||||
'libraries.sortLabels.title' => '标题',
|
||||
'libraries.sortLabels.dateAdded' => '添加日期',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => '发行日期',
|
||||
'libraries.sortLabels.rating' => '评分',
|
||||
'libraries.sortLabels.communityRating' => '社区评分',
|
||||
@@ -4467,12 +4459,12 @@ extension on TranslationsZh {
|
||||
'downloads.syncRuleCleanupUnavailable' => '无法安全识别相关下载。请重新连接服务器后再试,或在不删除下载的情况下移除规则。',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '已为 ${title} 同步 ${count} 个新剧集',
|
||||
'downloads.activeSyncRules' => '同步规则',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => '没有同步规则',
|
||||
'downloads.manageSyncRule' => '管理同步',
|
||||
'downloads.editEpisodeCount' => '剧集数量',
|
||||
'downloads.editSyncFilter' => '同步筛选',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => '同步所有项目',
|
||||
'downloads.syncUnwatchedItems' => '同步未观看项目',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '服务器:${server} • ${status}',
|
||||
|
||||
@@ -381,10 +381,6 @@ class _Translations$settings$zh_Hant extends Translations$settings$zh {
|
||||
@override String get autoPlayNextEpisodeDescription => '目前單集結束時自動播放下一集';
|
||||
@override String get playNextCountdown => '下一集倒數計時';
|
||||
@override String get playNextCountdownImmediate => '立即播放';
|
||||
@override String get autoSkipIntro => '自動跳過片頭';
|
||||
@override String get autoSkipIntroDescription => '幾秒鐘後自動跳過片頭標記';
|
||||
@override String get autoSkipCredits => '自動跳過片尾';
|
||||
@override String get autoSkipCreditsDescription => '自動跳過片尾並播放下一集';
|
||||
@override String get forceSkipMarkerFallback => '強制使用備用標記';
|
||||
@override String get forceSkipMarkerFallbackDescription => '即使 Plex 有標記,也強制使用章節標題模式';
|
||||
@override String get autoSkipDelay => '自動跳過延遲';
|
||||
@@ -3185,10 +3181,6 @@ extension on TranslationsZhHant {
|
||||
'settings.autoPlayNextEpisodeDescription' => '目前單集結束時自動播放下一集',
|
||||
'settings.playNextCountdown' => '下一集倒數計時',
|
||||
'settings.playNextCountdownImmediate' => '立即播放',
|
||||
'settings.autoSkipIntro' => '自動跳過片頭',
|
||||
'settings.autoSkipIntroDescription' => '幾秒鐘後自動跳過片頭標記',
|
||||
'settings.autoSkipCredits' => '自動跳過片尾',
|
||||
'settings.autoSkipCreditsDescription' => '自動跳過片尾並播放下一集',
|
||||
'settings.forceSkipMarkerFallback' => '強制使用備用標記',
|
||||
'settings.forceSkipMarkerFallbackDescription' => '即使 Plex 有標記,也強制使用章節標題模式',
|
||||
'settings.autoSkipDelay' => '自動跳過延遲',
|
||||
@@ -3440,12 +3432,12 @@ extension on TranslationsZhHant {
|
||||
'fileInfo.has64bitOffsets' => '具 64 位元偏移量',
|
||||
'fileInfo.protocol' => '通訊協定',
|
||||
'fileInfo.mediaType' => '媒體類型',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => '來源類型',
|
||||
'fileInfo.optimizedVersion' => '最佳化版本',
|
||||
'fileInfo.optimizationTarget' => '最佳化目標',
|
||||
'fileInfo.deletedAt' => '已刪除',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.remoteSource' => '遠端來源',
|
||||
'fileInfo.infiniteStream' => '無限串流',
|
||||
'fileInfo.directPlay' => '直接播放',
|
||||
@@ -3954,12 +3946,12 @@ extension on TranslationsZhHant {
|
||||
'libraries.filterCategories.contentRating' => '分級',
|
||||
'libraries.filterCategories.tag' => '標籤',
|
||||
'libraries.filterCategories.unwatched' => '未觀看',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => '未播放',
|
||||
'libraries.filterCategories.favorites' => '我的最愛',
|
||||
'libraries.sortLabels.title' => '標題',
|
||||
'libraries.sortLabels.dateAdded' => '新增日期',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.sortLabels.releaseDate' => '發行日期',
|
||||
'libraries.sortLabels.rating' => '評分',
|
||||
'libraries.sortLabels.communityRating' => '社群評分',
|
||||
@@ -4468,12 +4460,12 @@ extension on TranslationsZhHant {
|
||||
'downloads.syncRuleCleanupUnavailable' => '無法安全地識別相關的下載內容。請重新連線伺服器後再試,或在不刪除下載內容的情況下移除規則。',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '已為 ${title} 同步 ${count} 個新單集',
|
||||
'downloads.activeSyncRules' => '同步規則',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => '沒有同步規則',
|
||||
'downloads.manageSyncRule' => '管理同步',
|
||||
'downloads.editEpisodeCount' => '單集數量',
|
||||
'downloads.editSyncFilter' => '同步篩選器',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.syncAllItems' => '同步所有項目',
|
||||
'downloads.syncUnwatchedItems' => '同步未觀看項目',
|
||||
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '伺服器:${server} • ${status}',
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Sidofältet förblir utfällt och innehållsytan anpassas efter det",
|
||||
"showUnwatchedCount": "Visa antal osedda",
|
||||
"showUnwatchedCountDescription": "Visa antal osedda avsnitt för serier och säsonger",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Visa avsnittsnummer på kort",
|
||||
"showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort",
|
||||
"showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar",
|
||||
"playNextCountdown": "Nedräkning för nästa avsnitt",
|
||||
"playNextCountdownImmediate": "Spela direkt",
|
||||
"autoSkipIntro": "Hoppa över intro automatiskt",
|
||||
"autoSkipIntroDescription": "Hoppa automatiskt över intromarkörer efter några sekunder",
|
||||
"autoSkipCredits": "Hoppa över eftertexter automatiskt",
|
||||
"autoSkipCreditsDescription": "Hoppa automatiskt över eftertexterna och spela nästa avsnitt",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Tvinga reservmarkörer",
|
||||
"forceSkipMarkerFallbackDescription": "Använd mönster i kapiteltitlar även när Plex har markörer",
|
||||
"autoSkipDelay": "Fördröjning före automatiskt hopp",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Finns inte i ditt bibliotek",
|
||||
"inTheseLibraries": "I dessa bibliotek",
|
||||
"checkingLibrary": "Kontrollerar ditt bibliotek...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Inget här ännu",
|
||||
"emptyMessage": "Rader från ${source} visas här när de har innehåll.",
|
||||
"searchHint": "Sök i ${source}",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Reläservern svarade inte i tid",
|
||||
"connectionLost": "Anslutningen stängdes innan sessionen var klar",
|
||||
"invalidRelayResponse": "Reläservern skickade ett oväntat svar",
|
||||
"sessionEnded": "Värden avslutade sessionen"
|
||||
"sessionEnded": "Värden avslutade sessionen",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr skapade ingen sessionscookie",
|
||||
"freshCookieRejected": "Seerr avvisade den nya sessionscookien",
|
||||
"noUserInformation": "Seerr returnerade ingen användarinformation",
|
||||
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen"
|
||||
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Tjänster",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Yan menü genişletilmiş kalır ve içerik alanı buna göre ayarlanır",
|
||||
"showUnwatchedCount": "İzlenmemiş Sayısını Göster",
|
||||
"showUnwatchedCountDescription": "Dizilerde ve sezonlarda izlenmemiş bölüm sayısını göster",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Kartlarda Bölüm Numarasını Göster",
|
||||
"showEpisodeNumberOnCardsDescription": "Bölüm kartlarında sezon ve bölüm numarasını göster",
|
||||
"showSeasonPostersOnTabs": "Sekmelerde Sezon Afişlerini Göster",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Bir bölüm bittiğinde sonraki bölümü otomatik başlat",
|
||||
"playNextCountdown": "Sonraki Bölüm Geri Sayımı",
|
||||
"playNextCountdownImmediate": "Hemen oynat",
|
||||
"autoSkipIntro": "Jeneriği Otomatik Atla",
|
||||
"autoSkipIntroDescription": "Kaç saniye sonra jenerik işaretçilerini otomatik atla",
|
||||
"autoSkipCredits": "Emeği Geçenleri Otomatik Atla",
|
||||
"autoSkipCreditsDescription": "Emeği geçenleri otomatik atla ve sonraki bölümü oynat",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Yedek İşaretçileri Zorla",
|
||||
"forceSkipMarkerFallbackDescription": "Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan",
|
||||
"autoSkipDelay": "Otomatik Atlama Gecikmesi",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Kitaplığınızda yok",
|
||||
"inTheseLibraries": "Bu kitaplıklarda var",
|
||||
"checkingLibrary": "Kitaplığınız kontrol ediliyor...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Henüz burada bir şey yok",
|
||||
"emptyMessage": "${source} kaynağındaki satırlar içerik bulunduğunda burada görünecektir.",
|
||||
"searchHint": "${source} üzerinde ara",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Aktarıcı sunucusu zamanında yanıt vermedi",
|
||||
"connectionLost": "Oturum hazır olmadan bağlantı kapandı",
|
||||
"invalidRelayResponse": "Aktarıcı sunucusu beklenmeyen bir yanıt gönderdi",
|
||||
"sessionEnded": "Kurucu oturumu sonlandırdı"
|
||||
"sessionEnded": "Kurucu oturumu sonlandırdı",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr bir oturum çerezi oluşturmadı",
|
||||
"freshCookieRejected": "Seerr yeni oturum çerezini reddetti",
|
||||
"noUserInformation": "Seerr kullanıcı bilgilerini döndürmedi",
|
||||
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi"
|
||||
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Servisler",
|
||||
|
||||
+24
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "Yon menyu ochiq holatda qoladi",
|
||||
"showUnwatchedCount": "Koʻrilmaganlar sonini koʻrsatish",
|
||||
"showUnwatchedCountDescription": "Seriallar va mavsumlarda koʻrilmagan qismlar sonini koʻrsatish",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "Kartochkalarda qism raqamini koʻrsatish",
|
||||
"showEpisodeNumberOnCardsDescription": "Qism kartochkalarida mavsum va qism raqamini koʻrsatish",
|
||||
"showSeasonPostersOnTabs": "Varaqlarda mavsum posterlarini koʻrsatish",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "Qism tugagach, keyingisini avtomatik ijro etish",
|
||||
"playNextCountdown": "Keyingi qism sanogʻi",
|
||||
"playNextCountdownImmediate": "Darhol ijro etish",
|
||||
"autoSkipIntro": "Kirish qismini (Intro) avtomatik oʻtkazish",
|
||||
"autoSkipIntroDescription": "Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish",
|
||||
"autoSkipCredits": "Titrlarni avtomatik oʻtkazish",
|
||||
"autoSkipCreditsDescription": "Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "Zahira belgilarini majburlash",
|
||||
"forceSkipMarkerFallbackDescription": "Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish",
|
||||
"autoSkipDelay": "Avtomatik oʻtkazish kechikishi",
|
||||
@@ -770,6 +779,8 @@
|
||||
"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",
|
||||
@@ -1254,6 +1265,10 @@
|
||||
"notInLibrary": "Kutubxonangizda yoʻq",
|
||||
"inTheseLibraries": "Ushbu kutubxonalarda bor",
|
||||
"checkingLibrary": "Kutubxona tekshirilmoqda...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Hali bu yerda hech narsa yoʻq",
|
||||
"emptyMessage": "${source} manbasidan olingan qatorlar bu yerda koʻrinadi.",
|
||||
"searchHint": "${source} ichidan qidirish",
|
||||
@@ -1647,7 +1662,8 @@
|
||||
"timedOut": "Rele serveri oʻz vaqtida javob bermadi",
|
||||
"connectionLost": "Seans tayyor boʻlmasidan ulanish uzildi",
|
||||
"invalidRelayResponse": "Rele serveri kutilmagan javob yubordi",
|
||||
"sessionEnded": "Tashkilotchi seansni tugatdi"
|
||||
"sessionEnded": "Tashkilotchi seansni tugatdi",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2103,7 +2119,9 @@
|
||||
"noSessionCookie": "Seerr seans cookie-faylini bermadi",
|
||||
"freshCookieRejected": "Seerr yangi seans cookie-faylini rad etdi",
|
||||
"noUserInformation": "Seerr foydalanuvchi maʼlumotlarini qaytarmadi",
|
||||
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi"
|
||||
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "Xizmatlar",
|
||||
|
||||
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "側邊欄保持展開狀態,內容區域自動調整",
|
||||
"showUnwatchedCount": "顯示未觀看數量",
|
||||
"showUnwatchedCountDescription": "在影集和單季上顯示未觀看的集數",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "在卡片上顯示集數",
|
||||
"showEpisodeNumberOnCardsDescription": "在單集卡片上顯示季和集編號",
|
||||
"showSeasonPostersOnTabs": "在索引標籤上顯示單季海報",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "目前單集結束時自動播放下一集",
|
||||
"playNextCountdown": "下一集倒數計時",
|
||||
"playNextCountdownImmediate": "立即播放",
|
||||
"autoSkipIntro": "自動跳過片頭",
|
||||
"autoSkipIntroDescription": "幾秒鐘後自動跳過片頭標記",
|
||||
"autoSkipCredits": "自動跳過片尾",
|
||||
"autoSkipCreditsDescription": "自動跳過片尾並播放下一集",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "強制使用備用標記",
|
||||
"forceSkipMarkerFallbackDescription": "即使 Plex 有標記,也強制使用章節標題模式",
|
||||
"autoSkipDelay": "自動跳過延遲",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "無法開始播放。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
|
||||
"errorLoadingSeries": "載入影集時發生錯誤",
|
||||
"musicNotSupported": "目前不支援播放音樂",
|
||||
@@ -1248,6 +1259,9 @@
|
||||
"notInLibrary": "不在您的媒體庫中",
|
||||
"inTheseLibraries": "在這些媒體庫中",
|
||||
"checkingLibrary": "正在檢查您的媒體庫…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "這裡還沒有任何內容",
|
||||
"emptyMessage": "當 ${source} 有內容時,相關資訊將顯示在此處。",
|
||||
"searchHint": "搜尋 ${source}",
|
||||
@@ -1637,7 +1651,8 @@
|
||||
"timedOut": "中繼伺服器未及時回應",
|
||||
"connectionLost": "工作階段就緒前連線已中斷",
|
||||
"invalidRelayResponse": "中繼伺服器傳回了非預期的回應",
|
||||
"sessionEnded": "主持人已結束工作階段"
|
||||
"sessionEnded": "主持人已結束工作階段",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2093,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerr 未發出工作階段 Cookie",
|
||||
"freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie",
|
||||
"noUserInformation": "Seerr 未傳回使用者資訊",
|
||||
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕"
|
||||
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "外部服務",
|
||||
|
||||
+23
-6
@@ -169,6 +169,8 @@
|
||||
"alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整",
|
||||
"showUnwatchedCount": "显示未观看数量",
|
||||
"showUnwatchedCountDescription": "在剧集和季上显示未观看集数",
|
||||
"showWatchedIndicators": "",
|
||||
"showWatchedIndicatorsDescription": "",
|
||||
"showEpisodeNumberOnCards": "在卡片上显示集数",
|
||||
"showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号",
|
||||
"showSeasonPostersOnTabs": "在选项卡上显示季海报",
|
||||
@@ -273,10 +275,17 @@
|
||||
"autoPlayNextEpisodeDescription": "当前剧集结束时自动播放下一集",
|
||||
"playNextCountdown": "播放下一集倒计时",
|
||||
"playNextCountdownImmediate": "立即播放",
|
||||
"autoSkipIntro": "自动跳过片头",
|
||||
"autoSkipIntroDescription": "几秒钟后自动跳过片头标记",
|
||||
"autoSkipCredits": "自动跳过片尾",
|
||||
"autoSkipCreditsDescription": "自动跳过片尾并播放下一集",
|
||||
"skipIntroMode": "",
|
||||
"skipIntroModeOffDescription": "",
|
||||
"skipIntroModeButtonDescription": "",
|
||||
"skipIntroModeAutoDescription": "",
|
||||
"skipCreditsMode": "",
|
||||
"skipCreditsModeOffDescription": "",
|
||||
"skipCreditsModeButtonDescription": "",
|
||||
"skipCreditsModeAutoDescription": "",
|
||||
"skipMarkerModeOff": "",
|
||||
"skipMarkerModeButton": "",
|
||||
"skipMarkerModeAuto": "",
|
||||
"forceSkipMarkerFallback": "强制使用备用标记",
|
||||
"forceSkipMarkerFallbackDescription": "即使 Plex 有标记,也使用章节标题模式",
|
||||
"autoSkipDelay": "自动跳过延迟",
|
||||
@@ -766,6 +775,8 @@
|
||||
"playbackDataInvalid": "服务器返回了无效的播放信息。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "无法开始播放。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
|
||||
"errorLoadingSeries": "加载剧集时出错",
|
||||
"musicNotSupported": "尚不支持播放音乐",
|
||||
@@ -1248,6 +1259,9 @@
|
||||
"notInLibrary": "不在你的媒体库中",
|
||||
"inTheseLibraries": "在这些媒体库中",
|
||||
"checkingLibrary": "正在检查你的媒体库…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "这里还什么都没有",
|
||||
"emptyMessage": "当 ${source} 有内容时,相关内容将显示在这里。",
|
||||
"searchHint": "搜索 ${source}",
|
||||
@@ -1637,7 +1651,8 @@
|
||||
"timedOut": "中继服务器未及时响应",
|
||||
"connectionLost": "会话准备就绪前连接已断开",
|
||||
"invalidRelayResponse": "中继服务器返回了意外的响应",
|
||||
"sessionEnded": "主持人已结束会话"
|
||||
"sessionEnded": "主持人已结束会话",
|
||||
"sessionUnavailable": ""
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
@@ -2093,7 +2108,9 @@
|
||||
"noSessionCookie": "Seerr 未签发会话 Cookie",
|
||||
"freshCookieRejected": "Seerr 拒绝了新的会话 Cookie",
|
||||
"noUserInformation": "Seerr 未返回用户信息",
|
||||
"sessionRejectedAfterReauth": "重新登录后会话被拒绝"
|
||||
"sessionRejectedAfterReauth": "重新登录后会话被拒绝",
|
||||
"permissionDenied": "",
|
||||
"permissionRevoked": ""
|
||||
},
|
||||
"services": {
|
||||
"title": "服务",
|
||||
|
||||
+23
-6
@@ -49,6 +49,7 @@ 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';
|
||||
@@ -1249,7 +1250,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
bool _isAutoDeleteRunning = false;
|
||||
bool _lastConnectivityWasWifi = false;
|
||||
bool _lastConnectivityHadNetwork = true;
|
||||
bool _shutdownStarted = false;
|
||||
Future<void>? _shutdownFuture;
|
||||
|
||||
/// Last time server health probes ran from a resume event (cooldown for desktop)
|
||||
DateTime _lastResumeProbe = DateTime(0);
|
||||
@@ -1302,10 +1303,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shutdownForExit() async {
|
||||
if (_shutdownStarted) return;
|
||||
_shutdownStarted = true;
|
||||
Future<void> _shutdownForExit() => _shutdownFuture ??= _runShutdownForExit().timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
appLogger.w('Application exit teardown exceeded its deadline');
|
||||
},
|
||||
);
|
||||
|
||||
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
|
||||
@@ -1320,6 +1325,14 @@ 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();
|
||||
@@ -1330,7 +1343,11 @@ 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.
|
||||
await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3), onTimeout: () {});
|
||||
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);
|
||||
}
|
||||
TrackerCoordinator.instance.cancelInFlight();
|
||||
|
||||
await _serverManager.shutdown();
|
||||
@@ -1349,7 +1366,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
_removeConnectivitySyncListener();
|
||||
_memoryCheckTimer?.cancel();
|
||||
_appLifecycleListener.dispose();
|
||||
if (!_shutdownStarted) {
|
||||
if (_shutdownFuture == null) {
|
||||
_libraryEventService.dispose();
|
||||
_downloadManager.dispose();
|
||||
_serverManager.dispose();
|
||||
|
||||
@@ -13,7 +13,7 @@ class AccountPreferenceTarget {
|
||||
required this.ref,
|
||||
required this.label,
|
||||
this.subtitle,
|
||||
this.isActiveProfileAccount = false,
|
||||
this.isDefaultConnection = false,
|
||||
});
|
||||
|
||||
final AccountRef ref;
|
||||
@@ -24,9 +24,9 @@ class AccountPreferenceTarget {
|
||||
/// Secondary row label: user · URL (MediaBrowser) or Home user (Plex).
|
||||
final String? subtitle;
|
||||
|
||||
/// Whether this is the account the active profile is currently browsing
|
||||
/// with. Used to order the picker; not a permission.
|
||||
final bool isActiveProfileAccount;
|
||||
/// Whether the profile designated this connection as its picker default.
|
||||
/// Presentation only: this does not establish playback authority.
|
||||
final bool isDefaultConnection;
|
||||
|
||||
MediaBackend get backend => ref.backend;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ class LibraryChangeEvent {
|
||||
final ServerId serverId;
|
||||
|
||||
/// Backend-native ids of the affected libraries (Plex section ids,
|
||||
/// MediaBrowser collection-folder ids). Empty when the backend did not say.
|
||||
/// MediaBrowser collection-folder ids). Empty when the backend did not say,
|
||||
/// which consumers must read as [targetsWholeServer] — never as "nothing".
|
||||
final Set<String> libraryIds;
|
||||
|
||||
/// Backend-native ids of removed items, when the backend named them (Plex
|
||||
@@ -41,6 +42,12 @@ class LibraryChangeEvent {
|
||||
|
||||
bool get hasChanges => itemsAdded || itemsRemoved || itemsUpdated;
|
||||
|
||||
/// Whether the change's scope is unidentified and therefore covers every
|
||||
/// library on [serverId]. Absorbing under coalescing: merging any event
|
||||
/// with a whole-server one must yield a whole-server event, never a
|
||||
/// narrower union of the named ids.
|
||||
bool get targetsWholeServer => libraryIds.isEmpty;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'LibraryChangeEvent($serverId, libraries: $libraryIds, '
|
||||
|
||||
@@ -23,6 +23,27 @@ class LiveProgramInfo {
|
||||
static const none = LiveProgramInfo();
|
||||
}
|
||||
|
||||
/// What a live heartbeat learned from the server. Both windows are
|
||||
/// `TranscodeSession` snapshots (epoch origin plus min/max offsets), but they
|
||||
/// describe different things:
|
||||
///
|
||||
/// - [captureBuffer]: the tuner's seekable history — the coordinate system
|
||||
/// for time-shift offsets and the timeline's range.
|
||||
/// - [playbackStream]: the transcode currently feeding the player. Its
|
||||
/// `startedAt` is the epoch of stream position zero, i.e. the exact clock
|
||||
/// anchor for `epoch = startedAt + player position`. Plex's own client
|
||||
/// derives the playhead from this object, not from wall clock (#2100).
|
||||
///
|
||||
/// Either may be null when the backend does not report it.
|
||||
class LiveTimelineUpdate {
|
||||
final CaptureBuffer? captureBuffer;
|
||||
final CaptureBuffer? playbackStream;
|
||||
|
||||
const LiveTimelineUpdate({this.captureBuffer, this.playbackStream});
|
||||
|
||||
bool get isEmpty => captureBuffer == null && playbackStream == null;
|
||||
}
|
||||
|
||||
/// One live-TV playback session, produced by [LiveTvSupport.startPlayback].
|
||||
///
|
||||
/// This is the backend-neutral handle the player drives; the
|
||||
@@ -80,9 +101,9 @@ abstract class LiveTvPlaybackSession {
|
||||
|
||||
/// Send a playback heartbeat (`'playing'` / `'paused'` / `'stopped'`).
|
||||
/// [positionMs] is elapsed playback time; [durationMs] the program
|
||||
/// duration when known. Returns an updated capture buffer when the backend
|
||||
/// supplies one, null otherwise.
|
||||
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs});
|
||||
/// duration when known. Returns what the backend reported back (capture
|
||||
/// window, playback-stream origin), null when it reports nothing.
|
||||
Future<LiveTimelineUpdate?> reportTimeline({required String state, required int positionMs, required int durationMs});
|
||||
|
||||
/// Re-establish playback after stream death. Plex re-tunes (the previous
|
||||
/// capture session expires while the player exhausts its reconnect
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'media_backend.dart';
|
||||
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
|
||||
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
|
||||
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
|
||||
/// and `api_key=` query fallback. Plezy therefore drives both through one
|
||||
/// and a token query fallback. Plezy therefore drives both through one
|
||||
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
|
||||
///
|
||||
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
|
||||
@@ -76,8 +76,16 @@ enum MediaBrowserDialect {
|
||||
MediaBrowserDialect.emby => const [8920, 8096],
|
||||
};
|
||||
|
||||
/// Jellyfin 12 rejects legacy `api_key` when `EnableLegacyAuthorization` is
|
||||
/// false, while Emby requires it; `ApiKey` works across Jellyfin versions.
|
||||
String get tokenQueryParam => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'ApiKey',
|
||||
MediaBrowserDialect.emby => 'api_key',
|
||||
};
|
||||
|
||||
/// Path of the realtime notification websocket. Same protocol on both
|
||||
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
|
||||
/// dialects (`?ApiKey=` on Jellyfin, `?api_key=` on Emby,
|
||||
/// `ForceKeepAlive`/`KeepAlive`,
|
||||
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
|
||||
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
|
||||
String get webSocketPath => switch (this) {
|
||||
@@ -117,6 +125,12 @@ 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,15 +2,42 @@ import 'ids.dart';
|
||||
import 'media_item.dart';
|
||||
import 'media_version.dart';
|
||||
|
||||
/// The library a copy belongs to: [MediaItem.libraryId] with the
|
||||
/// [MediaItem.libraryTitle] that labels it. The two travel as a pair, since a
|
||||
/// title is only meaningful next to the id it names.
|
||||
typedef _LibraryPair = ({String? libraryId, String? libraryTitle});
|
||||
|
||||
/// The pair [fresh] belongs to, backed by [known] only where [fresh] leaves
|
||||
/// room for it.
|
||||
///
|
||||
/// A fresh copy naming a different library than [known] takes both fields
|
||||
/// verbatim — a null title too, so the UI falls back to the server name rather
|
||||
/// than labeling the new library with the old one's name. One naming the same
|
||||
/// library fills a missing title from [known]. One naming no library at all
|
||||
/// keeps the known pair: the stamp is best-effort on both backends, and its
|
||||
/// absence is not a move.
|
||||
_LibraryPair _libraryPair(MediaItem fresh, MediaItem? known) {
|
||||
final freshId = fresh.libraryId;
|
||||
final knownId = known?.libraryId;
|
||||
if (freshId == null) {
|
||||
return knownId == null
|
||||
? (libraryId: null, libraryTitle: fresh.libraryTitle ?? known?.libraryTitle)
|
||||
: (libraryId: knownId, libraryTitle: known!.libraryTitle);
|
||||
}
|
||||
return (libraryId: freshId, libraryTitle: fresh.libraryTitle ?? (freshId == knownId ? known!.libraryTitle : null));
|
||||
}
|
||||
|
||||
/// Merge freshly fetched metadata with identity and library context already
|
||||
/// known by the caller. The fetched item owns descriptive fields, while
|
||||
/// existing context wins when the backend omits it.
|
||||
/// existing context wins when the backend omits it. Library id and title
|
||||
/// follow [_libraryPair].
|
||||
MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId fallbackServerId, MediaItem? existing}) {
|
||||
final library = _libraryPair(fetched, existing);
|
||||
return fetched.copyWith(
|
||||
serverId: existing?.serverId ?? fetched.serverId ?? fallbackServerId,
|
||||
serverName: existing?.serverName ?? fetched.serverName,
|
||||
libraryId: fetched.libraryId ?? existing?.libraryId,
|
||||
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
|
||||
libraryId: library.libraryId,
|
||||
libraryTitle: library.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,26 +67,44 @@ int compareLibraryCopies(MediaItem a, MediaItem b) {
|
||||
return a.globalKey.compareTo(b.globalKey);
|
||||
}
|
||||
|
||||
/// Fold a re-resolved copy into the one already on screen.
|
||||
/// Fold a re-resolved copy into the one already known for its
|
||||
/// [MediaItem.globalKey].
|
||||
///
|
||||
/// The addition is fresher, but a degraded pass must not erase context. The
|
||||
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
|
||||
/// hands back an unstamped item when it fails, and a copy that lost its
|
||||
/// library title is indistinguishable from its sibling in the same server's
|
||||
/// other library — exactly the ambiguity the chooser exists to resolve. The
|
||||
/// version list behind the resolution hint is treated the same way.
|
||||
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
|
||||
/// library pair follows [_libraryPair]; the version list behind the
|
||||
/// resolution hint is treated the same way as the stamp.
|
||||
///
|
||||
/// Shared by the on-screen union ([mergeLibraryCopies]) and the per-server
|
||||
/// replacement in `CatalogLibraryMatcher`, so one policy decides what a
|
||||
/// returning copy keeps from its predecessor. Returns [addition] itself when
|
||||
/// it inherits nothing: [MediaItem] compares by identity, and a caller
|
||||
/// holding on to the fresh copy must still recognize it.
|
||||
MediaItem mergeLibraryCopy(MediaItem existing, MediaItem addition) {
|
||||
final versions = addition.mediaVersions;
|
||||
final library = _libraryPair(addition, existing);
|
||||
final serverName = addition.serverName ?? existing.serverName;
|
||||
final mediaVersions = versions == null || versions.isEmpty ? existing.mediaVersions : versions;
|
||||
if (library.libraryId == addition.libraryId &&
|
||||
library.libraryTitle == addition.libraryTitle &&
|
||||
serverName == addition.serverName &&
|
||||
identical(mediaVersions, versions)) {
|
||||
return addition;
|
||||
}
|
||||
return addition.copyWith(
|
||||
libraryId: addition.libraryId ?? existing.libraryId,
|
||||
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
|
||||
serverName: addition.serverName ?? existing.serverName,
|
||||
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
|
||||
libraryId: library.libraryId,
|
||||
libraryTitle: library.libraryTitle,
|
||||
serverName: serverName,
|
||||
mediaVersions: mediaVersions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by
|
||||
/// [mergeLibraryCopy].
|
||||
///
|
||||
/// Never removes a copy, and never downgrades one. The cross-server fan-out
|
||||
/// behind these lists logs and skips per-server failures, so a later pass can
|
||||
@@ -69,7 +114,7 @@ List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaIt
|
||||
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
|
||||
for (final item in additions) {
|
||||
final existing = byKey[item.globalKey];
|
||||
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
|
||||
byKey[item.globalKey] = existing == null ? item : mergeLibraryCopy(existing, item);
|
||||
}
|
||||
return byKey.values.toList()..sort(compareLibraryCopies);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ abstract class MediaServerClient {
|
||||
MediaBackend get backend;
|
||||
ServerCapabilities get capabilities;
|
||||
|
||||
/// Opaque identity of the committed authentication session, independent of
|
||||
/// the endpoint. Compare by identity: successful effective credential or
|
||||
/// profile changes install a fresh identity; failed and no-op updates do not.
|
||||
Object get authenticationSessionId;
|
||||
|
||||
/// Release HTTP resources and any other long-lived state. Idempotent.
|
||||
void close();
|
||||
|
||||
@@ -603,11 +608,13 @@ abstract class MediaServerClient {
|
||||
|
||||
/// Reverse lookup: find every library movie/show matching any of [ids].
|
||||
///
|
||||
/// Neither backend can filter by external id — Plex's `guid=` matches only
|
||||
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
|
||||
/// `anyProviderIdEquals` (silently ignored on 10.11.10) — so both search by
|
||||
/// title and verify candidates against their exact external ids. Title
|
||||
/// alone never produces a match.
|
||||
/// Neither backend can filter by the external ids a modern item carries —
|
||||
/// Plex's `guid=` sees only the primary guid (verified on PMS 1.43) and
|
||||
/// Jellyfin dropped `anyProviderIdEquals` (silently ignored on 10.11.10) —
|
||||
/// so both search by title and verify candidates against their exact
|
||||
/// external ids. Title alone never produces a match. Plex does reach a
|
||||
/// legacy-agent item by id, because that item's primary guid *is* the
|
||||
/// external id ([ExternalIds.legacyPlexGuidPrefixes]).
|
||||
///
|
||||
/// One title can own several library items: a server with a 4K section and
|
||||
/// an HD section holds two rating keys for the same movie, and a library
|
||||
@@ -618,12 +625,17 @@ abstract class MediaServerClient {
|
||||
/// the caller (the Explore "In these libraries" chooser) exists to show
|
||||
/// them. Ordering is the implementation's, and callers re-sort.
|
||||
///
|
||||
/// [titles] are tried in order until one yields id-verified candidates;
|
||||
/// pass the entry's own title first and broader forms after (see
|
||||
/// `titleMatchCandidates`). A sequel entry's own title never matches its
|
||||
/// parent show, which is why more than one is needed. [year] applies a ±1
|
||||
/// window to the first attempt only — for a sequel the catalog year is the
|
||||
/// season's, not the show's.
|
||||
/// 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
|
||||
/// 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
|
||||
/// backends whose title search is a substring match: a ±1 window on the
|
||||
/// first title only, since for a sequel the catalog year is the season's,
|
||||
/// not the show's.
|
||||
///
|
||||
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
|
||||
/// already holds, which the local server *can* filter on exactly. It is
|
||||
@@ -637,10 +649,14 @@ abstract class MediaServerClient {
|
||||
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
|
||||
/// on a guess.
|
||||
///
|
||||
/// Returns an empty list when this server has no match or [kind] is not
|
||||
/// movie/show. Used to match external catalog items (Explore tab) back to
|
||||
/// the user's libraries.
|
||||
Future<List<MediaItem>> findByExternalIds(
|
||||
/// Returns null when this backend cannot execute the supplied query (for
|
||||
/// example, Jellyfin/Emby need external ids and a title, whereas Plex can
|
||||
/// query a Plex guid or legacy-agent id without titles). Unsupported kinds
|
||||
/// also return null. An empty list means a supported lookup completed and
|
||||
/// found no match. Failures and cancellation throw rather than supplying
|
||||
/// negative membership evidence. Used to match external catalog items
|
||||
/// (Explore tab) back to the user's libraries.
|
||||
Future<List<MediaItem>?> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> titles = const [],
|
||||
@@ -676,7 +692,15 @@ abstract class MediaServerClient {
|
||||
/// playback path to recover audio/subtitle track info (track ids, language
|
||||
/// codes, displayTitles) without hitting the network. Returns `null` when
|
||||
/// the row isn't cached or carries no usable media source.
|
||||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
|
||||
/// Uses playback's source-selection order: stable [mediaSourceId], then a
|
||||
/// sibling [preferredVersionSignature], then [mediaIndex], subject to the
|
||||
/// backend's playable-source rules.
|
||||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(
|
||||
String itemId, {
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
String? preferredVersionSignature,
|
||||
});
|
||||
|
||||
/// Build a scrub preview source for [item] using [mediaSource]. Plex
|
||||
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
|
||||
|
||||
@@ -15,6 +15,10 @@ class MediaSourceInfo {
|
||||
/// `MediaSources` id.
|
||||
final String? mediaSourceId;
|
||||
|
||||
/// Effective version/part positions after backend selection and fallback.
|
||||
final int? mediaIndex;
|
||||
final int? partIndex;
|
||||
|
||||
/// Jellyfin default stream indexes for this source. A subtitle index of -1
|
||||
/// is an explicit server/user decision to start with subtitles off.
|
||||
final int? defaultAudioStreamIndex;
|
||||
@@ -36,6 +40,8 @@ class MediaSourceInfo {
|
||||
this.partId,
|
||||
this.displayCriteria,
|
||||
this.mediaSourceId,
|
||||
this.mediaIndex,
|
||||
this.partIndex,
|
||||
this.defaultAudioStreamIndex,
|
||||
this.defaultSubtitleStreamIndex,
|
||||
this.trickplayByWidth,
|
||||
@@ -54,6 +60,8 @@ class MediaSourceInfo {
|
||||
partId: partId,
|
||||
displayCriteria: displayCriteria,
|
||||
mediaSourceId: mediaSourceId,
|
||||
mediaIndex: mediaIndex,
|
||||
partIndex: partIndex,
|
||||
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
trickplayByWidth: trickplayByWidth,
|
||||
@@ -108,6 +116,9 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
final String? displayTitle;
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
|
||||
/// Container fallback, independent of the server/user-selected row.
|
||||
final bool isDefault;
|
||||
final bool external;
|
||||
|
||||
MediaAudioTrack({
|
||||
@@ -120,6 +131,7 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
this.displayTitle,
|
||||
this.channels,
|
||||
required this.selected,
|
||||
this.isDefault = false,
|
||||
this.external = false,
|
||||
});
|
||||
|
||||
@@ -137,6 +149,7 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
displayTitle: displayTitle,
|
||||
channels: channels,
|
||||
selected: selected,
|
||||
isDefault: isDefault,
|
||||
external: external,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Manages a map of grid-item [FocusNode]s with focus-tracking and restoration.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Lazy creation of per-index focus nodes via [getGridItemFocusNode].
|
||||
/// - Focus tracking ([lastFocusedGridIndex], [gridContentVersion]) so callers
|
||||
/// can restore focus after rebuilds.
|
||||
/// - [cleanupGridFocusNodes] to prune nodes for indices beyond the current count.
|
||||
/// - [disposeGridFocusNodes] for full teardown.
|
||||
/// A detached FocusNode retains its last context, even after that host unmounts.
|
||||
/// Keep the attachment token so eviction can distinguish history from borrowers.
|
||||
class _GridFocusNode extends FocusNode {
|
||||
_GridFocusNode({super.debugLabel});
|
||||
|
||||
FocusAttachment? _gridAttachment;
|
||||
bool _hasPendingFocusRequest = false;
|
||||
|
||||
bool get _isAttached => _gridAttachment?.isAttached ?? false;
|
||||
|
||||
@override
|
||||
FocusAttachment attach(
|
||||
BuildContext? context, {
|
||||
FocusOnKeyEventCallback? onKeyEvent,
|
||||
// ignore: deprecated_member_use
|
||||
FocusOnKeyCallback? onKey,
|
||||
}) {
|
||||
// Forward the legacy argument because this still implements FocusNode.attach.
|
||||
// ignore: deprecated_member_use
|
||||
final attachment = super.attach(context, onKeyEvent: onKeyEvent, onKey: onKey);
|
||||
_gridAttachment = attachment;
|
||||
_hasPendingFocusRequest = false;
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@override
|
||||
void requestFocus([FocusNode? node]) {
|
||||
if (node == null && !_isAttached && parent == null && canRequestFocus) {
|
||||
_hasPendingFocusRequest = true;
|
||||
}
|
||||
super.requestFocus(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hasPendingFocusRequest = false;
|
||||
super.dispose();
|
||||
_gridAttachment = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns grid nodes independently of their current spatial index. A content
|
||||
/// commit reconciles every realized item's node, including scope history and
|
||||
/// references captured by menus; it never requests focus behind a cover.
|
||||
mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
final Map<int, FocusNode> gridItemFocusNodes = {};
|
||||
final Map<int, Object> _gridItemIdentities = {};
|
||||
final Set<FocusNode> _retiredGridFocusNodes = {};
|
||||
bool _gridRetirementScheduled = false;
|
||||
int? lastFocusedGridIndex;
|
||||
int gridContentVersion = 0;
|
||||
int lastFocusedGridContentVersion = 0;
|
||||
|
||||
/// Get or create a focus node for a grid item at [index].
|
||||
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item'}) {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index'));
|
||||
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item', String? debugLabel}) {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => _GridFocusNode(debugLabel: debugLabel ?? '${prefix}_$index'));
|
||||
}
|
||||
|
||||
/// Get the focus node for [index], routing index 0 through [firstNode] when
|
||||
/// the grid pins a dedicated node for the first item (e.g. `firstItemFocusNode`).
|
||||
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix}) {
|
||||
/// [firstNode] must resolve the current index-zero node owned by this mixin,
|
||||
/// not a permanently slot-bound node owned by the caller.
|
||||
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix, Object? itemIdentity}) {
|
||||
if (itemIdentity != null) _gridItemIdentities[index] = itemIdentity;
|
||||
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
|
||||
}
|
||||
|
||||
@@ -32,39 +71,88 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last-focused index is still valid for restoration.
|
||||
/// Atomically reassign spatial indices after an accepted content commit.
|
||||
/// Surviving items keep the same node and attachment. Removed items retire
|
||||
/// after their cards unmount, so captured references cannot focus a new item.
|
||||
void reconcileGridFocusNodes(Map<Object, int> indices) {
|
||||
final nodes = <int, FocusNode>{};
|
||||
final identities = <int, Object>{};
|
||||
final rememberedIndex = lastFocusedGridIndex;
|
||||
int? restoredIndex;
|
||||
for (final entry in gridItemFocusNodes.entries) {
|
||||
final identity = _gridItemIdentities[entry.key];
|
||||
final index = identity == null ? null : indices[identity];
|
||||
if (index == null) {
|
||||
_retireGridFocusNode(entry.value);
|
||||
} else {
|
||||
nodes[index] = entry.value;
|
||||
identities[index] = identity!;
|
||||
if (entry.key == rememberedIndex) restoredIndex = index;
|
||||
}
|
||||
}
|
||||
gridItemFocusNodes
|
||||
..clear()
|
||||
..addAll(nodes);
|
||||
_gridItemIdentities
|
||||
..clear()
|
||||
..addAll(identities);
|
||||
lastFocusedGridIndex = restoredIndex;
|
||||
lastFocusedGridContentVersion = gridContentVersion;
|
||||
}
|
||||
|
||||
bool get shouldRestoreGridFocus =>
|
||||
lastFocusedGridIndex != null && lastFocusedGridContentVersion == gridContentVersion && lastFocusedGridIndex! >= 0;
|
||||
|
||||
void _retireGridFocusNode(FocusNode node) {
|
||||
// Disable stale menu/route destinations immediately, without issuing a
|
||||
// replacement request. The owning scope supplies its normal fallback.
|
||||
node.canRequestFocus = false;
|
||||
if (node is _GridFocusNode) node._hasPendingFocusRequest = false;
|
||||
_retiredGridFocusNodes.add(node);
|
||||
if (_gridRetirementScheduled) return;
|
||||
_gridRetirementScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_gridRetirementScheduled = false;
|
||||
while (_retiredGridFocusNodes.isNotEmpty) {
|
||||
final retired = _retiredGridFocusNodes.first;
|
||||
_retiredGridFocusNodes.remove(retired);
|
||||
retired.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cleanupGridFocusNodes(int itemCount) {
|
||||
final keysToRemove = gridItemFocusNodes.keys.where((i) => i >= itemCount).toList();
|
||||
for (final key in keysToRemove) {
|
||||
gridItemFocusNodes[key]?.dispose();
|
||||
gridItemFocusNodes.remove(key);
|
||||
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
|
||||
_gridItemIdentities.remove(key);
|
||||
}
|
||||
if (lastFocusedGridIndex != null && lastFocusedGridIndex! >= itemCount) lastFocusedGridIndex = null;
|
||||
}
|
||||
|
||||
/// Evict focus nodes far from [centerIndex], keeping at most [keepCount] around it.
|
||||
/// Evict detached nodes far from the viewport, never live attachments or
|
||||
/// remembered/pending destinations. The budget is soft while those survive.
|
||||
void evictDistantFocusNodes(int centerIndex, {int keepCount = 200}) {
|
||||
if (gridItemFocusNodes.length <= keepCount) return;
|
||||
|
||||
final halfKeep = keepCount ~/ 2;
|
||||
final keepStart = centerIndex - halfKeep;
|
||||
final keepEnd = centerIndex + halfKeep;
|
||||
|
||||
final keysToRemove = <int>[];
|
||||
for (final key in gridItemFocusNodes.keys) {
|
||||
if (key < keepStart || key > keepEnd) {
|
||||
keysToRemove.add(key);
|
||||
for (final entry in gridItemFocusNodes.entries) {
|
||||
final node = entry.value;
|
||||
if ((entry.key < keepStart || entry.key > keepEnd) &&
|
||||
node is _GridFocusNode &&
|
||||
!node._isAttached &&
|
||||
!node._hasPendingFocusRequest &&
|
||||
!(shouldRestoreGridFocus && entry.key == lastFocusedGridIndex) &&
|
||||
!node.hasFocus &&
|
||||
node.enclosingScope?.focusedChild != node) {
|
||||
keysToRemove.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in keysToRemove) {
|
||||
final node = gridItemFocusNodes[key];
|
||||
// A focused node is still borrowed by its mounted card. Keep ownership
|
||||
// and indexed identity until a later eviction or final teardown.
|
||||
if (node == null || node.hasFocus) continue;
|
||||
gridItemFocusNodes.remove(key);
|
||||
node.dispose();
|
||||
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
|
||||
_gridItemIdentities.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +160,11 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
for (final node in gridItemFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
for (final node in _retiredGridFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
gridItemFocusNodes.clear();
|
||||
_gridItemIdentities.clear();
|
||||
_retiredGridFocusNodes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'grid_focus_node_mixin.dart';
|
||||
|
||||
/// Mixin that provides focus management for library tabs.
|
||||
/// Handles the lifecycle of a focus node for the first item and provides
|
||||
/// a method to request focus on that item.
|
||||
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Focus node for the first item (for programmatic focus)
|
||||
late final FocusNode firstItemFocusNode;
|
||||
/// Tab-entry focus resolves the current first item's grid-owned node. Reorders
|
||||
/// may move the former first node elsewhere without invalidating menu restores.
|
||||
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T>, GridFocusNodeMixin<T> {
|
||||
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: focusNodeDebugLabel);
|
||||
|
||||
String get focusNodeDebugLabel;
|
||||
|
||||
int get itemCount;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstItemFocusNode = FocusNode(debugLabel: focusNodeDebugLabel);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
firstItemFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Focus the first item in the grid/list (for tab activation)
|
||||
void focusFirstItem() {
|
||||
if (itemCount > 0) {
|
||||
|
||||
@@ -424,7 +424,13 @@ class CatalogItem {
|
||||
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
|
||||
endDate: detail.endDate ?? endDate,
|
||||
originalTitle: detail.originalTitle ?? originalTitle,
|
||||
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
|
||||
// Detail aliases get first chance at the bounded lookup budget, without
|
||||
// losing names known only to the row.
|
||||
altTitles: detail.altTitles.isEmpty
|
||||
? altTitles
|
||||
: altTitles.isEmpty
|
||||
? detail.altTitles
|
||||
: {...detail.altTitles, ...altTitles}.toList(),
|
||||
tagline: detail.tagline ?? tagline,
|
||||
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
|
||||
format: detail.format ?? format,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user