fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
+12
View File
@@ -88,6 +88,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="false"
android:grantUriPermissions="true" />
<receiver
android:name=".watchnext.SystemShelfUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -6,6 +6,8 @@ import android.content.Context
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.mpv.completeMpvPropertyNotInitialized
import com.edde746.plezy.mpv.completeMpvPropertyResult
import com.edde746.plezy.shared.MpvContentUriResolver
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -336,24 +338,64 @@ class ExoPlayerPlugin :
}
}
private fun handlePlay(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "no")
} else {
playerCore?.play()
private fun handleFallbackMpvProperty(
name: String,
value: String,
result: MethodChannel.Result,
successValue: Any? = null
) {
val currentActivity = activity
val core = mpvCore
if (currentActivity == null || core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
val generation = sessionGeneration
currentActivity.runOnUiThread {
if (
!usingMpvFallback ||
generation != sessionGeneration ||
activity !== currentActivity ||
mpvCore !== core ||
!core.isInitialized
) {
completeMpvPropertyNotInitialized(result)
return@runOnUiThread
}
core.setProperty(name, value) { outcome ->
val currentOutcome = if (
usingMpvFallback &&
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
) {
outcome
} else {
Result.failure(IllegalStateException("MPV fallback unavailable"))
}
completeMpvPropertyResult(result, currentOutcome, successValue)
}
}
}
private fun handlePlay(result: MethodChannel.Result) {
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "no", result)
return
}
activity?.runOnUiThread {
playerCore?.play()
result.success(null)
} ?: result.success(null)
}
private fun handlePause(result: MethodChannel.Result) {
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "yes", result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "yes")
} else {
playerCore?.pause()
}
playerCore?.pause()
result.success(null)
} ?: result.success(null)
}
@@ -397,12 +439,12 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
handleFallbackMpvProperty("volume", volume.toString(), result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("volume", volume.toString())
} else {
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
}
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
result.success(null)
} ?: result.success(null)
}
@@ -415,12 +457,12 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
handleFallbackMpvProperty("speed", rate.toString(), result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("speed", rate.toString())
} else {
playerCore?.setPlaybackSpeed(rate)
}
playerCore?.setPlaybackSpeed(rate)
result.success(null)
} ?: result.success(null)
}
@@ -433,13 +475,13 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
handleFallbackMpvProperty("aid", trackId, result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
mpvCore?.setProperty("aid", trackId)
} else {
playerCore?.selectAudioTrack(trackId)
}
playerCore?.selectAudioTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -448,12 +490,12 @@ class ExoPlayerPlugin :
val trackId = call.argument<String>("trackId")
// trackId can be null or "no" to disable subtitles
if (usingMpvFallback) {
handleFallbackMpvProperty("sid", trackId ?: "no", result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("sid", trackId ?: "no")
} else {
playerCore?.selectSubtitleTrack(trackId)
}
playerCore?.selectSubtitleTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -705,8 +747,7 @@ class ExoPlayerPlugin :
val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else ""
pendingMpvProperties["audio-spdif"] = audioSpdif
if (usingMpvFallback) {
mpvCore?.setProperty("audio-spdif", audioSpdif)
result.success(true)
handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true)
return
}
activity?.runOnUiThread {
@@ -724,24 +765,23 @@ class ExoPlayerPlugin :
return
}
// Apply sync offsets to ExoPlayer when active
if (!usingMpvFallback) {
when (name) {
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
}
if (usingMpvFallback) {
handleFallbackMpvProperty(name, value, result)
return
}
if (usingMpvFallback) {
mpvCore?.setProperty(name, value)
} else {
// Store for later application if ExoPlayer falls back to MPV
pendingMpvProperties[name] = value
// Apply sync offsets to ExoPlayer when active
when (name) {
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
}
// Before fallback this is queue acceptance, not a completed MPV write.
pendingMpvProperties[name] = value
result.success(null)
}
@@ -867,7 +907,11 @@ class ExoPlayerPlugin :
}
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
core.setProperty(propName, propValue) { outcome ->
if (outcome.isFailure) {
Log.w(TAG, "Failed to replay queued MPV property")
}
}
}
// Re-observe exactly what Dart registered via observeProperty, so the
@@ -35,10 +35,19 @@ import kotlinx.coroutines.sync.withLock
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
class MpvPlayerCore(
class MpvPlayerCore private constructor(
private val context: Context,
private val audioOnly: Boolean = false
private val audioOnly: Boolean,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
initializedForTesting: Boolean
) : SurfaceHolder.Callback {
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : this(context, audioOnly, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
@@ -71,6 +80,10 @@ class MpvPlayerCore(
var isInitialized: Boolean = false
private set
init {
if (initializedForTesting) isInitialized = true
}
@Volatile private var player: MpvPlayer? = null
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val endFileDiagnostics = MpvEndFileDiagnostics()
@@ -691,43 +704,66 @@ class MpvPlayerCore(
// Public API
fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(false)
onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
return
}
if (name == "pause") {
val paused = normalizePauseValue(value)
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
val paused = if (name == "pause") normalizePauseValue(value) else null
if (paused == false && !hasReadyVideoOutput()) {
runOnMain {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
resumeBlockedByPublicPause = false
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
onComplete?.invoke(true)
return
}
cachedPaused = false
pausedForSurfaceLoss = false
Log.d(TAG, "Public pause state updated: paused=false")
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
onComplete?.invoke(Result.success(Unit))
}
return
}
scope.launch(mpvWriteDispatcher) {
var success = false
try {
player?.setProperty(name, value)
success = true
} catch (e: Exception) {
Log.w(TAG, "setProperty($name) failed", e)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(success)
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val writeResult = try {
val writer = propertyWriterOverride
if (writer != null) {
writer(name, value)
} else {
val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
currentPlayer.setProperty(name, value)
}
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "MPV property write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
val completion = if (disposing || !isInitialized) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
writeResult
}
if (completion.isSuccess) {
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
cachedPaused = false
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = false
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=false")
}
}
onComplete?.invoke(completion)
}
}
}
@@ -13,6 +13,26 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
outcome: Result<Unit>,
successValue: Any? = null
) {
if (outcome.isSuccess) {
result.success(successValue)
} else {
result.error(
"SET_PROPERTY_FAILED",
"MPV property write was rejected or cancelled",
null
)
}
}
internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
result.error("NOT_INITIALIZED", "Player not initialized", null)
}
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
@@ -261,13 +281,13 @@ open class MpvPlayerPlugin(
}
val core = playerCore
if (core == null) {
result.success(null)
if (core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
core.setProperty(name, value) {
result.success(null)
core.setProperty(name, value) { outcome ->
completeMpvPropertyResult(result, outcome)
}
}
@@ -0,0 +1,174 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
class SystemShelfArtworkProvider : ContentProvider() {
companion object {
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
}
override fun onCreate(): Boolean = context != null
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("Read-only artwork")
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
?: throw FileNotFoundException("Unknown artwork")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
internal class SystemShelfArtworkStore(private val cacheDir: File) {
companion object {
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
const val MAX_ITEMS = 20
const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
}
data class Materialized(val key: String, val uri: Uri, val file: File)
class Budget(var remaining: Int = MAX_SYNC_BYTES)
private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun materialize(ownerId: String, source: String, budget: Budget): Materialized? {
if (ownerId.isBlank() || budget.remaining <= 0) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
return try {
connection.instanceFollowRedirects = true
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.useCaches = false
connection.setRequestProperty("Accept", "image/*")
val status = connection.responseCode
if (status !in 200..299) return null
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
val contentLength = connection.contentLengthLong
val cap = minOf(MAX_IMAGE_BYTES, budget.remaining)
if (contentLength > cap) return null
val bytes = connection.inputStream.use { input ->
val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap))
val buffer = ByteArray(16 * 1024)
var total = 0
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > cap) return null
output.write(buffer, 0, read)
}
output.toByteArray()
}
if (!isSupportedImage(bytes)) return null
val ownerKey = sha256(ownerId)
val directory = File(root, ownerKey)
if (!directory.mkdirs() && !directory.isDirectory) return null
val key = UUID.randomUUID().toString().replace("-", "") + ".art"
val staged = File(directory, ".$key.tmp")
staged.outputStream().use { output ->
output.write(bytes)
output.flush()
output.fd.sync()
}
val destination = File(directory, key)
if (!staged.renameTo(destination)) {
staged.delete()
return null
}
budget.remaining -= bytes.size
Materialized(key, contentUri(ownerKey, key), destination)
} catch (_: Exception) {
null
} finally {
connection.disconnect()
}
}
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
.scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY)
.appendPath("art")
.appendPath(ownerKey)
.appendPath(key)
.build()
fun resolve(uri: Uri): File? {
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1]
val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
val canonicalRoot = root.canonicalFile
val candidate = File(File(canonicalRoot, owner), key).canonicalFile
if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null
return candidate
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile }
root.listFiles()?.forEach { ownerDirectory ->
ownerDirectory.listFiles()?.forEach { file ->
if (file.canonicalFile !in canonicalKeep) file.delete()
}
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
}
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun isSupportedImage(bytes: ByteArray): Boolean {
if (bytes.size < 4) return false
val png = bytes.size >= 8 &&
bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte()
val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()
val webp = bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray())
if (!png && !jpeg && !gif && !webp) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
val width = options.outWidth
val height = options.outHeight
return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L
}
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte) }
}
@@ -0,0 +1,30 @@
package com.edde746.plezy.watchnext
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */
class SystemShelfUpdateReceiver private constructor(
private val executor: Executor,
private val ownsExecutor: Boolean
) : BroadcastReceiver() {
constructor() : this(Executors.newSingleThreadExecutor(), true)
internal constructor(executor: Executor) : this(executor, false)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val pending = goAsync()
executor.execute {
try {
WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
} finally {
pending?.finish()
if (ownsExecutor) (executor as ExecutorService).shutdown()
}
}
}
}
@@ -12,30 +12,23 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/**
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
class WatchNextPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
private var pendingDeepLink: String? = null
/**
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
return if (data.scheme == "plezy" && data.authority == "play") {
data.getQueryParameter("content_id")
} else {
null
}
return null
}
}
@@ -63,7 +56,7 @@ class WatchNextPlugin :
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"clear" -> handleClear(call, result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
@@ -72,52 +65,43 @@ class WatchNextPlugin :
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
}
private fun ownerArguments(call: MethodCall): Pair<String, Long>? {
if (call.argument<Number>("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
val owner = call.argument<String>("ownerId")?.takeIf(String::isNotBlank) ?: return null
val generation = call.argument<Number>("generation")?.toLong()?.takeIf { it > 0 } ?: return null
return owner to generation
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
?: return result.error("INVALID_ARGS", "Missing items", null)
if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
return result.error("INVALID_ARGS", "Too many items", null)
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
val items = itemsData.mapNotNull(::parseWatchNextItem)
executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
executeOnIo(result) { provider.clearAll() }
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
executeOnIo(result) { provider.clearAll(owner, generation) }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
?: return result.error("INVALID_ARGS", "Missing contentId", null)
executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
@@ -126,12 +110,12 @@ class WatchNextPlugin :
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
} catch (_: Exception) {
Log.e(TAG, "System shelf IO operation failed")
mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
} catch (_: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
@@ -143,22 +127,18 @@ class WatchNextPlugin :
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
val type = when ((data["type"] as? String)?.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
posterSourceUri = data["posterSourceUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
@@ -169,16 +149,12 @@ class WatchNextPlugin :
)
}
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
} catch (_: Exception) {
Log.d(TAG, "Method channel not ready; deep link retained")
}
}
}
@@ -3,19 +3,19 @@ package com.edde746.plezy.watchnext
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
/**
* Wraps Android TvProvider API for Watch Next row integration.
* Manages WatchNextProgram entries for Plex "On Deck" content.
*/
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
class WatchNextProvider(private val context: Context) {
companion object {
private const val TAG = "WatchNextProvider"
private const val PREFS = "system_shelf_state"
private const val GRANTED_URIS = "granted_uris"
}
data class WatchNextItem(
@@ -23,7 +23,7 @@ class WatchNextProvider(private val context: Context) {
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val posterSourceUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
@@ -33,132 +33,208 @@ class WatchNextProvider(private val context: Context) {
val episodeNumber: Int?
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
val ops = ArrayList<ContentProviderOperation>()
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
)
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir)
private var currentOwner = ""
private var currentGeneration = 0L
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
/** Materializes transient art, then atomically replaces the durable rows. */
fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List<WatchNextItem>): Boolean {
if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) }
val budget = SystemShelfArtworkStore.Budget()
val prepared = items.map { item ->
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) }
PreparedWatchNextItem(item, materialized?.uri)
}
if (!accepts(ownerId, generation)) {
artwork.deleteExcept(oldFiles)
return false
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
grantReadAccess(newUris)
val committed = replaceRows(prepared)
if (!committed) {
revokeReadAccess(newUris - oldUris)
artwork.deleteExcept(oldFiles)
return false
}
prefs.edit()
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
.commit()
currentOwner = ownerId
currentGeneration = generation
revokeReadAccess(oldUris - newUris)
artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
return true
}
fun clearAll(): Boolean = try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
/** Deletes rows first, then grants, then owned files. */
fun clearAll(ownerId: String, generation: Long): Boolean {
if (!acceptsClear(ownerId, generation)) return false
val rowsCleared = deleteRows()
if (!rowsCleared) return false
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
revokeReadAccess(uris)
artwork.deleteAll()
prefs.edit().remove(GRANTED_URIS).commit()
currentOwner = ""
currentGeneration = generation
return true
}
fun removeItem(contentId: String): Boolean {
/** Package replacement is a clean cutover: remote legacy rows cannot survive. */
fun clearLegacyOnPackageUpdate(): Boolean {
val rowsCleared = deleteRows()
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
revokeReadAccess(uris)
artwork.deleteAll()
prefs.edit().clear().commit()
currentOwner = ""
currentGeneration = 0
return rowsCleared
}
fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
if (!accepts(ownerId, generation)) return false
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex))
context.contentResolver.delete(deleteUri, null, null)
if (posterIndex >= 0) {
val poster = it.getString(posterIndex)?.let(Uri::parse)
if (poster != null) {
revokeReadAccess(setOf(poster))
artwork.resolve(poster)?.delete()
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
}
}
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
} catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item")
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0) {
private fun accepts(ownerId: String, generation: Long): Boolean {
if (ownerId.isBlank() || generation <= 0) return false
return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId
}
private fun acceptsClear(ownerId: String, generation: Long): Boolean {
if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false
return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId
}
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
items.forEach { item ->
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
.withValues(buildProgram(item).toContentValues())
.build()
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs")
false
}
private fun deleteRows(): Boolean = try {
context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries")
false
}
private fun consumerPackages(): Set<String> {
val packages = LinkedHashSet<String>()
context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
?.let(packages::add)
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.mapTo(packages) { it.activityInfo.packageName }
return packages
}
private fun grantReadAccess(uris: Set<Uri>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
consumerPackages().forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
}
}
}
private fun revokeReadAccess(uris: Set<Uri>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
}
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
val metadata = item.metadata
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setType(metadata.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
.setTitle(metadata.title)
.setInternalProviderId(metadata.contentId)
.setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
metadata.description?.let(builder::setDescription)
item.localPosterUri?.let { uri ->
builder.setPosterArtUri(uri)
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
}
if (metadata.duration > 0) {
builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
if (metadata.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
metadata.episodeTitle?.let(builder::setEpisodeTitle)
metadata.seasonNumber?.let(builder::setSeasonNumber)
metadata.episodeNumber?.let(builder::setEpisodeNumber)
}
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
builder.setIntentUri(
Uri.Builder().scheme("plezy").authority("play")
.appendQueryParameter("content_id", metadata.contentId).build()
)
return builder.build()
}
}
@@ -1,15 +1,20 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.os.Looper
import com.edde746.plezy.mpv.MpvPlayerCore
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@@ -40,6 +45,119 @@ class ExoPlayerPluginTest {
assertEquals(mapOf("playerType" to "mpv"), result.successValue)
}
@Test
fun fallbackPropertyHandlersWaitForAcceptedWritesAndReplyOnce() {
for (case in fallbackPropertyCases()) {
val writes = mutableListOf<Pair<String, String>>()
val plugin = fallbackPlugin { name, value -> writes += name to value }
val result = RecordingResult()
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
awaitCompletion(result)
assertEquals(listOf(case.expectedWrite), writes)
assertEquals(case.successValue, result.successValue)
assertEquals(1, result.completionCount)
assertEquals(null, result.errorCode)
}
}
@Test
fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() {
for (case in fallbackPropertyCases()) {
val writes = AtomicInteger()
val plugin = fallbackPlugin { _, _ ->
writes.incrementAndGet()
error("secret-fallback-value")
}
val result = RecordingResult()
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
awaitCompletion(result)
assertEquals(1, writes.get())
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
assertEquals(null, result.successValue)
assertEquals(null, result.errorDetails)
}
}
@Test
fun fallbackCancellationReturnsSetPropertyFailedOnce() {
val plugin = fallbackPlugin { _, _ ->
throw CancellationException("secret-cancellation")
}
val result = RecordingResult()
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "secret")),
result
)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret") == false)
assertEquals(null, result.successValue)
}
@Test
fun fallbackWithoutCoreReturnsNotInitializedOnce() {
val plugin = ExoPlayerPlugin()
setField(plugin, "usingMpvFallback", true)
setField(plugin, "activity", Robolectric.buildActivity(Activity::class.java).setup().get())
val result = RecordingResult()
plugin.onMethodCall(MethodCall("pause", null), result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
}
@Test
fun fallbackWithoutActivityReturnsNotInitializedOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val core = MpvPlayerCore(activity, true) { _, _ -> Unit }
val plugin = ExoPlayerPlugin()
setField(plugin, "usingMpvFallback", true)
setField(plugin, "mpvCore", core)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("play", null), result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
}
@Test
fun genericPropertyBeforeFallbackIsAcceptedIntoLastWriteWinsPendingMap() {
val plugin = ExoPlayerPlugin()
val first = RecordingResult()
val second = RecordingResult()
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "first")),
first
)
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "second")),
second
)
@Suppress("UNCHECKED_CAST")
val pending = getField(plugin, "pendingMpvProperties") as Map<String, String>
assertEquals(mapOf("custom" to "second"), pending)
assertEquals(1, first.completionCount)
assertEquals(1, second.completionCount)
assertEquals(null, first.errorCode)
assertEquals(null, second.errorCode)
}
@Test
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
val plugin = ExoPlayerPlugin()
@@ -58,20 +176,95 @@ class ExoPlayerPluginTest {
)
}
private data class FallbackPropertyCase(
val method: String,
val arguments: Any?,
val expectedWrite: Pair<String, String>,
val successValue: Any? = null
)
private fun fallbackPropertyCases() = listOf(
FallbackPropertyCase("play", null, "pause" to "no"),
FallbackPropertyCase("pause", null, "pause" to "yes"),
FallbackPropertyCase("setVolume", mapOf("volume" to 25), "volume" to "25.0"),
FallbackPropertyCase("setRate", mapOf("rate" to 1.5), "speed" to "1.5"),
FallbackPropertyCase("selectAudioTrack", mapOf("trackId" to "2"), "aid" to "2"),
FallbackPropertyCase("selectSubtitleTrack", emptyMap<String, Any?>(), "sid" to "no"),
FallbackPropertyCase(
"setAudioPassthrough",
mapOf("enabled" to true),
"audio-spdif" to "ac3,eac3,dts,dts-hd,truehd",
true
),
FallbackPropertyCase(
"setMpvProperty",
mapOf("name" to "custom", "value" to "value"),
"custom" to "value"
)
)
private fun fallbackPlugin(
writer: suspend (String, String) -> Unit
): ExoPlayerPlugin {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val core = MpvPlayerCore(activity, true, writer)
return ExoPlayerPlugin().also { plugin ->
setField(plugin, "activity", activity)
setField(plugin, "mpvCore", core)
setField(plugin, "usingMpvFallback", true)
}
}
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
plugin.javaClass.getDeclaredField(name).apply {
isAccessible = true
set(plugin, value)
}
}
private fun getField(plugin: ExoPlayerPlugin, name: String): Any? = plugin.javaClass.getDeclaredField(name).run {
isAccessible = true
get(plugin)
}
private fun awaitCompletion(result: RecordingResult) {
var completed = false
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (result.completed.await(10, TimeUnit.MILLISECONDS)) {
completed = true
return@repeat
}
}
shadowOf(Looper.getMainLooper()).idle()
assertTrue("fallback property result never completed", completed)
assertEquals(1, result.completionCount)
}
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
var errorCode: String? = null
var errorMessage: String? = null
var errorDetails: Any? = null
var completionCount: Int = 0
override fun success(result: Any?) {
completionCount++
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completionCount++
this.errorCode = errorCode
this.errorMessage = errorMessage
this.errorDetails = errorDetails
completed.countDown()
}
override fun notImplemented() {
completionCount++
completed.countDown()
}
}
@@ -1,5 +1,7 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.os.Looper
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
@@ -7,12 +9,19 @@ import dev.jdtech.mpv.MpvEvent
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class MpvPlayerPluginTest {
@@ -30,6 +39,139 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
assertNull(result.successValue)
}
}
@Test
fun acceptedSetPropertyCompletesOnceForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
val writes = AtomicInteger()
installCore(plugin, testCore { _, _ -> writes.incrementAndGet() })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, writes.get())
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertNull(result.successValue)
}
}
@Test
fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
assertNull(result.successValue)
assertNull(result.errorDetails)
}
}
@Test
fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
assertNull(result.successValue)
}
}
@Test
fun coreReportsMissingPlayerDuringWriteAsFailure() {
val core = testCore(null)
var outcome: Result<Unit>? = null
core.setProperty("volume", "50") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isFailure == true)
}
@Test
fun disposeCancelsQueuedPropertyWritesAndCompletesEachCallbackOnce() {
val firstStarted = CountDownLatch(1)
val core = testCore { name, _ ->
if (name == "first") {
suspendCancellableCoroutine<Unit> {
firstStarted.countDown()
}
}
}
val outcomes = mutableListOf<Result<Unit>>()
core.setProperty("first", "value") { outcomes += it }
assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
core.setProperty("second", "value") { outcomes += it }
core.dispose()
awaitCondition { outcomes.size == 2 }
assertEquals(2, outcomes.size)
assertTrue(outcomes.all { it.isFailure })
}
@Test
fun failedPauseLeavesAllPauseBookkeepingUnchanged() {
val core = testVideoCore { _, _ -> error("rejected") }
setBoolean(core, "cachedPaused", false)
setBoolean(core, "pausedForSurfaceLoss", true)
setBoolean(core, "resumeBlockedByPublicPause", false)
setBoolean(core, "deferredResumeRequested", true)
var outcome: Result<Unit>? = null
core.setProperty("pause", "yes") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isFailure == true)
assertEquals(false, getBoolean(core, "cachedPaused"))
assertEquals(true, getBoolean(core, "pausedForSurfaceLoss"))
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
}
@Test
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
val writes = AtomicInteger()
val core = testVideoCore { _, _ -> writes.incrementAndGet() }
setBoolean(core, "resumeBlockedByPublicPause", true)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(0, writes.get())
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test
fun disposeCompletesEveryPendingInitialization() {
val plugin = MpvPlayerPlugin()
@@ -53,9 +195,9 @@ class MpvPlayerPluginTest {
assertEquals(false, first.successValue)
assertEquals(false, second.successValue)
assertNull(dispose.successValue)
assertTrue(first.completed)
assertTrue(second.completed)
assertTrue(dispose.completed)
assertEquals(1, first.completionCount)
assertEquals(1, second.completionCount)
assertEquals(1, dispose.completionCount)
assertEquals(0, pending.size)
}
@@ -125,22 +267,91 @@ class MpvPlayerPluginTest {
)
}
private fun propertyCall() = MethodCall(
"setProperty",
mapOf("name" to "volume", "value" to "50")
)
private fun testCore(
writer: (suspend (String, String) -> Unit)?
): MpvPlayerCore = MpvPlayerCore(
Robolectric.buildActivity(Activity::class.java).setup().get(),
true,
writer
)
private fun testVideoCore(
writer: suspend (String, String) -> Unit
): MpvPlayerCore = MpvPlayerCore(
Robolectric.buildActivity(Activity::class.java).setup().get(),
false,
writer
)
private fun installCore(plugin: MpvPlayerPlugin, core: MpvPlayerCore) {
MpvPlayerPlugin::class.java.getDeclaredField("playerCore").apply {
isAccessible = true
set(plugin, core)
}
}
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
MpvPlayerCore::class.java.getDeclaredField(name).apply {
isAccessible = true
setBoolean(core, value)
}
}
private fun getBoolean(core: MpvPlayerCore, name: String): Boolean = MpvPlayerCore::class.java.getDeclaredField(name).run {
isAccessible = true
getBoolean(core)
}
private fun awaitCompletion(result: RecordingResult) {
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, result.completionCount)
}
private fun awaitCondition(condition: () -> Boolean) {
var completed = false
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (condition()) {
completed = true
return@repeat
}
Thread.sleep(10)
}
assertTrue("asynchronous operation never completed", completed)
}
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
var errorCode: String? = null
var completed: Boolean = false
var errorMessage: String? = null
var errorDetails: Any? = null
var completionCount: Int = 0
override fun success(result: Any?) {
completed = true
completionCount++
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed = true
completionCount++
this.errorCode = errorCode
this.errorMessage = errorMessage
this.errorDetails = errorDetails
completed.countDown()
}
override fun notImplemented() = Unit
override fun notImplemented() {
completionCount++
completed.countDown()
}
}
private class RecordingEventSink : EventChannel.EventSink {
@@ -0,0 +1,198 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat
import java.net.InetAddress
import java.net.ServerSocket
import java.util.Base64
import java.util.concurrent.Executor
import kotlin.concurrent.thread
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.shadows.ShadowContentResolver
@RunWith(RobolectricTestRunner::class)
class WatchNextProviderTest {
private val context get() = RuntimeEnvironment.getApplication()
private lateinit var tvProvider: CapturingTvProvider
private val imageBytes = Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
@Before
fun setUp() {
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
tvProvider = CapturingTvProvider()
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, tvProvider)
}
@After
fun tearDown() {
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
}
@Test
fun syncPersistsOnlyGrantedLocalUriAndProviderReturnsValidatedBytes() {
withServer("image/png", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertEquals(1, tvProvider.inserted.size)
val stored = tvProvider.inserted.single()
val poster = stored.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
assertTrue(poster.startsWith("content://${SystemShelfArtworkProvider.AUTHORITY}/art/"))
assertFalse(poster.contains("http"))
val artworkProvider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java).create().get()
val localBytes = AutoCloseInputStream(artworkProvider.openFile(Uri.parse(poster), "r")).use { it.readBytes() }
assertArrayEquals(imageBytes, localBytes)
}
}
@Test
fun traversalUnknownOversizeAndMalformedArtworkAreRejectedWithoutDroppingMetadata() {
val store = SystemShelfArtworkStore(context.cacheDir)
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/../../private")))
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/${"a".repeat(64)}/${"b".repeat(32)}.art")))
withServer("image/png", ByteArray(SystemShelfArtworkStore.MAX_IMAGE_BYTES + 1)) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
assertEquals("Private title", tvProvider.inserted.single().getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE))
}
tvProvider.inserted.clear()
withServer("image/png", "not an image".toByteArray()) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
tvProvider.inserted.clear()
withServer("text/plain", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
tvProvider.inserted.clear()
withServer("image/png", imageBytes, delayMillis = 3_000) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
}
@Test
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
withServer("image/png", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
assertFalse(provider.syncWatchNextPrograms("owner-old", 2, listOf(item(source))))
assertTrue(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
assertTrue(provider.clearAll("owner-a", 4))
assertTrue(tvProvider.deleteCount >= 2)
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
assertTrue(context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", null).isNullOrEmpty())
}
}
@Test
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(1, tvProvider.deleteCount)
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
}
private fun item(source: String) = WatchNextProvider.WatchNextItem(
contentId = "plezy_server_item",
title = "Private title",
episodeTitle = null,
description = "Private summary",
posterSourceUri = source,
type = TvContractCompat.WatchNextPrograms.TYPE_MOVIE,
duration = 100,
lastPlaybackPosition = 10,
lastEngagementTime = 1,
seriesTitle = null,
seasonNumber = null,
episodeNumber = null
)
private fun withServer(
contentType: String,
body: ByteArray,
delayMillis: Long = 0,
block: (String) -> Unit
) {
val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
val responder = thread(start = true, name = "system-shelf-test-http") {
server.accept().use { socket ->
val reader = socket.getInputStream().bufferedReader()
while (reader.readLine()?.isNotEmpty() == true) {
// Consume the local deterministic request headers.
}
if (delayMillis > 0) Thread.sleep(delayMillis)
val headers = (
"HTTP/1.1 200 OK\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
).toByteArray()
socket.getOutputStream().use { output ->
output.write(headers)
output.write(body)
output.flush()
}
}
}
try {
block("http://127.0.0.1:${server.localPort}/art")
responder.join(5_000)
} finally {
server.close()
}
}
}
private class CapturingTvProvider : ContentProvider() {
val inserted = mutableListOf<ContentValues>()
var deleteCount = 0
override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri {
inserted += ContentValues(values)
return uri.buildUpon().appendPath(inserted.size.toString()).build()
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
deleteCount++
inserted.clear()
return 1
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> = super.applyBatch(operations)
override fun getType(uri: Uri): String? = null
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
+4 -9
View File
@@ -12,7 +12,6 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
private var isDisposed = false
private static var activeDisplayCriteriaKey: String?
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
#if os(tvOS)
@@ -777,11 +776,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
func dispose(preserveDisplayCriteria: Bool = false) {
// Guard double-dispose: the plugin calls dispose() then drops the
// strong ref, which fires deinit dispose() again. The second call
// would re-enter and crash on weak-ref formation during dealloc.
guard !isDisposed else { return }
isDisposed = true
guard beginDisposal() else { return }
#if os(tvOS)
if preserveDisplayCriteria {
@@ -867,7 +862,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterBackground() {
isBackgrounded = true
setBackgrounded(true)
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
@@ -878,7 +873,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterForeground() {
isBackgrounded = false
setBackgrounded(false)
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
@@ -890,7 +885,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#if os(iOS)
@objc private func sceneDidActivate() {
isBackgrounded = false
setBackgrounded(false)
if isPipActive {
return
}
+4 -2
View File
@@ -239,7 +239,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
isManualPipRequest = false
stopPipTimebaseSync()
if pause {
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in
guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
@@ -485,7 +486,8 @@ extension MpvPlayerPlugin: MpvPipDelegate {
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in
guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
+206
View File
@@ -0,0 +1,206 @@
import Flutter
import XCTest
@testable import Runner
final class ControllablePropertyCore: MpvPlayerCoreBase {
var nextResult: Result<Void, Error>?
private(set) var propertyCalls: [(String, String)] = []
private var pendingCompletion: ((Result<Void, Error>) -> Void)?
override func setPropertyAsync(
_ name: String,
value: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
propertyCalls.append((name, value))
if let nextResult {
self.nextResult = nil
completion(nextResult)
} else {
pendingCompletion = completion
}
}
func finish(_ result: Result<Void, Error>) {
let completion = pendingCompletion
pendingCompletion = nil
completion?(result)
}
}
final class RecordingMpvPlugin: MpvPluginShared {
var coreBase: MpvPlayerCoreBase?
var eventSink: FlutterEventSink?
var nameToId: [String: Int] = [:]
private(set) var pauseHookValues: [String] = []
init(core: MpvPlayerCoreBase?) {
coreBase = core
}
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
func updatePlayerFrame() {}
func didSetPauseProperty(value: String) {
pauseHookValues.append(value)
}
}
final class MpvPlayerContractTests: XCTestCase {
private let failure = NSError(
domain: "MpvPlayerContractTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
)
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
let core = ControllablePropertyCore()
let plugin = RecordingMpvPlugin(core: core)
core.nextResult = .success(())
let success = invokeSetProperty(plugin, name: "pause", value: "no")
XCTAssertEqual(success.count, 1)
XCTAssertNil(success[0])
XCTAssertEqual(plugin.pauseHookValues, ["no"])
core.nextResult = .failure(failure)
let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
XCTAssertEqual(rejected.count, 1)
XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
XCTAssertEqual(plugin.pauseHookValues, ["no"])
plugin.coreBase = nil
let missing = invokeSetProperty(plugin, name: "volume", value: "50")
XCTAssertEqual(missing.count, 1)
XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")
var invalidResults: [Any?] = []
plugin.handleSetProperty(
call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
) {
invalidResults.append($0)
}
XCTAssertEqual(invalidResults.count, 1)
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
}
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
defer {
core.dispose()
core.queue.sync {}
}
XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
XCTAssertTrue(core.isPaused)
XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")
XCTAssertFailure(
awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
)
XCTAssertTrue(core.isPaused)
XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
}
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
let queueEntered = expectation(description: "mpv queue blocked")
let releaseQueue = DispatchSemaphore(value: 0)
core.queue.async {
queueEntered.fulfill()
releaseQueue.wait()
}
wait(for: [queueEntered], timeout: 2)
let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true
var completionCount = 0
core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1
if case .success = result {
XCTFail("Disposal must fail an accepted-but-pending property request")
}
completion.fulfill()
}
core.dispose()
releaseQueue.signal()
wait(for: [completion], timeout: 2)
core.queue.sync {}
XCTAssertEqual(completionCount, 1)
XCTAssertFailure(awaitProperty(core, name: "volume", value: "52"))
}
func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
for _ in 0..<5 {
autoreleasepool {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
core.dispose()
core.dispose()
core.queue.sync {}
XCTAssertFalse(core.hasActiveMpv)
}
}
}
private func invokeSetProperty(
_ plugin: RecordingMpvPlugin,
name: String,
value: String
) -> [Any?] {
var results: [Any?] = []
plugin.handleSetProperty(
call: FlutterMethodCall(
methodName: "setProperty",
arguments: ["name": name, "value": value]
)
) {
results.append($0)
}
return results
}
private func awaitProperty(
_ core: MpvPlayerCoreBase,
name: String,
value: String
) -> Result<Void, Error> {
let completion = expectation(description: "set \(name)")
var propertyResult: Result<Void, Error>?
core.setPropertyAsync(name, value: value) {
propertyResult = $0
completion.fulfill()
}
wait(for: [completion], timeout: 2)
return propertyResult ?? .failure(failure)
}
private func XCTAssertSuccess(
_ result: Result<Void, Error>,
file: StaticString = #filePath,
line: UInt = #line
) {
if case .failure(let error) = result {
XCTFail("Expected success, received \(error)", file: file, line: line)
}
}
private func XCTAssertFailure(
_ result: Result<Void, Error>,
file: StaticString = #filePath,
line: UInt = #line
) {
if case .success = result {
XCTFail("Expected failure", file: file, line: line)
}
}
}
+52 -42
View File
@@ -45,55 +45,61 @@ class ConnectionRegistry {
/// the row's current `isDefault` (so token/metadata refreshes don't clear
/// the default flag).
Future<void> upsert(Connection connection) async {
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
final bool isDefault;
if (existing != null) {
isDefault = existing.isDefault;
} else {
final any =
await (_db.selectOnly(_db.connections)
..addColumns([_db.connections.id])
..limit(1))
.getSingleOrNull();
isDefault = any == null;
}
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
connection.toConfigJson(),
);
final row = ConnectionsCompanion(
id: Value(connection.id),
kind: Value(connection.kind.id),
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.connections).insertOnConflictUpdate(row);
await _db.runIdentityMutation(() async {
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
final bool isDefault;
if (existing != null) {
isDefault = existing.isDefault;
} else {
final any =
await (_db.selectOnly(_db.connections)
..addColumns([_db.connections.id])
..limit(1))
.getSingleOrNull();
isDefault = any == null;
}
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
connection.toConfigJson(),
);
final row = ConnectionsCompanion(
id: Value(connection.id),
kind: Value(connection.kind.id),
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.connections).insertOnConflictUpdate(row);
});
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
}
/// Remove a stored connection. If the removed row was the default, the
/// oldest remaining connection (if any) becomes default.
Future<void> remove(String id) async {
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
}
await _db.runIdentityMutation(() async {
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
}
});
appLogger.d('ConnectionRegistry: removed $id');
}
/// Set [id] as the default connection. Clears the flag on all others.
Future<void> setDefault(String id) async {
await _db.transaction(() async {
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
await _db.runIdentityMutation(() async {
await _db.transaction(() async {
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
});
});
}
@@ -101,13 +107,17 @@ class ConnectionRegistry {
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
/// silent refresh without touching the rest of the config.
Future<void> recordAuthSuccess(String id, DateTime at) async {
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
);
await _db.runIdentityMutation(() async {
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
);
});
}
Future<void> clear() async {
await _db.delete(_db.connections).go();
await _db.runIdentityMutation(() async {
await _db.delete(_db.connections).go();
});
}
/// All Plex accounts in insertion order. Convenience over
File diff suppressed because it is too large Load Diff
+227 -4
View File
@@ -153,6 +153,17 @@ class $DownloadedMediaTable extends DownloadedMedia
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _safRootUriMeta = const VerificationMeta(
'safRootUri',
);
@override
late final GeneratedColumn<String> safRootUri = GeneratedColumn<String>(
'saf_root_uri',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _thumbPathMeta = const VerificationMeta(
'thumbPath',
);
@@ -247,6 +258,7 @@ class $DownloadedMediaTable extends DownloadedMedia
totalBytes,
downloadedBytes,
videoFilePath,
safRootUri,
thumbPath,
downloadedAt,
errorMessage,
@@ -367,6 +379,15 @@ class $DownloadedMediaTable extends DownloadedMedia
),
);
}
if (data.containsKey('saf_root_uri')) {
context.handle(
_safRootUriMeta,
safRootUri.isAcceptableOrUnknown(
data['saf_root_uri']!,
_safRootUriMeta,
),
);
}
if (data.containsKey('thumb_path')) {
context.handle(
_thumbPathMeta,
@@ -479,6 +500,10 @@ class $DownloadedMediaTable extends DownloadedMedia
DriftSqlType.string,
data['${effectivePrefix}video_file_path'],
),
safRootUri: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}saf_root_uri'],
),
thumbPath: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}thumb_path'],
@@ -531,6 +556,7 @@ class DownloadedMediaItem extends DataClass
final int? totalBytes;
final int downloadedBytes;
final String? videoFilePath;
final String? safRootUri;
final String? thumbPath;
final int? downloadedAt;
final String? errorMessage;
@@ -552,6 +578,7 @@ class DownloadedMediaItem extends DataClass
this.totalBytes,
required this.downloadedBytes,
this.videoFilePath,
this.safRootUri,
this.thumbPath,
this.downloadedAt,
this.errorMessage,
@@ -586,6 +613,9 @@ class DownloadedMediaItem extends DataClass
if (!nullToAbsent || videoFilePath != null) {
map['video_file_path'] = Variable<String>(videoFilePath);
}
if (!nullToAbsent || safRootUri != null) {
map['saf_root_uri'] = Variable<String>(safRootUri);
}
if (!nullToAbsent || thumbPath != null) {
map['thumb_path'] = Variable<String>(thumbPath);
}
@@ -631,6 +661,9 @@ class DownloadedMediaItem extends DataClass
videoFilePath: videoFilePath == null && nullToAbsent
? const Value.absent()
: Value(videoFilePath),
safRootUri: safRootUri == null && nullToAbsent
? const Value.absent()
: Value(safRootUri),
thumbPath: thumbPath == null && nullToAbsent
? const Value.absent()
: Value(thumbPath),
@@ -672,6 +705,7 @@ class DownloadedMediaItem extends DataClass
totalBytes: serializer.fromJson<int?>(json['totalBytes']),
downloadedBytes: serializer.fromJson<int>(json['downloadedBytes']),
videoFilePath: serializer.fromJson<String?>(json['videoFilePath']),
safRootUri: serializer.fromJson<String?>(json['safRootUri']),
thumbPath: serializer.fromJson<String?>(json['thumbPath']),
downloadedAt: serializer.fromJson<int?>(json['downloadedAt']),
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
@@ -698,6 +732,7 @@ class DownloadedMediaItem extends DataClass
'totalBytes': serializer.toJson<int?>(totalBytes),
'downloadedBytes': serializer.toJson<int>(downloadedBytes),
'videoFilePath': serializer.toJson<String?>(videoFilePath),
'safRootUri': serializer.toJson<String?>(safRootUri),
'thumbPath': serializer.toJson<String?>(thumbPath),
'downloadedAt': serializer.toJson<int?>(downloadedAt),
'errorMessage': serializer.toJson<String?>(errorMessage),
@@ -722,6 +757,7 @@ class DownloadedMediaItem extends DataClass
Value<int?> totalBytes = const Value.absent(),
int? downloadedBytes,
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -751,6 +787,7 @@ class DownloadedMediaItem extends DataClass
videoFilePath: videoFilePath.present
? videoFilePath.value
: this.videoFilePath,
safRootUri: safRootUri.present ? safRootUri.value : this.safRootUri,
thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath,
downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt,
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
@@ -788,6 +825,9 @@ class DownloadedMediaItem extends DataClass
videoFilePath: data.videoFilePath.present
? data.videoFilePath.value
: this.videoFilePath,
safRootUri: data.safRootUri.present
? data.safRootUri.value
: this.safRootUri,
thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath,
downloadedAt: data.downloadedAt.present
? data.downloadedAt.value
@@ -824,6 +864,7 @@ class DownloadedMediaItem extends DataClass
..write('totalBytes: $totalBytes, ')
..write('downloadedBytes: $downloadedBytes, ')
..write('videoFilePath: $videoFilePath, ')
..write('safRootUri: $safRootUri, ')
..write('thumbPath: $thumbPath, ')
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
@@ -836,7 +877,7 @@ class DownloadedMediaItem extends DataClass
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
id,
serverId,
clientScopeId,
@@ -850,6 +891,7 @@ class DownloadedMediaItem extends DataClass
totalBytes,
downloadedBytes,
videoFilePath,
safRootUri,
thumbPath,
downloadedAt,
errorMessage,
@@ -857,7 +899,7 @@ class DownloadedMediaItem extends DataClass
bgTaskId,
mediaIndex,
mediaSourceId,
);
]);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -875,6 +917,7 @@ class DownloadedMediaItem extends DataClass
other.totalBytes == this.totalBytes &&
other.downloadedBytes == this.downloadedBytes &&
other.videoFilePath == this.videoFilePath &&
other.safRootUri == this.safRootUri &&
other.thumbPath == this.thumbPath &&
other.downloadedAt == this.downloadedAt &&
other.errorMessage == this.errorMessage &&
@@ -898,6 +941,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
final Value<int?> totalBytes;
final Value<int> downloadedBytes;
final Value<String?> videoFilePath;
final Value<String?> safRootUri;
final Value<String?> thumbPath;
final Value<int?> downloadedAt;
final Value<String?> errorMessage;
@@ -919,6 +963,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.totalBytes = const Value.absent(),
this.downloadedBytes = const Value.absent(),
this.videoFilePath = const Value.absent(),
this.safRootUri = const Value.absent(),
this.thumbPath = const Value.absent(),
this.downloadedAt = const Value.absent(),
this.errorMessage = const Value.absent(),
@@ -941,6 +986,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.totalBytes = const Value.absent(),
this.downloadedBytes = const Value.absent(),
this.videoFilePath = const Value.absent(),
this.safRootUri = const Value.absent(),
this.thumbPath = const Value.absent(),
this.downloadedAt = const Value.absent(),
this.errorMessage = const Value.absent(),
@@ -967,6 +1013,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Expression<int>? totalBytes,
Expression<int>? downloadedBytes,
Expression<String>? videoFilePath,
Expression<String>? safRootUri,
Expression<String>? thumbPath,
Expression<int>? downloadedAt,
Expression<String>? errorMessage,
@@ -990,6 +1037,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (totalBytes != null) 'total_bytes': totalBytes,
if (downloadedBytes != null) 'downloaded_bytes': downloadedBytes,
if (videoFilePath != null) 'video_file_path': videoFilePath,
if (safRootUri != null) 'saf_root_uri': safRootUri,
if (thumbPath != null) 'thumb_path': thumbPath,
if (downloadedAt != null) 'downloaded_at': downloadedAt,
if (errorMessage != null) 'error_message': errorMessage,
@@ -1014,6 +1062,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Value<int?>? totalBytes,
Value<int>? downloadedBytes,
Value<String?>? videoFilePath,
Value<String?>? safRootUri,
Value<String?>? thumbPath,
Value<int?>? downloadedAt,
Value<String?>? errorMessage,
@@ -1036,6 +1085,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
totalBytes: totalBytes ?? this.totalBytes,
downloadedBytes: downloadedBytes ?? this.downloadedBytes,
videoFilePath: videoFilePath ?? this.videoFilePath,
safRootUri: safRootUri ?? this.safRootUri,
thumbPath: thumbPath ?? this.thumbPath,
downloadedAt: downloadedAt ?? this.downloadedAt,
errorMessage: errorMessage ?? this.errorMessage,
@@ -1090,6 +1140,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (videoFilePath.present) {
map['video_file_path'] = Variable<String>(videoFilePath.value);
}
if (safRootUri.present) {
map['saf_root_uri'] = Variable<String>(safRootUri.value);
}
if (thumbPath.present) {
map['thumb_path'] = Variable<String>(thumbPath.value);
}
@@ -1130,6 +1183,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
..write('totalBytes: $totalBytes, ')
..write('downloadedBytes: $downloadedBytes, ')
..write('videoFilePath: $videoFilePath, ')
..write('safRootUri: $safRootUri, ')
..write('thumbPath: $thumbPath, ')
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
@@ -1170,6 +1224,28 @@ class $DownloadOwnersTable extends DownloadOwners
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _backendMeta = const VerificationMeta(
'backend',
);
@override
late final GeneratedColumn<String> backend = GeneratedColumn<String>(
'backend',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _clientScopeIdMeta = const VerificationMeta(
'clientScopeId',
);
@override
late final GeneratedColumn<String> clientScopeId = GeneratedColumn<String>(
'client_scope_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@@ -1182,7 +1258,13 @@ class $DownloadOwnersTable extends DownloadOwners
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [profileId, globalKey, createdAt];
List<GeneratedColumn> get $columns => [
profileId,
globalKey,
backend,
clientScopeId,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@@ -1211,6 +1293,21 @@ class $DownloadOwnersTable extends DownloadOwners
} else if (isInserting) {
context.missing(_globalKeyMeta);
}
if (data.containsKey('backend')) {
context.handle(
_backendMeta,
backend.isAcceptableOrUnknown(data['backend']!, _backendMeta),
);
}
if (data.containsKey('client_scope_id')) {
context.handle(
_clientScopeIdMeta,
clientScopeId.isAcceptableOrUnknown(
data['client_scope_id']!,
_clientScopeIdMeta,
),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@@ -1236,6 +1333,14 @@ class $DownloadOwnersTable extends DownloadOwners
DriftSqlType.string,
data['${effectivePrefix}global_key'],
)!,
backend: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}backend'],
),
clientScopeId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}client_scope_id'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
@@ -1253,10 +1358,14 @@ class DownloadOwnerItem extends DataClass
implements Insertable<DownloadOwnerItem> {
final String profileId;
final String globalKey;
final String? backend;
final String? clientScopeId;
final int createdAt;
const DownloadOwnerItem({
required this.profileId,
required this.globalKey,
this.backend,
this.clientScopeId,
required this.createdAt,
});
@override
@@ -1264,6 +1373,12 @@ class DownloadOwnerItem extends DataClass
final map = <String, Expression>{};
map['profile_id'] = Variable<String>(profileId);
map['global_key'] = Variable<String>(globalKey);
if (!nullToAbsent || backend != null) {
map['backend'] = Variable<String>(backend);
}
if (!nullToAbsent || clientScopeId != null) {
map['client_scope_id'] = Variable<String>(clientScopeId);
}
map['created_at'] = Variable<int>(createdAt);
return map;
}
@@ -1272,6 +1387,12 @@ class DownloadOwnerItem extends DataClass
return DownloadOwnersCompanion(
profileId: Value(profileId),
globalKey: Value(globalKey),
backend: backend == null && nullToAbsent
? const Value.absent()
: Value(backend),
clientScopeId: clientScopeId == null && nullToAbsent
? const Value.absent()
: Value(clientScopeId),
createdAt: Value(createdAt),
);
}
@@ -1284,6 +1405,8 @@ class DownloadOwnerItem extends DataClass
return DownloadOwnerItem(
profileId: serializer.fromJson<String>(json['profileId']),
globalKey: serializer.fromJson<String>(json['globalKey']),
backend: serializer.fromJson<String?>(json['backend']),
clientScopeId: serializer.fromJson<String?>(json['clientScopeId']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@@ -1293,6 +1416,8 @@ class DownloadOwnerItem extends DataClass
return <String, dynamic>{
'profileId': serializer.toJson<String>(profileId),
'globalKey': serializer.toJson<String>(globalKey),
'backend': serializer.toJson<String?>(backend),
'clientScopeId': serializer.toJson<String?>(clientScopeId),
'createdAt': serializer.toJson<int>(createdAt),
};
}
@@ -1300,16 +1425,26 @@ class DownloadOwnerItem extends DataClass
DownloadOwnerItem copyWith({
String? profileId,
String? globalKey,
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
int? createdAt,
}) => DownloadOwnerItem(
profileId: profileId ?? this.profileId,
globalKey: globalKey ?? this.globalKey,
backend: backend.present ? backend.value : this.backend,
clientScopeId: clientScopeId.present
? clientScopeId.value
: this.clientScopeId,
createdAt: createdAt ?? this.createdAt,
);
DownloadOwnerItem copyWithCompanion(DownloadOwnersCompanion data) {
return DownloadOwnerItem(
profileId: data.profileId.present ? data.profileId.value : this.profileId,
globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey,
backend: data.backend.present ? data.backend.value : this.backend,
clientScopeId: data.clientScopeId.present
? data.clientScopeId.value
: this.clientScopeId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@@ -1319,36 +1454,47 @@ class DownloadOwnerItem extends DataClass
return (StringBuffer('DownloadOwnerItem(')
..write('profileId: $profileId, ')
..write('globalKey: $globalKey, ')
..write('backend: $backend, ')
..write('clientScopeId: $clientScopeId, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(profileId, globalKey, createdAt);
int get hashCode =>
Object.hash(profileId, globalKey, backend, clientScopeId, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is DownloadOwnerItem &&
other.profileId == this.profileId &&
other.globalKey == this.globalKey &&
other.backend == this.backend &&
other.clientScopeId == this.clientScopeId &&
other.createdAt == this.createdAt);
}
class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
final Value<String> profileId;
final Value<String> globalKey;
final Value<String?> backend;
final Value<String?> clientScopeId;
final Value<int> createdAt;
final Value<int> rowid;
const DownloadOwnersCompanion({
this.profileId = const Value.absent(),
this.globalKey = const Value.absent(),
this.backend = const Value.absent(),
this.clientScopeId = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
DownloadOwnersCompanion.insert({
required String profileId,
required String globalKey,
this.backend = const Value.absent(),
this.clientScopeId = const Value.absent(),
required int createdAt,
this.rowid = const Value.absent(),
}) : profileId = Value(profileId),
@@ -1357,12 +1503,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
static Insertable<DownloadOwnerItem> custom({
Expression<String>? profileId,
Expression<String>? globalKey,
Expression<String>? backend,
Expression<String>? clientScopeId,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (profileId != null) 'profile_id': profileId,
if (globalKey != null) 'global_key': globalKey,
if (backend != null) 'backend': backend,
if (clientScopeId != null) 'client_scope_id': clientScopeId,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
@@ -1371,12 +1521,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
DownloadOwnersCompanion copyWith({
Value<String>? profileId,
Value<String>? globalKey,
Value<String?>? backend,
Value<String?>? clientScopeId,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return DownloadOwnersCompanion(
profileId: profileId ?? this.profileId,
globalKey: globalKey ?? this.globalKey,
backend: backend ?? this.backend,
clientScopeId: clientScopeId ?? this.clientScopeId,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
@@ -1391,6 +1545,12 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
if (globalKey.present) {
map['global_key'] = Variable<String>(globalKey.value);
}
if (backend.present) {
map['backend'] = Variable<String>(backend.value);
}
if (clientScopeId.present) {
map['client_scope_id'] = Variable<String>(clientScopeId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
@@ -1405,6 +1565,8 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
return (StringBuffer('DownloadOwnersCompanion(')
..write('profileId: $profileId, ')
..write('globalKey: $globalKey, ')
..write('backend: $backend, ')
..write('clientScopeId: $clientScopeId, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
@@ -5423,6 +5585,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
Value<int?> totalBytes,
Value<int> downloadedBytes,
Value<String?> videoFilePath,
Value<String?> safRootUri,
Value<String?> thumbPath,
Value<int?> downloadedAt,
Value<String?> errorMessage,
@@ -5446,6 +5609,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
Value<int?> totalBytes,
Value<int> downloadedBytes,
Value<String?> videoFilePath,
Value<String?> safRootUri,
Value<String?> thumbPath,
Value<int?> downloadedAt,
Value<String?> errorMessage,
@@ -5529,6 +5693,11 @@ class $$DownloadedMediaTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get thumbPath => $composableBuilder(
column: $table.thumbPath,
builder: (column) => ColumnFilters(column),
@@ -5639,6 +5808,11 @@ class $$DownloadedMediaTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get thumbPath => $composableBuilder(
column: $table.thumbPath,
builder: (column) => ColumnOrderings(column),
@@ -5735,6 +5909,11 @@ class $$DownloadedMediaTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => column,
);
GeneratedColumn<String> get thumbPath =>
$composableBuilder(column: $table.thumbPath, builder: (column) => column);
@@ -5817,6 +5996,7 @@ class $$DownloadedMediaTableTableManager
Value<int?> totalBytes = const Value.absent(),
Value<int> downloadedBytes = const Value.absent(),
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -5838,6 +6018,7 @@ class $$DownloadedMediaTableTableManager
totalBytes: totalBytes,
downloadedBytes: downloadedBytes,
videoFilePath: videoFilePath,
safRootUri: safRootUri,
thumbPath: thumbPath,
downloadedAt: downloadedAt,
errorMessage: errorMessage,
@@ -5861,6 +6042,7 @@ class $$DownloadedMediaTableTableManager
Value<int?> totalBytes = const Value.absent(),
Value<int> downloadedBytes = const Value.absent(),
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -5882,6 +6064,7 @@ class $$DownloadedMediaTableTableManager
totalBytes: totalBytes,
downloadedBytes: downloadedBytes,
videoFilePath: videoFilePath,
safRootUri: safRootUri,
thumbPath: thumbPath,
downloadedAt: downloadedAt,
errorMessage: errorMessage,
@@ -5923,6 +6106,8 @@ typedef $$DownloadOwnersTableCreateCompanionBuilder =
DownloadOwnersCompanion Function({
required String profileId,
required String globalKey,
Value<String?> backend,
Value<String?> clientScopeId,
required int createdAt,
Value<int> rowid,
});
@@ -5930,6 +6115,8 @@ typedef $$DownloadOwnersTableUpdateCompanionBuilder =
DownloadOwnersCompanion Function({
Value<String> profileId,
Value<String> globalKey,
Value<String?> backend,
Value<String?> clientScopeId,
Value<int> createdAt,
Value<int> rowid,
});
@@ -5953,6 +6140,16 @@ class $$DownloadOwnersTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get backend => $composableBuilder(
column: $table.backend,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
@@ -5978,6 +6175,16 @@ class $$DownloadOwnersTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get backend => $composableBuilder(
column: $table.backend,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
@@ -5999,6 +6206,14 @@ class $$DownloadOwnersTableAnnotationComposer
GeneratedColumn<String> get globalKey =>
$composableBuilder(column: $table.globalKey, builder: (column) => column);
GeneratedColumn<String> get backend =>
$composableBuilder(column: $table.backend, builder: (column) => column);
GeneratedColumn<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => column,
);
GeneratedColumn<int> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
@@ -6042,11 +6257,15 @@ class $$DownloadOwnersTableTableManager
({
Value<String> profileId = const Value.absent(),
Value<String> globalKey = const Value.absent(),
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
Value<int> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => DownloadOwnersCompanion(
profileId: profileId,
globalKey: globalKey,
backend: backend,
clientScopeId: clientScopeId,
createdAt: createdAt,
rowid: rowid,
),
@@ -6054,11 +6273,15 @@ class $$DownloadOwnersTableTableManager
({
required String profileId,
required String globalKey,
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
required int createdAt,
Value<int> rowid = const Value.absent(),
}) => DownloadOwnersCompanion.insert(
profileId: profileId,
globalKey: globalKey,
backend: backend,
clientScopeId: clientScopeId,
createdAt: createdAt,
rowid: rowid,
),
+435 -29
View File
@@ -1,20 +1,53 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../media/ids.dart';
import 'app_database.dart';
import '../models/download_models.dart';
import '../profiles/profile.dart';
import '../utils/active_client_scope.dart';
enum QueueDownloadOutcome {
/// A missing or retryable row was durably admitted to the queue.
admitted,
/// The row was already queued; only its queue policy was refreshed.
alreadyQueued,
/// The existing row is active, paused, or complete and was left unchanged.
unchanged,
}
extension DownloadDatabaseOperations on AppDatabase {
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
Future<void> addDownloadOwner({
required String profileId,
required String globalKey,
String? backendId,
String? clientScopeId,
}) async {
if (profileId.isEmpty) return;
await into(downloadOwners).insert(
DownloadOwnersCompanion.insert(
profileId: profileId,
globalKey: globalKey,
createdAt: DateTime.now().millisecondsSinceEpoch,
),
mode: InsertMode.insertOrIgnore,
await customUpdate(
'''
INSERT INTO download_owners (
profile_id,
global_key,
backend,
client_scope_id,
created_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(profile_id, global_key) DO UPDATE SET
backend = COALESCE(excluded.backend, download_owners.backend),
client_scope_id = COALESCE(excluded.client_scope_id, download_owners.client_scope_id)
''',
variables: [
Variable<String>(profileId),
Variable<String>(globalKey),
Variable<String>(backendId),
Variable<String>(clientScopeId),
Variable<int>(DateTime.now().millisecondsSinceEpoch),
],
updates: {downloadOwners},
);
}
@@ -22,6 +55,53 @@ extension DownloadDatabaseOperations on AppDatabase {
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
}
/// Removes one owner from a shared download while keeping an incomplete
/// physical row usable by a remaining owner.
///
/// When there is no remaining valid owner, nothing is removed so callers
/// can delete the physical download before releasing its final durable
/// owner. Selection, scope rebinding, and owner removal share a transaction.
Future<({DownloadOwnerItem? removedOwner, bool hasRemainingOwner})>
removeSharedDownloadOwnerAndRebindIncompleteMedia({required String profileId, required String globalKey}) {
return transaction(() async {
final departingOwner = await getDownloadOwner(profileId: profileId, globalKey: globalKey);
final remainingOwners = (await _validDownloadOwnerRows(globalKey, excludingProfileId: profileId)).toList()
..sort((a, b) {
final aHasScope = a.clientScopeId?.isNotEmpty ?? false;
final bHasScope = b.clientScopeId?.isNotEmpty ?? false;
if (aHasScope != bHasScope) return aHasScope ? -1 : 1;
final createdAtComparison = a.createdAt.compareTo(b.createdAt);
return createdAtComparison != 0 ? createdAtComparison : a.profileId.compareTo(b.profileId);
});
if (remainingOwners.isEmpty) {
return (removedOwner: null, hasRemainingOwner: false);
}
if (departingOwner != null) {
final media = await getDownloadedMedia(globalKey);
final departingScope = departingOwner.clientScopeId;
if (media != null &&
media.status != DownloadStatus.completed.index &&
departingScope != null &&
departingScope.isNotEmpty &&
media.clientScopeId == departingScope) {
final replacementScope = remainingOwners.first.clientScopeId;
if (replacementScope != media.clientScopeId) {
await updateDownloadedMediaClientScope(globalKey, replacementScope);
}
}
await removeDownloadOwner(profileId: profileId, globalKey: globalKey);
}
return (removedOwner: departingOwner, hasRemainingOwner: true);
});
}
Future<DownloadOwnerItem?> getDownloadOwner({required String profileId, required String globalKey}) {
return (select(
downloadOwners,
)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> clearAllDownloadOwners() async {
await delete(downloadOwners).go();
}
@@ -32,6 +112,28 @@ extension DownloadDatabaseOperations on AppDatabase {
return rows.map((row) => row.globalKey).toSet();
}
Future<List<DownloadOwnerItem>> getDownloadOwnersForProfile(String profileId) {
if (profileId.isEmpty) return Future.value(const []);
return (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
}
Future<void> updateDownloadOwnerScope({
required String profileId,
required String globalKey,
required String backendId,
required String clientScopeId,
}) {
return (update(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).write(
DownloadOwnersCompanion(backend: Value(backendId), clientScopeId: Value(clientScopeId)),
);
}
Future<void> updateDownloadedMediaClientScope(String globalKey, String? clientScopeId) {
return (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(clientScopeId: Value(clientScopeId)),
);
}
Future<int> getDownloadOwnerCount(String globalKey) async {
return (await _validDownloadOwnerRows(globalKey)).length;
}
@@ -41,6 +143,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return rows.isNotEmpty;
}
Future<List<DownloadOwnerItem>> getValidDownloadOwnersForKey(String globalKey) {
return _validDownloadOwnerRows(globalKey);
}
Future<bool> hasDownloadOwnerForCacheScope(
String globalKey, {
required String backendId,
required String clientScopeId,
}) async {
final owners = await _validDownloadOwnerRows(globalKey);
return owners.any((owner) => owner.backend == backendId && owner.clientScopeId == clientScopeId);
}
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
if (rows.isEmpty) return const [];
@@ -65,14 +180,34 @@ extension DownloadDatabaseOperations on AppDatabase {
/// Runs on every profile switch — validity context is computed once and
/// applied in memory instead of the per-download full-table rescan
/// `getDownloadOwnerCount` would do.
Future<void> adoptLegacyDownloadsForProfile(String profileId) async {
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
if (profileId.isEmpty) return;
if (isStillActive != null && !isStillActive()) return;
final rows = await select(downloadedMedia).get();
if (rows.isEmpty) return;
final owners = await select(downloadOwners).get();
final localProfileIds = (await select(profiles).get()).map((row) => row.id).toSet();
final connectionIds = (await select(connections).get()).map((row) => row.id).toSet();
final connectionRows = await select(connections).get();
final connectionIds = connectionRows.map((row) => row.id).toSet();
final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
final jellyfinMachineIds = <String>{};
for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
final identity = _jellyfinConnectionIdentity(connection);
jellyfinIdentities[connection.id] = identity;
jellyfinMachineIds.add(identity.machineId);
}
final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
for (final binding in await select(profileConnections).get()) {
if (binding.userIdentifier.isEmpty) continue;
final identity = jellyfinIdentities[binding.connectionId];
if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
jellyfinScopesByProfileAndMachine
.putIfAbsent(binding.profileId, () => <String, Set<String>>{})
.putIfAbsent(identity.machineId, () => <String>{})
.add('${identity.machineId}/${binding.userIdentifier}');
}
final ownedKeys = <String>{
for (final owner in owners)
if (_isValidDownloadOwner(owner, localProfileIds: localProfileIds, connectionIds: connectionIds))
@@ -80,7 +215,56 @@ extension DownloadDatabaseOperations on AppDatabase {
};
for (final row in rows) {
if (!ownedKeys.contains(row.globalKey)) {
await addDownloadOwner(profileId: profileId, globalKey: row.globalKey);
if (isStillActive != null && !isStillActive()) return;
final scopeId = row.clientScopeId;
final plexScope = PlexProfileScopeId.tryParse(scopeId ?? '');
final transferScope = PlexTransferScopeId.tryParse(scopeId ?? '');
// A scoped Plex row already identifies the Plezy profile whose token
// and cache namespace produced it. Logout first moves preserved rows
// through a sanitized transfer namespace so a new profile can adopt
// the physical file without inheriting the old profile's watch state.
if (plexScope != null && plexScope.profileId != profileId) continue;
if (plexScope != null || transferScope != null) {
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'plex',
clientScopeId: scopeId,
);
continue;
}
final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
if (jellyfinScopes.length == 1) {
final adoptingScope = jellyfinScopes.single;
await transaction(() async {
if (isStillActive != null && !isStillActive()) return;
await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'jellyfin',
clientScopeId: adoptingScope,
);
});
continue;
}
// A compound non-Plex scope is a legacy Jellyfin user namespace.
// Never attach it to another profile unless that profile has exactly
// one matching Jellyfin binding. The same applies when persisted
// Jellyfin connections identify the machine but the profile has zero
// or multiple possible users.
final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;
final backendId = connectionKindsById[scopeId];
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: backendId,
clientScopeId: scopeId,
);
}
}
}
@@ -97,20 +281,49 @@ extension DownloadDatabaseOperations on AppDatabase {
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
type: type,
parentRatingKey: Value(parentRatingKey),
grandparentRatingKey: Value(grandparentRatingKey),
status: status,
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
mode: InsertMode.insertOrReplace,
await customUpdate(
'''
INSERT INTO downloaded_media (
server_id,
client_scope_id,
rating_key,
global_key,
type,
parent_rating_key,
grandparent_rating_key,
status,
media_index,
media_source_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(global_key) DO UPDATE SET
server_id = excluded.server_id,
client_scope_id = excluded.client_scope_id,
rating_key = excluded.rating_key,
type = excluded.type,
parent_rating_key = excluded.parent_rating_key,
grandparent_rating_key = excluded.grandparent_rating_key,
status = excluded.status,
progress = 0,
total_bytes = NULL,
downloaded_bytes = 0,
error_message = NULL,
retry_count = 0,
media_index = excluded.media_index,
media_source_id = excluded.media_source_id
''',
variables: [
Variable<String>(serverId),
Variable<String>(clientScopeId),
Variable<String>(ratingKey),
Variable<String>(globalKey),
Variable<String>(type),
Variable<String>(parentRatingKey),
Variable<String>(grandparentRatingKey),
Variable<int>(status),
Variable<int>(mediaIndex),
Variable<String>(mediaSourceId),
],
updates: {downloadedMedia},
);
}
@@ -132,6 +345,133 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<void> updateSupplementaryQueueIntent(
String mediaGlobalKey, {
required bool downloadSubtitles,
required bool downloadArtwork,
}) async {
await (update(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).write(
DownloadQueueCompanion(downloadSubtitles: Value(downloadSubtitles), downloadArtwork: Value(downloadArtwork)),
);
}
/// Atomically admits a durable media row and its executable queue item.
///
/// Existing active, paused, and completed media rows are never rewritten.
/// Failed, cancelled, and partial attempts keep their stable row identity
/// and physical-file fields while their request and attempt state is refreshed.
Future<QueueDownloadOutcome> insertQueuedDownload({
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String globalKey,
required String type,
String? parentRatingKey,
String? grandparentRatingKey,
int mediaIndex = 0,
String? mediaSourceId,
int priority = 0,
bool downloadSubtitles = true,
bool downloadArtwork = true,
}) {
return transaction(() async {
final admitted = await customUpdate(
'''
INSERT INTO downloaded_media (
server_id,
client_scope_id,
rating_key,
global_key,
type,
parent_rating_key,
grandparent_rating_key,
status,
media_index,
media_source_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(global_key) DO UPDATE SET
server_id = excluded.server_id,
client_scope_id = excluded.client_scope_id,
rating_key = excluded.rating_key,
type = excluded.type,
parent_rating_key = excluded.parent_rating_key,
grandparent_rating_key = excluded.grandparent_rating_key,
status = excluded.status,
progress = 0,
total_bytes = NULL,
downloaded_bytes = 0,
error_message = NULL,
retry_count = 0,
bg_task_id = NULL,
media_index = excluded.media_index,
media_source_id = excluded.media_source_id
WHERE downloaded_media.status IN (?, ?, ?)
''',
variables: [
Variable<String>(serverId),
Variable<String>(clientScopeId),
Variable<String>(ratingKey),
Variable<String>(globalKey),
Variable<String>(type),
Variable<String>(parentRatingKey),
Variable<String>(grandparentRatingKey),
Variable<int>(DownloadStatus.queued.index),
Variable<int>(mediaIndex),
Variable<String>(mediaSourceId),
Variable<int>(DownloadStatus.failed.index),
Variable<int>(DownloadStatus.cancelled.index),
Variable<int>(DownloadStatus.partial.index),
],
updates: {downloadedMedia},
);
if (admitted > 0) {
await addToQueue(
mediaGlobalKey: globalKey,
priority: priority,
downloadSubtitles: downloadSubtitles,
downloadArtwork: downloadArtwork,
);
return QueueDownloadOutcome.admitted;
}
final current = await getDownloadedMedia(globalKey);
if (current?.status == DownloadStatus.queued.index) {
await addToQueue(
mediaGlobalKey: globalKey,
priority: priority,
downloadSubtitles: downloadSubtitles,
downloadArtwork: downloadArtwork,
);
return QueueDownloadOutcome.alreadyQueued;
}
return QueueDownloadOutcome.unchanged;
});
}
/// Restores queue items omitted by legacy non-atomic queue creation.
///
/// Existing queue rows are never rewritten because they retain the original
/// priority and supplementary-download policy.
Future<int> repairMissingQueuedDownloadEntries() async {
return transaction(() async {
final queuedMedia = await (select(
downloadedMedia,
)..where((t) => t.status.equals(DownloadStatus.queued.index))).get();
if (queuedMedia.isEmpty) return 0;
final existingKeys = (await select(downloadQueue).get()).map((row) => row.mediaGlobalKey).toSet();
var repaired = 0;
for (final media in queuedMedia) {
if (existingKeys.contains(media.globalKey)) continue;
await addToQueue(mediaGlobalKey: media.globalKey);
existingKeys.add(media.globalKey);
repaired++;
}
return repaired;
});
}
/// Get next item from queue (highest priority, oldest first)
/// Only returns items that are not paused
Future<DownloadQueueItem?> getNextQueueItem() async {
@@ -151,6 +491,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return result?.readTable(downloadQueue);
}
/// Completed videos whose retained queue row records unsettled
/// supplementary download intent.
Future<List<DownloadQueueItem>> getPendingSupplementaryQueueItems() async {
final query = select(
downloadQueue,
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
query
..where(downloadedMedia.status.equals(DownloadStatus.completed.index) & downloadedMedia.videoFilePath.isNotNull())
..orderBy([OrderingTerm(expression: downloadQueue.addedAt)]);
final rows = await query.get();
return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false);
}
Future<void> updateDownloadStatus(String globalKey, int status) async {
await (update(
downloadedMedia,
@@ -182,6 +535,30 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<void> updateDownloadSafRoot(String globalKey, String? safRootUri) async {
await (update(
downloadedMedia,
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(safRootUri: Value(safRootUri)));
}
Future<int> countDownloadsReferencingSafRoot(String safRootUri) async {
final count = downloadedMedia.id.count();
final query = selectOnly(downloadedMedia)
..addColumns([count])
..where(downloadedMedia.safRootUri.equals(safRootUri));
return (await query.map((row) => row.read(count) ?? 0).getSingle());
}
Future<Set<String>> getReferencedDownloadSafRoots() async {
final rows =
await (selectOnly(downloadedMedia)
..addColumns([downloadedMedia.safRootUri])
..where(downloadedMedia.safRootUri.isNotNull()))
.map((row) => row.read(downloadedMedia.safRootUri))
.get();
return rows.whereType<String>().toSet();
}
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
await (update(
downloadedMedia,
@@ -211,10 +588,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> deleteDownload(String globalKey) async {
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
/// Removes the physical row and all dependent queue/owner state atomically,
/// returning the row's SAF root only after the transaction commits.
///
/// The caller owns persisted-grant reconciliation after this returns.
Future<String?> deleteDownload(String globalKey) async {
late String? safRootUri;
await transaction(() async {
safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri;
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
});
return safRootUri;
}
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
@@ -328,3 +714,23 @@ bool _isValidDownloadOwner(
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
return localProfileIds.isEmpty;
}
({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
final separator = connection.id.indexOf('/');
var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
String? userId = separator < 0 || separator == connection.id.length - 1
? null
: connection.id.substring(separator + 1);
try {
final config = jsonDecode(connection.configJson);
if (config is Map<String, dynamic>) {
final configuredMachineId = config['serverMachineId'];
final configuredUserId = config['userId'];
if (configuredMachineId is String && configuredMachineId.isNotEmpty) machineId = configuredMachineId;
if (configuredUserId is String && configuredUserId.isNotEmpty) userId = configuredUserId;
}
} on FormatException {
// Legacy rows still carry enough identity in their canonical id.
}
return (machineId: machineId, userId: userId);
}
+3
View File
@@ -53,6 +53,7 @@ class DownloadedMedia extends Table {
IntColumn get totalBytes => integer().nullable()();
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
TextColumn get videoFilePath => text().nullable()();
TextColumn get safRootUri => text().nullable()();
TextColumn get thumbPath => text().nullable()();
IntColumn get downloadedAt => integer().nullable()();
TextColumn get errorMessage => text().nullable()();
@@ -73,6 +74,8 @@ class DownloadedMedia extends Table {
class DownloadOwners extends Table {
TextColumn get profileId => text()();
TextColumn get globalKey => text()();
TextColumn get backend => text().nullable()();
TextColumn get clientScopeId => text().nullable()();
IntColumn get createdAt => integer()();
@override
@@ -0,0 +1,562 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/app_logger.dart';
/// Startup result after reconciling the purgeable tvOS database with its
/// bounded standard-domain recovery image.
enum TvosDatabaseRecoveryOutcome { notApplicable, fresh, adoptedExistingDatabase, restored, recoveryRequired }
/// The critical row group changed by a database mutation.
enum TvosDatabaseRecoveryGroup { identity, pending }
/// Deterministic fault-injection points for the recovery commit protocol.
@visibleForTesting
enum TvosDatabaseRecoveryCrashPoint { afterInvalidation, afterDatabaseMutation, afterPayloadWrite, afterFinalManifest }
/// A durability failure that is deliberately free of protected row payloads.
final class TvosDatabaseDurabilityException implements Exception {
const TvosDatabaseDurabilityException();
@override
String toString() => 'TvosDatabaseDurabilityException: critical local data was not durably committed';
}
final class _TvosDatabaseRecoveryBudgetException implements Exception {
const _TvosDatabaseRecoveryBudgetException();
}
final class _TvosDatabaseRecoveryInvalidationException implements Exception {
const _TvosDatabaseRecoveryInvalidationException();
}
/// Raw critical rows from a validated, committed recovery image.
///
/// Values are kept raw so already-protected connection configuration and user
/// token bytes are restored exactly, without crossing a reveal boundary.
final class TvosDatabaseRecoverySnapshot {
const TvosDatabaseRecoverySnapshot({required this.identity, required this.pending});
final Map<String, Object?> identity;
final Map<String, Object?> pending;
}
typedef TvosDatabaseRecoveryRowsReader = Future<Map<String, Object?>> Function();
typedef TvosDatabaseRecoveryRestore = Future<void> Function(TvosDatabaseRecoverySnapshot snapshot);
typedef TvosDatabaseRecoveryPriorInstallEvidence = Future<bool> Function();
typedef TvosDatabaseRecoveryDebugCrash = Future<void> Function(TvosDatabaseRecoveryCrashPoint point);
typedef TvosDatabaseRecoveryDebugBeforePreferenceWrite = Future<void> Function(String key);
/// Maintains the bounded two-group tvOS recovery image in UserDefaults.standard.
///
/// The manifest is invalidated before a critical Drift mutation. The changed
/// payload and its digest are written only after Drift commits, followed by the
/// committed manifest as the final write. Therefore a missing database is
/// restorable only from a complete committed image; an interrupted update
/// always requires recovery instead of silently resurrecting stale state.
final class TvosDatabaseRecoveryStore {
TvosDatabaseRecoveryStore(
this._preferences, {
this.isTvos = false,
this.preferenceImageByteCeiling = defaultPreferenceImageByteCeiling,
this.debugCrash,
this.debugBeforePreferenceWrite,
});
static const int recoveryFormatVersion = 1;
static const int defaultPreferenceImageByteCeiling = 400000;
static const String manifestKey = 'tvos_db_recovery_manifest_v1';
static const String identityKey = 'tvos_db_recovery_identity_v1';
static const String pendingKey = 'tvos_db_recovery_pending_v1';
static const String recoveryRequiredKey = 'tvos_db_recovery_required_v1';
static const String keyPrefix = 'tvos_db_recovery_';
static const String _stateInvalidated = 'invalidated';
static const String _stateCommitted = 'committed';
final SharedPreferencesWithCache _preferences;
final bool isTvos;
final int preferenceImageByteCeiling;
final TvosDatabaseRecoveryDebugCrash? debugCrash;
final TvosDatabaseRecoveryDebugBeforePreferenceWrite? debugBeforePreferenceWrite;
bool _recoveryDisabled = false;
bool _manifestCacheNeedsReload = false;
bool _pendingPayloadTruncated = false;
/// Reconciles startup before any registry, legacy bootstrap, or UI consumer.
Future<TvosDatabaseRecoveryOutcome> reconcile({
required bool databaseExisted,
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
required TvosDatabaseRecoveryRestore restore,
required TvosDatabaseRecoveryPriorInstallEvidence hasPriorInstallEvidence,
}) async {
if (!isTvos) return TvosDatabaseRecoveryOutcome.notApplicable;
final recoveryRequired = _preferences.getBool(recoveryRequiredKey) ?? false;
if (recoveryRequired) {
await _reloadManifestCacheIfNeeded();
final snapshot = _readCommittedSnapshot();
if (snapshot == null) return TvosDatabaseRecoveryOutcome.recoveryRequired;
try {
return await _restoreCommittedSnapshot(
snapshot: snapshot,
restore: restore,
readIdentity: readIdentity,
readPending: readPending,
);
} catch (_) {
return TvosDatabaseRecoveryOutcome.recoveryRequired;
}
}
if (databaseExisted) {
// The database is authoritative. Read it outside the recovery publishing
// failure boundary so database failures are never mistaken for damaged
// recovery evidence.
final identityRows = await readIdentity();
final pendingRows = await readPending();
try {
await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows);
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
} on _TvosDatabaseRecoveryInvalidationException {
// The old committed image may still be restorable. Keep recovery
// enabled so every later critical mutation must retry invalidation
// before it is allowed to touch the authoritative database.
_recoveryDisabled = false;
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
} catch (error, stackTrace) {
_disableRecovery(error, stackTrace);
return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase;
}
}
final hasAnyRecoveryKey = _preferences.keys.any((key) => key.startsWith(keyPrefix));
if (!hasAnyRecoveryKey) {
if (await hasPriorInstallEvidence()) {
return _markRecoveryRequired();
}
try {
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
return TvosDatabaseRecoveryOutcome.fresh;
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
_disableRecovery(error, stackTrace);
return TvosDatabaseRecoveryOutcome.fresh;
} catch (_) {
return _markRecoveryRequired();
}
}
final snapshot = _readCommittedSnapshot();
if (snapshot == null) return _markRecoveryRequired();
try {
return await _restoreCommittedSnapshot(
snapshot: snapshot,
restore: restore,
readIdentity: readIdentity,
readPending: readPending,
);
} catch (_) {
return _markRecoveryRequired();
}
}
Future<TvosDatabaseRecoveryOutcome> _restoreCommittedSnapshot({
required TvosDatabaseRecoverySnapshot snapshot,
required TvosDatabaseRecoveryRestore restore,
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
}) async {
await restore(snapshot);
// The restored database may have migrated legacy plaintext credentials.
// Publish a replacement image before clearing the replay marker so the
// committed preference copy is protected as well.
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
await _clearRecoveryRequired();
return TvosDatabaseRecoveryOutcome.restored;
}
Future<TvosDatabaseRecoveryOutcome> _markRecoveryRequired() async {
try {
await debugBeforePreferenceWrite?.call(recoveryRequiredKey);
await _preferences.setBool(recoveryRequiredKey, true);
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
return TvosDatabaseRecoveryOutcome.recoveryRequired;
}
Future<void> _clearRecoveryRequired() async {
// Always issue the removal. SharedPreferencesWithCache can update its
// cache before the platform write finishes, so a failed removal may make
// the key look absent locally while it remains durable.
try {
await debugBeforePreferenceWrite?.call(recoveryRequiredKey);
await _preferences.remove(recoveryRequiredKey);
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
}
/// Runs one complete critical mutation and resolves only after its recovery
/// image is committed. Off tvOS this is a zero-storage wrapper.
Future<T> runDurableMutation<T>({
required TvosDatabaseRecoveryGroup group,
required Future<T> Function() mutation,
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
}) async {
if (!isTvos) return mutation();
if (_recoveryDisabled) return mutation();
await _reloadManifestCacheIfNeeded();
final previous = _readCommittedManifestForMutation();
// Invalidating the old image is mandatory even when the preference
// domain is already over budget: stale identity must never become
// restorable after the database mutation commits.
try {
await _invalidateRecoveryImage(previous);
} on _TvosDatabaseRecoveryInvalidationException {
throw const TvosDatabaseDurabilityException();
}
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterInvalidation);
late final T result;
late final Map<String, Object?> rows;
try {
result = await mutation();
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation);
rows = await (group == TvosDatabaseRecoveryGroup.identity ? readIdentity() : readPending());
} catch (error, stackTrace) {
// The database may already have committed, so the previous recovery
// image is no longer safe to restore. Keep it invalidated and let later
// mutations use the authoritative database for the rest of this process.
_disableRecovery(error, stackTrace);
rethrow;
}
try {
await _commitChangedGroup(previous: previous, group: group, rows: rows);
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
_disableRecovery(error, stackTrace);
} on TvosDatabaseDurabilityException catch (error, stackTrace) {
if (debugCrash != null || debugBeforePreferenceWrite != null) rethrow;
_disableRecovery(error, stackTrace);
}
return result;
}
/// Replaces an irrecoverable image with the current authoritative database
/// only after the user explicitly starts a new sign-in.
Future<void> acknowledgeRecoveryRequired({
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
}) async {
if (!isTvos) return;
try {
await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending);
await _clearRecoveryRequired();
_recoveryDisabled = false;
} on _TvosDatabaseRecoveryInvalidationException {
throw const TvosDatabaseDurabilityException();
} on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) {
_disableRecovery(error, stackTrace);
throw const TvosDatabaseDurabilityException();
}
}
Future<void> _commitAuthoritativeDatabase({
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
}) async {
final identityRows = await readIdentity();
final pendingRows = await readPending();
await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows);
}
Future<void> _publishAuthoritativeRows({
required Map<String, Object?> identityRows,
required Map<String, Object?> pendingRows,
}) async {
await _reloadManifestCacheIfNeeded();
final previous = _readManifestLenient();
await _invalidateRecoveryImage(previous);
final identityPayload = _encodePayload(identityRows);
final pendingPayload = _encodePayload(pendingRows);
final manifest = _Manifest(
state: _stateCommitted,
identityDigest: _digest(identityPayload),
pendingDigest: _digest(pendingPayload),
);
await _commitGeneration(
payloads: {identityKey: identityPayload, pendingKey: pendingPayload},
manifest: manifest,
reportsPendingPayloadState: true,
);
}
Future<void> _commitChangedGroup({
required _Manifest previous,
required TvosDatabaseRecoveryGroup group,
required Map<String, Object?> rows,
}) async {
final payloadKey = group == TvosDatabaseRecoveryGroup.identity ? identityKey : pendingKey;
final payload = _encodePayload(rows);
final digest = _digest(payload);
final manifest = switch (group) {
TvosDatabaseRecoveryGroup.identity => previous.copyWith(state: _stateCommitted, identityDigest: digest),
TvosDatabaseRecoveryGroup.pending => previous.copyWith(state: _stateCommitted, pendingDigest: digest),
};
await _commitGeneration(
payloads: {payloadKey: payload},
manifest: manifest,
reportsPendingPayloadState: group == TvosDatabaseRecoveryGroup.pending,
);
}
Future<void> _commitGeneration({
required Map<String, String> payloads,
required _Manifest manifest,
required bool reportsPendingPayloadState,
}) async {
final committedPayloads = Map<String, String>.of(payloads);
var committedManifest = manifest;
var replacements = <String, String>{...committedPayloads, manifestKey: _encodeManifest(committedManifest)};
var pendingTruncated = false;
if (!_candidateFits(replacements)) {
final emptyPendingPayload = _encodePayload(_emptyPendingRows);
committedPayloads[pendingKey] = emptyPendingPayload;
committedManifest = committedManifest.copyWith(pendingDigest: _digest(emptyPendingPayload));
replacements = <String, String>{...committedPayloads, manifestKey: _encodeManifest(committedManifest)};
pendingTruncated = true;
}
_requireCandidateFits(replacements);
if (reportsPendingPayloadState || pendingTruncated) {
_markPendingPayloadTruncated(pendingTruncated);
}
try {
for (final entry in committedPayloads.entries) {
await debugBeforePreferenceWrite?.call(entry.key);
await _preferences.setString(entry.key, entry.value);
}
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterPayloadWrite);
await debugBeforePreferenceWrite?.call(manifestKey);
await _preferences.setString(manifestKey, _encodeManifest(committedManifest));
await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterFinalManifest);
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
}
TvosDatabaseRecoverySnapshot? _readCommittedSnapshot() {
try {
final manifest = _decodeManifest(_preferences.getString(manifestKey));
if (manifest == null || manifest.state != _stateCommitted) return null;
if (_currentPreferenceImageSize() > preferenceImageByteCeiling) return null;
final identityRaw = _preferences.getString(identityKey);
final pendingRaw = _preferences.getString(pendingKey);
if (identityRaw == null || pendingRaw == null) return null;
if (_digest(identityRaw) != manifest.identityDigest || _digest(pendingRaw) != manifest.pendingDigest) {
return null;
}
final identity = _decodePayload(identityRaw, _identityRowKeys);
final pending = _decodePayload(pendingRaw, _pendingRowKeys);
if (identity == null || pending == null) return null;
return TvosDatabaseRecoverySnapshot(identity: identity, pending: pending);
} catch (_) {
return null;
}
}
_Manifest _readCommittedManifestForMutation() {
try {
final manifest = _decodeManifest(_preferences.getString(manifestKey));
final identityRaw = _preferences.getString(identityKey);
final pendingRaw = _preferences.getString(pendingKey);
if (manifest == null ||
manifest.state != _stateCommitted ||
identityRaw == null ||
pendingRaw == null ||
_digest(identityRaw) != manifest.identityDigest ||
_digest(pendingRaw) != manifest.pendingDigest) {
throw const TvosDatabaseDurabilityException();
}
return manifest;
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
}
_Manifest? _readManifestLenient() {
try {
return _decodeManifest(_preferences.getString(manifestKey));
} catch (_) {
return null;
}
}
Future<void> _invalidateRecoveryImage(_Manifest? previous) async {
try {
await _writeManifest(_invalidatedManifest(previous), enforceBudget: false);
} on TvosDatabaseDurabilityException {
if (previous?.state == _stateCommitted) {
throw const _TvosDatabaseRecoveryInvalidationException();
}
rethrow;
}
}
Future<void> _writeManifest(_Manifest manifest, {bool enforceBudget = true}) async {
final encoded = _encodeManifest(manifest);
if (enforceBudget) _requireCandidateFits({manifestKey: encoded});
try {
await debugBeforePreferenceWrite?.call(manifestKey);
await _preferences.setString(manifestKey, encoded);
} catch (_) {
// SharedPreferencesWithCache updates its local value before awaiting the
// platform write. Reload the durable domain so a failed invalidation
// cannot leave an optimistic "invalidated" manifest blocking retries.
_manifestCacheNeedsReload = true;
try {
await _reloadManifestCacheIfNeeded();
} catch (_) {
// The next mutation retries the durable reload before reading state.
}
throw const TvosDatabaseDurabilityException();
}
}
Future<void> _reloadManifestCacheIfNeeded() async {
if (!_manifestCacheNeedsReload) return;
try {
await _preferences.reloadCache();
_manifestCacheNeedsReload = false;
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
}
_Manifest _invalidatedManifest(_Manifest? previous) => _Manifest(
state: _stateInvalidated,
identityDigest: previous?.identityDigest ?? '',
pendingDigest: previous?.pendingDigest ?? '',
);
bool _candidateFits(Map<String, Object?> replacements) =>
_preferenceImageSize({recoveryRequiredKey: true, ...replacements}) <= preferenceImageByteCeiling;
void _requireCandidateFits(Map<String, Object?> replacements) {
if (!_candidateFits(replacements)) {
throw const _TvosDatabaseRecoveryBudgetException();
}
}
int _currentPreferenceImageSize() => _preferenceImageSize(const {});
int _preferenceImageSize(Map<String, Object?> replacements) {
final keys = <String>{..._preferences.keys.where((key) => key.startsWith(keyPrefix)), ...replacements.keys}.toList()
..sort();
final image = <String, Object?>{};
for (final key in keys) {
image[key] = replacements.containsKey(key) ? replacements[key] : _preferences.get(key);
}
return utf8.encode(jsonEncode(image)).length;
}
void _markPendingPayloadTruncated(bool truncated) {
if (truncated && !_pendingPayloadTruncated) {
appLogger.w('tvOS database recovery omitted pending watch progress to stay within its preference budget');
}
_pendingPayloadTruncated = truncated;
}
void _disableRecovery(Object error, StackTrace stackTrace) {
if (_recoveryDisabled) return;
_recoveryDisabled = true;
appLogger.w(
'tvOS database recovery disabled for this process; the authoritative database remains available',
error: error,
stackTrace: stackTrace,
);
}
static const Set<String> _identityRowKeys = {'connections', 'profiles', 'profileConnections'};
static const Set<String> _pendingRowKeys = {'offlineWatchProgress'};
static const Map<String, Object?> _emptyPendingRows = {'offlineWatchProgress': <Object?>[]};
static String _encodePayload(Map<String, Object?> rows) =>
jsonEncode({'version': recoveryFormatVersion, 'rows': rows});
static Map<String, Object?>? _decodePayload(String raw, Set<String> expectedKeys) {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic> || decoded.length != 2 || decoded['version'] != recoveryFormatVersion) {
return null;
}
final rows = decoded['rows'];
if (rows is! Map<String, dynamic> ||
rows.keys.toSet().difference(expectedKeys).isNotEmpty ||
rows.length != expectedKeys.length) {
return null;
}
for (final key in expectedKeys) {
final value = rows[key];
if (value is! List || value.any((row) => row is! Map<String, dynamic>)) return null;
}
return Map<String, Object?>.unmodifiable(rows);
}
static String _digest(String value) => sha256.convert(utf8.encode(value)).toString();
static String _encodeManifest(_Manifest manifest) => jsonEncode({
'version': recoveryFormatVersion,
'state': manifest.state,
'identityDigest': manifest.identityDigest,
'pendingDigest': manifest.pendingDigest,
});
static _Manifest? _decodeManifest(String? raw) {
if (raw == null) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic> ||
decoded.length != 4 ||
decoded['version'] != recoveryFormatVersion ||
decoded['state'] is! String ||
decoded['identityDigest'] is! String ||
decoded['pendingDigest'] is! String) {
return null;
}
final state = decoded['state'] as String;
final identityDigest = decoded['identityDigest'] as String;
final pendingDigest = decoded['pendingDigest'] as String;
if ((state != _stateInvalidated && state != _stateCommitted) ||
(state == _stateCommitted && (identityDigest.isEmpty || pendingDigest.isEmpty))) {
return null;
}
return _Manifest(state: state, identityDigest: identityDigest, pendingDigest: pendingDigest);
}
}
final class _Manifest {
const _Manifest({required this.state, required this.identityDigest, required this.pendingDigest});
final String state;
final String identityDigest;
final String pendingDigest;
_Manifest copyWith({String? state, String? identityDigest, String? pendingDigest}) => _Manifest(
state: state ?? this.state,
identityDigest: identityDigest ?? this.identityDigest,
pendingDigest: pendingDigest ?? this.pendingDigest,
);
}
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.",
"quickConnectWaiting": "Изчакване на одобрение…",
"quickConnectCancel": "Отказ",
"quickConnectExpired": "Quick Connect изтече. Опитайте отново."
"quickConnectExpired": "Quick Connect изтече. Опитайте отново.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Отказ",
@@ -551,6 +552,11 @@
"streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.",
"liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.",
"fileInfoNotAvailable": "Информацията за файла не е налична",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
@@ -937,6 +943,7 @@
"favorites": "Любими",
"reorderFavorites": "Пренареди любимите",
"favoritesLoadFailed": "Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.",
"favoritesUpdateFailed": "",
"joinSession": "Присъедини се към текуща сесия",
"watchFromStart": "Гледай от началото (преди ${minutes} мин)",
"watchLive": "Гледай на живо",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.",
"quickConnectWaiting": "Venter på godkendelse…",
"quickConnectCancel": "Annullér",
"quickConnectExpired": "Quick Connect er udløbet. Prøv igen."
"quickConnectExpired": "Quick Connect er udløbet. Prøv igen.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Annuller",
@@ -551,6 +552,11 @@
"streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.",
"liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.",
"fileInfoNotAvailable": "Filinfo ikke tilgængelig",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
@@ -937,6 +943,7 @@
"favorites": "Favoritter",
"reorderFavorites": "Omarranger favoritter",
"favoritesLoadFailed": "Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.",
"favoritesUpdateFailed": "",
"joinSession": "Deltag i igangværende session",
"watchFromStart": "Se fra start (${minutes} min siden)",
"watchLive": "Se live",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.",
"quickConnectWaiting": "Warte auf Bestätigung…",
"quickConnectCancel": "Abbrechen",
"quickConnectExpired": "Quick Connect ist abgelaufen. Versuche es erneut."
"quickConnectExpired": "Quick Connect ist abgelaufen. Versuche es erneut.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Abbrechen",
@@ -551,6 +552,11 @@
"streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.",
"liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
@@ -937,6 +943,7 @@
"favorites": "Favoriten",
"reorderFavorites": "Favoriten sortieren",
"favoritesLoadFailed": "Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.",
"favoritesUpdateFailed": "",
"joinSession": "Laufender Sitzung beitreten",
"watchFromStart": "Von Anfang an ansehen (vor ${minutes} Min.)",
"watchLive": "Live ansehen",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.",
"quickConnectWaiting": "Waiting for approval…",
"quickConnectCancel": "Cancel",
"quickConnectExpired": "Quick Connect expired. Try again."
"quickConnectExpired": "Quick Connect expired. Try again.",
"localDataRecoveryRequired": "Plezy could not safely recover local sign-in and pending playback data. Please sign in again."
},
"common": {
"cancel": "Cancel",
@@ -551,6 +552,11 @@
"streamInterrupted": "The stream was interrupted. Press play or seek to retry.",
"liveStreamInterrupted": "The live stream was interrupted. Press play to retry.",
"fileInfoNotAvailable": "File information not available",
"playbackAuthenticationRequired": "Sign in to the media server again to play this item.",
"playbackServerUnavailable": "The media server is unavailable. Try again later.",
"playbackDataInvalid": "The server returned invalid playback information.",
"playbackCancelled": "Playback was cancelled.",
"playbackFailed": "Playback could not be started.",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"musicNotSupported": "Music playback is not yet supported",
@@ -937,6 +943,7 @@
"favorites": "Favorites",
"reorderFavorites": "Reorder Favorites",
"favoritesLoadFailed": "Could not load favorites. Check your connection and try again.",
"favoritesUpdateFailed": "Could not update favorites. Check your connection and try again.",
"joinSession": "Join Session in Progress",
"watchFromStart": "Watch from start (${minutes} min ago)",
"watchLive": "Watch Live",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.",
"quickConnectWaiting": "Esperando aprobación…",
"quickConnectCancel": "Cancelar",
"quickConnectExpired": "Quick Connect caducó. Inténtalo de nuevo."
"quickConnectExpired": "Quick Connect caducó. Inténtalo de nuevo.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Cancelar",
@@ -551,6 +552,11 @@
"streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.",
"liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.",
"fileInfoNotAvailable": "Información de archivo no disponible",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Error al cargar info de archivo: ${error}",
"errorLoadingSeries": "Error al cargar la serie",
"musicNotSupported": "La reproducción de música aún no está soportada",
@@ -937,6 +943,7 @@
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"favoritesLoadFailed": "No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.",
"favoritesUpdateFailed": "",
"joinSession": "Unirse a sesión en curso",
"watchFromStart": "Ver desde el inicio (hace ${minutes} min)",
"watchLive": "Ver en vivo",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.",
"quickConnectWaiting": "En attente d'approbation…",
"quickConnectCancel": "Annuler",
"quickConnectExpired": "Quick Connect a expiré. Réessayez."
"quickConnectExpired": "Quick Connect a expiré. Réessayez.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Annuler",
@@ -551,6 +552,11 @@
"streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.",
"liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.",
"fileInfoNotAvailable": "Informations sur le fichier non disponibles",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"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",
@@ -937,6 +943,7 @@
"favorites": "Favoris",
"reorderFavorites": "Réorganiser les favoris",
"favoritesLoadFailed": "Impossible de charger les favoris. Vérifiez votre connexion et réessayez.",
"favoritesUpdateFailed": "",
"joinSession": "Rejoindre la session en cours",
"watchFromStart": "Regarder depuis le début (il y a ${minutes} min)",
"watchLive": "Regarder en direct",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.",
"quickConnectWaiting": "In attesa di approvazione…",
"quickConnectCancel": "Annulla",
"quickConnectExpired": "Quick Connect scaduto. Riprova."
"quickConnectExpired": "Quick Connect scaduto. Riprova.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Cancella",
@@ -551,6 +552,11 @@
"streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.",
"liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}",
"errorLoadingSeries": "Errore caricamento serie",
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
@@ -937,6 +943,7 @@
"favorites": "Preferiti",
"reorderFavorites": "Riordina preferiti",
"favoritesLoadFailed": "Impossibile caricare i preferiti. Controlla la connessione e riprova.",
"favoritesUpdateFailed": "",
"joinSession": "Partecipa alla sessione in corso",
"watchFromStart": "Guarda dall'inizio (${minutes} min fa)",
"watchLive": "Guarda in diretta",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。",
"quickConnectWaiting": "承認を待っています…",
"quickConnectCancel": "キャンセル",
"quickConnectExpired": "Quick Connectの有効期限が切れました。もう一度お試しください。"
"quickConnectExpired": "Quick Connectの有効期限が切れました。もう一度お試しください。",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "キャンセル",
@@ -550,6 +551,11 @@
"streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。",
"liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。",
"fileInfoNotAvailable": "ファイル情報が利用できません",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません",
@@ -935,6 +941,7 @@
"favorites": "お気に入り",
"reorderFavorites": "お気に入りを並べ替え",
"favoritesLoadFailed": "お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。",
"favoritesUpdateFailed": "",
"joinSession": "進行中のセッションに参加",
"watchFromStart": "最初から視聴(${minutes}分前に開始)",
"watchLive": "ライブで視聴",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.",
"quickConnectWaiting": "승인 대기 중…",
"quickConnectCancel": "취소",
"quickConnectExpired": "Quick Connect가 만료되었습니다. 다시 시도하세요."
"quickConnectExpired": "Quick Connect가 만료되었습니다. 다시 시도하세요.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "취소",
@@ -550,6 +551,11 @@
"streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.",
"liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.",
"fileInfoNotAvailable": "파일 정보가 없습니다",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원",
@@ -935,6 +941,7 @@
"favorites": "즐겨찾기",
"reorderFavorites": "즐겨찾기 순서 변경",
"favoritesLoadFailed": "즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"favoritesUpdateFailed": "",
"joinSession": "진행 중인 세션 참여",
"watchFromStart": "처음부터 시청 (${minutes}분 전 시작)",
"watchLive": "실시간 시청",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Åpne Quick Connect i Jellyfin og skriv inn denne koden.",
"quickConnectWaiting": "Venter på godkjenning…",
"quickConnectCancel": "Avbryt",
"quickConnectExpired": "Quick Connect er utløpt. Prøv igjen."
"quickConnectExpired": "Quick Connect er utløpt. Prøv igjen.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Avbryt",
@@ -551,6 +552,11 @@
"streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.",
"liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.",
"fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
@@ -937,6 +943,7 @@
"favorites": "Favoritter",
"reorderFavorites": "Endre rekkefølge på favoritter",
"favoritesLoadFailed": "Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.",
"favoritesUpdateFailed": "",
"joinSession": "Bli med i pågående økt",
"watchFromStart": "Se fra starten (${minutes} min siden)",
"watchLive": "Se direkte",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Open Quick Connect in Jellyfin en voer deze code in.",
"quickConnectWaiting": "Wachten op goedkeuring…",
"quickConnectCancel": "Annuleren",
"quickConnectExpired": "Quick Connect is verlopen. Probeer opnieuw."
"quickConnectExpired": "Quick Connect is verlopen. Probeer opnieuw.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Annuleren",
@@ -551,6 +552,11 @@
"streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.",
"liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.",
"fileInfoNotAvailable": "Bestand informatie niet beschikbaar",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Fout bij laden bestand info: ${error}",
"errorLoadingSeries": "Fout bij laden serie",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
@@ -937,6 +943,7 @@
"favorites": "Favorieten",
"reorderFavorites": "Favorieten herordenen",
"favoritesLoadFailed": "Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.",
"favoritesUpdateFailed": "",
"joinSession": "Deelnemen aan lopende sessie",
"watchFromStart": "Kijk vanaf het begin (${minutes} min geleden)",
"watchLive": "Live kijken",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Otwórz Quick Connect w Jellyfin i wpisz ten kod.",
"quickConnectWaiting": "Oczekiwanie na zatwierdzenie…",
"quickConnectCancel": "Anuluj",
"quickConnectExpired": "Quick Connect wygasł. Spróbuj ponownie."
"quickConnectExpired": "Quick Connect wygasł. Spróbuj ponownie.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Anuluj",
@@ -553,6 +554,11 @@
"streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.",
"liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.",
"fileInfoNotAvailable": "Informacje o pliku niedostępne",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
@@ -941,6 +947,7 @@
"favorites": "Ulubione",
"reorderFavorites": "Zmień kolejność ulubionych",
"favoritesLoadFailed": "Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.",
"favoritesUpdateFailed": "",
"joinSession": "Dołącz do trwającej sesji",
"watchFromStart": "Oglądaj od początku (${minutes} min temu)",
"watchLive": "Oglądaj na żywo",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Abra o Quick Connect no Jellyfin e insira este código.",
"quickConnectWaiting": "A aguardar aprovação…",
"quickConnectCancel": "Cancelar",
"quickConnectExpired": "Quick Connect expirou. Tente novamente."
"quickConnectExpired": "Quick Connect expirou. Tente novamente.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Cancelar",
@@ -551,6 +552,11 @@
"streamInterrupted": "A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.",
"liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.",
"fileInfoNotAvailable": "Informações do arquivo não disponíveis",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Erro ao carregar info do arquivo: ${error}",
"errorLoadingSeries": "Erro ao carregar série",
"musicNotSupported": "Reprodução de música ainda não é suportada",
@@ -937,6 +943,7 @@
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"favoritesLoadFailed": "Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.",
"favoritesUpdateFailed": "",
"joinSession": "Entrar na sessão em andamento",
"watchFromStart": "Assistir do início (${minutes} min atrás)",
"watchLive": "Assistir ao vivo",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Откройте Quick Connect в Jellyfin и введите этот код.",
"quickConnectWaiting": "Ожидание подтверждения…",
"quickConnectCancel": "Отмена",
"quickConnectExpired": "Срок Quick Connect истек. Попробуйте снова."
"quickConnectExpired": "Срок Quick Connect истек. Попробуйте снова.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Отмена",
@@ -553,6 +554,11 @@
"streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.",
"liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.",
"fileInfoNotAvailable": "Информация о файле недоступна",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
@@ -941,6 +947,7 @@
"favorites": "Избранное",
"reorderFavorites": "Изменить порядок избранного",
"favoritesLoadFailed": "Не удалось загрузить избранное. Проверьте подключение и повторите попытку.",
"favoritesUpdateFailed": "",
"joinSession": "Присоединиться к текущему сеансу",
"watchFromStart": "Смотреть сначала (${minutes} мин. назад)",
"watchLive": "Смотреть в прямом эфире",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22899 (1431 per locale)
/// Strings: 23011 (1438 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthBg extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Изчакване на одобрение…';
@override String get quickConnectCancel => 'Отказ';
@override String get quickConnectExpired => 'Quick Connect изтече. Опитайте отново.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesBg extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.';
@override String get liveStreamInterrupted => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.';
@override String get fileInfoNotAvailable => 'Информацията за файла не е налична';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}';
@override String get errorLoadingSeries => 'Грешка при зареждане на сериала';
@override String get musicNotSupported => 'Възпроизвеждането на музика все още не се поддържа';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvBg extends TranslationsLiveTvEn {
@override String get favorites => 'Любими';
@override String get reorderFavorites => 'Пренареди любимите';
@override String get favoritesLoadFailed => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Присъедини се към текуща сесия';
@override String watchFromStart({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)';
@override String get watchLive => 'Гледай на живо';
@@ -2141,6 +2148,7 @@ extension on TranslationsBg {
'auth.quickConnectWaiting' => 'Изчакване на одобрение…',
'auth.quickConnectCancel' => 'Отказ',
'auth.quickConnectExpired' => 'Quick Connect изтече. Опитайте отново.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Отказ',
'common.save' => 'Запази',
'common.close' => 'Затвори',
@@ -2637,12 +2645,17 @@ extension on TranslationsBg {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ),
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}',
'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала',
'messages.musicNotSupported' => 'Възпроизвеждането на музика все още не се поддържа',
@@ -2982,6 +2995,7 @@ extension on TranslationsBg {
'liveTv.favorites' => 'Любими',
'liveTv.reorderFavorites' => 'Пренареди любимите',
'liveTv.favoritesLoadFailed' => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Присъедини се към текуща сесия',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)',
'liveTv.watchLive' => 'Гледай на живо',
@@ -3145,6 +3159,8 @@ extension on TranslationsBg {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферира',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} е с по-стара версия на приложението — синхронизирането не е налично',
'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Изчакване другите да заредят...',
'watchTogether.waitingForName' => ({required Object name}) => 'Изчакване на ${name}...',
'watchTogether.recentRooms' => 'Скорошни стаи',
@@ -3152,8 +3168,6 @@ extension on TranslationsBg {
'watchTogether.removeRoom' => 'Премахни',
'watchTogether.guestSwitchUnavailable' => 'Превключването не е възможно — сървърът е недостъпен за синхронизация',
'watchTogether.guestSwitchFailed' => 'Превключването не е възможно — съдържанието не е намерено на този сървър',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Изтегляния',
'downloads.manage' => 'Управление',
'downloads.tvShows' => 'ТВ сериали',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthDa extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Venter på godkendelse…';
@override String get quickConnectCancel => 'Annullér';
@override String get quickConnectExpired => 'Quick Connect er udløbet. Prøv igen.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesDa extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.';
@override String get liveStreamInterrupted => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.';
@override String get fileInfoNotAvailable => 'Filinfo ikke tilgængelig';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}';
@override String get errorLoadingSeries => 'Fejl ved indlæsning af serie';
@override String get musicNotSupported => 'Musikafspilning understøttes endnu ikke';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvDa extends TranslationsLiveTvEn {
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Omarranger favoritter';
@override String get favoritesLoadFailed => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Deltag i igangværende session';
@override String watchFromStart({required Object minutes}) => 'Se fra start (${minutes} min siden)';
@override String get watchLive => 'Se live';
@@ -2141,6 +2148,7 @@ extension on TranslationsDa {
'auth.quickConnectWaiting' => 'Venter på godkendelse…',
'auth.quickConnectCancel' => 'Annullér',
'auth.quickConnectExpired' => 'Quick Connect er udløbet. Prøv igen.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Annuller',
'common.save' => 'Gem',
'common.close' => 'Luk',
@@ -2637,12 +2645,17 @@ extension on TranslationsDa {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ),
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.',
'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.',
'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}',
'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie',
'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke',
@@ -2982,6 +2995,7 @@ extension on TranslationsDa {
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Omarranger favoritter',
'liveTv.favoritesLoadFailed' => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Deltag i igangværende session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra start (${minutes} min siden)',
'liveTv.watchLive' => 'Se live',
@@ -3145,6 +3159,8 @@ extension on TranslationsDa {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} bufferer',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruger en ældre appversion — synkronisering er ikke tilgængelig',
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsætter uden ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Venter på at andre indlæser...',
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
'watchTogether.recentRooms' => 'Seneste rum',
@@ -3152,8 +3168,6 @@ extension on TranslationsDa {
'watchTogether.removeRoom' => 'Fjern',
'watchTogether.guestSwitchUnavailable' => 'Kunne ikke skifte — server ikke tilgængelig for synkronisering',
'watchTogether.guestSwitchFailed' => 'Kunne ikke skifte — indhold blev ikke fundet på denne server',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Administrer',
'downloads.tvShows' => 'TV-serier',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthDe extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Warte auf Bestätigung…';
@override String get quickConnectCancel => 'Abbrechen';
@override String get quickConnectExpired => 'Quick Connect ist abgelaufen. Versuche es erneut.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesDe extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.';
@override String get liveStreamInterrupted => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.';
@override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}';
@override String get errorLoadingSeries => 'Fehler beim Laden der Serie';
@override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvDe extends TranslationsLiveTvEn {
@override String get favorites => 'Favoriten';
@override String get reorderFavorites => 'Favoriten sortieren';
@override String get favoritesLoadFailed => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Laufender Sitzung beitreten';
@override String watchFromStart({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)';
@override String get watchLive => 'Live ansehen';
@@ -2141,6 +2148,7 @@ extension on TranslationsDe {
'auth.quickConnectWaiting' => 'Warte auf Bestätigung…',
'auth.quickConnectCancel' => 'Abbrechen',
'auth.quickConnectExpired' => 'Quick Connect ist abgelaufen. Versuche es erneut.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Abbrechen',
'common.save' => 'Speichern',
'common.close' => 'Schließen',
@@ -2637,12 +2645,17 @@ extension on TranslationsDe {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch entfernt: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ),
'messages.removedFromContinueWatching' => 'Aus Weiterschauen\' entfernt',
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}',
'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie',
'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt',
@@ -2982,6 +2995,7 @@ extension on TranslationsDe {
'liveTv.favorites' => 'Favoriten',
'liveTv.reorderFavorites' => 'Favoriten sortieren',
'liveTv.favoritesLoadFailed' => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Laufender Sitzung beitreten',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)',
'liveTv.watchLive' => 'Live ansehen',
@@ -3145,6 +3159,8 @@ extension on TranslationsDe {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} puffert',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar',
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Warte auf andere zum Laden...',
'watchTogether.waitingForName' => ({required Object name}) => 'Warten auf ${name}...',
'watchTogether.recentRooms' => 'Letzte Räume',
@@ -3152,8 +3168,6 @@ extension on TranslationsDe {
'watchTogether.removeRoom' => 'Entfernen',
'watchTogether.guestSwitchUnavailable' => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar',
'watchTogether.guestSwitchFailed' => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Verwalten',
'downloads.tvShows' => 'Serien',
+33 -3
View File
@@ -151,6 +151,9 @@ class TranslationsAuthEn {
/// en: 'Quick Connect expired. Try again.'
String get quickConnectExpired => 'Quick Connect expired. Try again.';
/// en: 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.'
String get localDataRecoveryRequired => 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.';
}
// Path: common
@@ -1680,6 +1683,21 @@ class TranslationsMessagesEn {
/// en: 'File information not available'
String get fileInfoNotAvailable => 'File information not available';
/// en: 'Sign in to the media server again to play this item.'
String get playbackAuthenticationRequired => 'Sign in to the media server again to play this item.';
/// en: 'The media server is unavailable. Try again later.'
String get playbackServerUnavailable => 'The media server is unavailable. Try again later.';
/// en: 'The server returned invalid playback information.'
String get playbackDataInvalid => 'The server returned invalid playback information.';
/// en: 'Playback was cancelled.'
String get playbackCancelled => 'Playback was cancelled.';
/// en: 'Playback could not be started.'
String get playbackFailed => 'Playback could not be started.';
/// en: 'Error loading file info: ${error}'
String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}';
@@ -2675,6 +2693,9 @@ class TranslationsLiveTvEn {
/// en: 'Could not load favorites. Check your connection and try again.'
String get favoritesLoadFailed => 'Could not load favorites. Check your connection and try again.';
/// en: 'Could not update favorites. Check your connection and try again.'
String get favoritesUpdateFailed => 'Could not update favorites. Check your connection and try again.';
/// en: 'Join Session in Progress'
String get joinSession => 'Join Session in Progress';
@@ -5004,6 +5025,7 @@ extension on Translations {
'auth.quickConnectWaiting' => 'Waiting for approval…',
'auth.quickConnectCancel' => 'Cancel',
'auth.quickConnectExpired' => 'Quick Connect expired. Try again.',
'auth.localDataRecoveryRequired' => 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.',
'common.cancel' => 'Cancel',
'common.save' => 'Save',
'common.close' => 'Close',
@@ -5493,6 +5515,7 @@ extension on Translations {
'videoControls.subtitleDownloadedNotApplied' => 'Subtitle downloaded, but it could not be selected',
'videoControls.subtitleDownloadFailed' => 'Failed to download subtitle',
'videoControls.searchLanguages' => 'Search languages...',
'messages.markedAsWatched' => 'Marked as watched',
'messages.markedAsUnwatched' => 'Marked as unwatched',
'messages.markedAsWatchedOffline' => 'Marked as watched (will sync when online)',
@@ -5500,12 +5523,17 @@ extension on Translations {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Auto-removed: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
'messages.fileInfoNotAvailable' => 'File information not available',
'messages.playbackAuthenticationRequired' => 'Sign in to the media server again to play this item.',
'messages.playbackServerUnavailable' => 'The media server is unavailable. Try again later.',
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
'messages.playbackCancelled' => 'Playback was cancelled.',
'messages.playbackFailed' => 'Playback could not be started.',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
'messages.musicNotSupported' => 'Music playback is not yet supported',
@@ -5845,6 +5873,7 @@ extension on Translations {
'liveTv.favorites' => 'Favorites',
'liveTv.reorderFavorites' => 'Reorder Favorites',
'liveTv.favoritesLoadFailed' => 'Could not load favorites. Check your connection and try again.',
'liveTv.favoritesUpdateFailed' => 'Could not update favorites. Check your connection and try again.',
'liveTv.joinSession' => 'Join Session in Progress',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Watch from start (${minutes} min ago)',
'liveTv.watchLive' => 'Watch Live',
@@ -6000,6 +6029,7 @@ extension on Translations {
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching',
'watchTogether.failedToOpenCurrentPlayback' => 'Failed to open current playback',
'watchTogether.participantJoined' => ({required Object name}) => '${name} joined',
'watchTogether.participantLeft' => ({required Object name}) => '${name} left',
'watchTogether.participantPaused' => ({required Object name}) => '${name} paused',
@@ -6008,6 +6038,8 @@ extension on Translations {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable',
'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Waiting for others to load...',
'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...',
'watchTogether.recentRooms' => 'Recent Rooms',
@@ -6015,8 +6047,6 @@ extension on Translations {
'watchTogether.removeRoom' => 'Remove',
'watchTogether.guestSwitchUnavailable' => 'Couldn\'t switch — server unavailable for sync',
'watchTogether.guestSwitchFailed' => 'Couldn\'t switch — content not found on this server',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthEs extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Esperando aprobación…';
@override String get quickConnectCancel => 'Cancelar';
@override String get quickConnectExpired => 'Quick Connect caducó. Inténtalo de nuevo.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesEs extends TranslationsMessagesEn {
@override String get streamInterrupted => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.';
@override String get liveStreamInterrupted => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.';
@override String get fileInfoNotAvailable => 'Información de archivo no disponible';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Error al cargar info de archivo: ${error}';
@override String get errorLoadingSeries => 'Error al cargar la serie';
@override String get musicNotSupported => 'La reproducción de música aún no está soportada';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvEs extends TranslationsLiveTvEn {
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get favoritesLoadFailed => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Unirse a sesión en curso';
@override String watchFromStart({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)';
@override String get watchLive => 'Ver en vivo';
@@ -2141,6 +2148,7 @@ extension on TranslationsEs {
'auth.quickConnectWaiting' => 'Esperando aprobación…',
'auth.quickConnectCancel' => 'Cancelar',
'auth.quickConnectExpired' => 'Quick Connect caducó. Inténtalo de nuevo.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Cancelar',
'common.save' => 'Guardar',
'common.close' => 'Cerrar',
@@ -2637,12 +2645,17 @@ extension on TranslationsEs {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Eliminado automáticamente: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ),
'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.',
'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.',
'messages.fileInfoNotAvailable' => 'Información de archivo no disponible',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}',
'messages.errorLoadingSeries' => 'Error al cargar la serie',
'messages.musicNotSupported' => 'La reproducción de música aún no está soportada',
@@ -2982,6 +2995,7 @@ extension on TranslationsEs {
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.favoritesLoadFailed' => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Unirse a sesión en curso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)',
'liveTv.watchLive' => 'Ver en vivo',
@@ -3145,6 +3159,8 @@ extension on TranslationsEs {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está cargando',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versión anterior de la app — sincronización no disponible',
'watchTogether.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Esperando a que otros carguen...',
'watchTogether.waitingForName' => ({required Object name}) => 'Esperando a ${name}...',
'watchTogether.recentRooms' => 'Salas recientes',
@@ -3152,8 +3168,6 @@ extension on TranslationsEs {
'watchTogether.removeRoom' => 'Eliminar',
'watchTogether.guestSwitchUnavailable' => 'No se pudo cambiar — servidor no disponible para sincronización',
'watchTogether.guestSwitchFailed' => 'No se pudo cambiar — contenido no encontrado en este servidor',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Descargas',
'downloads.manage' => 'Gestionar',
'downloads.tvShows' => 'Series de TV',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthFr extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'En attente d\'approbation…';
@override String get quickConnectCancel => 'Annuler';
@override String get quickConnectExpired => 'Quick Connect a expiré. Réessayez.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesFr extends TranslationsMessagesEn {
@override String get streamInterrupted => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.';
@override String get liveStreamInterrupted => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.';
@override String get fileInfoNotAvailable => 'Informations sur le fichier non disponibles';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}';
@override String get errorLoadingSeries => 'Erreur lors du chargement de la série';
@override String get musicNotSupported => 'La lecture de musique n\'est pas encore prise en charge';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvFr extends TranslationsLiveTvEn {
@override String get favorites => 'Favoris';
@override String get reorderFavorites => 'Réorganiser les favoris';
@override String get favoritesLoadFailed => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Rejoindre la session en cours';
@override String watchFromStart({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)';
@override String get watchLive => 'Regarder en direct';
@@ -2141,6 +2148,7 @@ extension on TranslationsFr {
'auth.quickConnectWaiting' => 'En attente d\'approbation…',
'auth.quickConnectCancel' => 'Annuler',
'auth.quickConnectExpired' => 'Quick Connect a expiré. Réessayez.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Annuler',
'common.save' => 'Sauvegarder',
'common.close' => 'Fermer',
@@ -2637,12 +2645,17 @@ extension on TranslationsFr {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Supprimé automatiquement : ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ),
'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.',
'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.',
'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}',
'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série',
'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge',
@@ -2982,6 +2995,7 @@ extension on TranslationsFr {
'liveTv.favorites' => 'Favoris',
'liveTv.reorderFavorites' => 'Réorganiser les favoris',
'liveTv.favoritesLoadFailed' => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Rejoindre la session en cours',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)',
'liveTv.watchLive' => 'Regarder en direct',
@@ -3145,6 +3159,8 @@ extension on TranslationsFr {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} met en mémoire tampon',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} utilise une ancienne version de lapp — synchronisation indisponible',
'watchTogether.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'En attente du chargement des autres...',
'watchTogether.waitingForName' => ({required Object name}) => 'En attente de ${name}...',
'watchTogether.recentRooms' => 'Salons récents',
@@ -3152,8 +3168,6 @@ extension on TranslationsFr {
'watchTogether.removeRoom' => 'Supprimer',
'watchTogether.guestSwitchUnavailable' => 'Impossible de changer — serveur indisponible pour la synchronisation',
'watchTogether.guestSwitchFailed' => 'Impossible de changer — contenu introuvable sur ce serveur',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Téléchargements',
'downloads.manage' => 'Gérer',
'downloads.tvShows' => 'Show TV',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthIt extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'In attesa di approvazione…';
@override String get quickConnectCancel => 'Annulla';
@override String get quickConnectExpired => 'Quick Connect scaduto. Riprova.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesIt extends TranslationsMessagesEn {
@override String get streamInterrupted => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.';
@override String get liveStreamInterrupted => 'La diretta si è interrotta. Premi Riproduci per riprovare.';
@override String get fileInfoNotAvailable => 'Informazioni sul file non disponibili';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Errore caricamento informazioni sul file: ${error}';
@override String get errorLoadingSeries => 'Errore caricamento serie';
@override String get musicNotSupported => 'La riproduzione musicale non è ancora supportata';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvIt extends TranslationsLiveTvEn {
@override String get favorites => 'Preferiti';
@override String get reorderFavorites => 'Riordina preferiti';
@override String get favoritesLoadFailed => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Partecipa alla sessione in corso';
@override String watchFromStart({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)';
@override String get watchLive => 'Guarda in diretta';
@@ -2141,6 +2148,7 @@ extension on TranslationsIt {
'auth.quickConnectWaiting' => 'In attesa di approvazione…',
'auth.quickConnectCancel' => 'Annulla',
'auth.quickConnectExpired' => 'Quick Connect scaduto. Riprova.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Cancella',
'common.save' => 'Salva',
'common.close' => 'Chiudi',
@@ -2637,12 +2645,17 @@ extension on TranslationsIt {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Rimosso automaticamente: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ),
'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.',
'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.',
'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}',
'messages.errorLoadingSeries' => 'Errore caricamento serie',
'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata',
@@ -2982,6 +2995,7 @@ extension on TranslationsIt {
'liveTv.favorites' => 'Preferiti',
'liveTv.reorderFavorites' => 'Riordina preferiti',
'liveTv.favoritesLoadFailed' => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Partecipa alla sessione in corso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)',
'liveTv.watchLive' => 'Guarda in diretta',
@@ -3145,6 +3159,8 @@ extension on TranslationsIt {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} sta caricando',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versione precedente dell\'app — sincronizzazione non disponibile',
'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'In attesa che gli altri carichino...',
'watchTogether.waitingForName' => ({required Object name}) => 'In attesa di ${name}...',
'watchTogether.recentRooms' => 'Stanze recenti',
@@ -3152,8 +3168,6 @@ extension on TranslationsIt {
'watchTogether.removeRoom' => 'Rimuovi',
'watchTogether.guestSwitchUnavailable' => 'Impossibile cambiare — server non disponibile per la sincronizzazione',
'watchTogether.guestSwitchFailed' => 'Impossibile cambiare — contenuto non trovato su questo server',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Download',
'downloads.manage' => 'Gestisci',
'downloads.tvShows' => 'Serie TV',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthJa extends TranslationsAuthEn {
@override String get quickConnectWaiting => '承認を待っています…';
@override String get quickConnectCancel => 'キャンセル';
@override String get quickConnectExpired => 'Quick Connectの有効期限が切れました。もう一度お試しください。';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -709,6 +710,11 @@ class _TranslationsMessagesJa extends TranslationsMessagesEn {
@override String get streamInterrupted => 'ストリームが中断されました。再生を押すかシークして再試行してください。';
@override String get liveStreamInterrupted => 'ライブストリームが中断されました。再生を押して再試行してください。';
@override String get fileInfoNotAvailable => 'ファイル情報が利用できません';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'ファイル情報の読み込みエラー: ${error}';
@override String get errorLoadingSeries => 'シリーズの読み込みエラー';
@override String get musicNotSupported => '音楽の再生はまだサポートされていません';
@@ -1141,6 +1147,7 @@ class _TranslationsLiveTvJa extends TranslationsLiveTvEn {
@override String get favorites => 'お気に入り';
@override String get reorderFavorites => 'お気に入りを並べ替え';
@override String get favoritesLoadFailed => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => '進行中のセッションに参加';
@override String watchFromStart({required Object minutes}) => '最初から視聴(${minutes}分前に開始)';
@override String get watchLive => 'ライブで視聴';
@@ -2138,6 +2145,7 @@ extension on TranslationsJa {
'auth.quickConnectWaiting' => '承認を待っています…',
'auth.quickConnectCancel' => 'キャンセル',
'auth.quickConnectExpired' => 'Quick Connectの有効期限が切れました。もう一度お試しください。',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'キャンセル',
'common.save' => '保存',
'common.close' => '閉じる',
@@ -2634,12 +2642,17 @@ extension on TranslationsJa {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ),
'messages.removedFromContinueWatching' => '視聴中から削除しました',
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}',
'messages.errorLoadingSeries' => 'シリーズの読み込みエラー',
'messages.musicNotSupported' => '音楽の再生はまだサポートされていません',
@@ -2979,6 +2992,7 @@ extension on TranslationsJa {
'liveTv.favorites' => 'お気に入り',
'liveTv.reorderFavorites' => 'お気に入りを並べ替え',
'liveTv.favoritesLoadFailed' => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => '進行中のセッションに参加',
'liveTv.watchFromStart' => ({required Object minutes}) => '最初から視聴(${minutes}分前に開始)',
'liveTv.watchLive' => 'ライブで視聴',
@@ -3142,6 +3156,8 @@ extension on TranslationsJa {
'watchTogether.participantBuffering' => ({required Object name}) => '${name}がバッファリング中',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} は古いバージョンのアプリを使用しているため、同期できません',
'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => '他の参加者の読み込みを待っています...',
'watchTogether.waitingForName' => ({required Object name}) => '${name}を待っています...',
'watchTogether.recentRooms' => '最近のルーム',
@@ -3149,8 +3165,6 @@ extension on TranslationsJa {
'watchTogether.removeRoom' => '削除',
'watchTogether.guestSwitchUnavailable' => '切り替えできません — サーバーが同期できません',
'watchTogether.guestSwitchFailed' => '切り替えできません — このサーバーにコンテンツが見つかりません',
_ => null,
} ?? switch (path) {
'downloads.title' => 'ダウンロード',
'downloads.manage' => '管理',
'downloads.tvShows' => 'テレビ番組',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthKo extends TranslationsAuthEn {
@override String get quickConnectWaiting => '승인 대기 중…';
@override String get quickConnectCancel => '취소';
@override String get quickConnectExpired => 'Quick Connect가 만료되었습니다. 다시 시도하세요.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -709,6 +710,11 @@ class _TranslationsMessagesKo extends TranslationsMessagesEn {
@override String get streamInterrupted => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.';
@override String get liveStreamInterrupted => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.';
@override String get fileInfoNotAvailable => '파일 정보가 없습니다';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => '파일 정보 로딩 중 오류: ${error}';
@override String get errorLoadingSeries => '시리즈 로딩 중 오류';
@override String get musicNotSupported => '음악 재생 미지원';
@@ -1141,6 +1147,7 @@ class _TranslationsLiveTvKo extends TranslationsLiveTvEn {
@override String get favorites => '즐겨찾기';
@override String get reorderFavorites => '즐겨찾기 순서 변경';
@override String get favoritesLoadFailed => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => '진행 중인 세션 참여';
@override String watchFromStart({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)';
@override String get watchLive => '실시간 시청';
@@ -2138,6 +2145,7 @@ extension on TranslationsKo {
'auth.quickConnectWaiting' => '승인 대기 중…',
'auth.quickConnectCancel' => '취소',
'auth.quickConnectExpired' => 'Quick Connect가 만료되었습니다. 다시 시도하세요.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => '취소',
'common.save' => '저장',
'common.close' => '닫기',
@@ -2634,12 +2642,17 @@ extension on TranslationsKo {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ),
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
'messages.fileInfoNotAvailable' => '파일 정보가 없습니다',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}',
'messages.errorLoadingSeries' => '시리즈 로딩 중 오류',
'messages.musicNotSupported' => '음악 재생 미지원',
@@ -2979,6 +2992,7 @@ extension on TranslationsKo {
'liveTv.favorites' => '즐겨찾기',
'liveTv.reorderFavorites' => '즐겨찾기 순서 변경',
'liveTv.favoritesLoadFailed' => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => '진행 중인 세션 참여',
'liveTv.watchFromStart' => ({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)',
'liveTv.watchLive' => '실시간 시청',
@@ -3142,6 +3156,8 @@ extension on TranslationsKo {
'watchTogether.participantBuffering' => ({required Object name}) => '${name}님이 버퍼링 중입니다',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다',
'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => '다른 참가자의 로딩을 기다리는 중...',
'watchTogether.waitingForName' => ({required Object name}) => '${name}님을 기다리는 중...',
'watchTogether.recentRooms' => '최근 방',
@@ -3149,8 +3165,6 @@ extension on TranslationsKo {
'watchTogether.removeRoom' => '제거',
'watchTogether.guestSwitchUnavailable' => '전환할 수 없음 — 동기화 서버를 사용할 수 없습니다',
'watchTogether.guestSwitchFailed' => '전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다',
_ => null,
} ?? switch (path) {
'downloads.title' => '다운로드',
'downloads.manage' => '관리',
'downloads.tvShows' => 'TV 프로그램',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthNb extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Venter på godkjenning…';
@override String get quickConnectCancel => 'Avbryt';
@override String get quickConnectExpired => 'Quick Connect er utløpt. Prøv igjen.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesNb extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.';
@override String get liveStreamInterrupted => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.';
@override String get fileInfoNotAvailable => 'Filinformasjon ikke tilgjengelig';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}';
@override String get errorLoadingSeries => 'Feil ved lasting av serie';
@override String get musicNotSupported => 'Musikkavspilling støttes ikke ennå';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn {
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Endre rekkefølge på favoritter';
@override String get favoritesLoadFailed => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Bli med i pågående økt';
@override String watchFromStart({required Object minutes}) => 'Se fra starten (${minutes} min siden)';
@override String get watchLive => 'Se direkte';
@@ -2141,6 +2148,7 @@ extension on TranslationsNb {
'auth.quickConnectWaiting' => 'Venter på godkjenning…',
'auth.quickConnectCancel' => 'Avbryt',
'auth.quickConnectExpired' => 'Quick Connect er utløpt. Prøv igjen.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Avbryt',
'common.save' => 'Lagre',
'common.close' => 'Lukk',
@@ -2637,12 +2645,17 @@ extension on TranslationsNb {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ),
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.',
'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.',
'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}',
'messages.errorLoadingSeries' => 'Feil ved lasting av serie',
'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå',
@@ -2982,6 +2995,7 @@ extension on TranslationsNb {
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Endre rekkefølge på favoritter',
'liveTv.favoritesLoadFailed' => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Bli med i pågående økt',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra starten (${minutes} min siden)',
'liveTv.watchLive' => 'Se direkte',
@@ -3145,6 +3159,8 @@ extension on TranslationsNb {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrer',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig',
'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Venter på at andre laster inn...',
'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...',
'watchTogether.recentRooms' => 'Nylige rom',
@@ -3152,8 +3168,6 @@ extension on TranslationsNb {
'watchTogether.removeRoom' => 'Fjern',
'watchTogether.guestSwitchUnavailable' => 'Kunne ikke bytte — server ikke tilgjengelig for synkronisering',
'watchTogether.guestSwitchFailed' => 'Kunne ikke bytte — innhold ble ikke funnet på denne serveren',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Nedlastinger',
'downloads.manage' => 'Administrer',
'downloads.tvShows' => 'TV-serier',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthNl extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Wachten op goedkeuring…';
@override String get quickConnectCancel => 'Annuleren';
@override String get quickConnectExpired => 'Quick Connect is verlopen. Probeer opnieuw.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesNl extends TranslationsMessagesEn {
@override String get streamInterrupted => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.';
@override String get liveStreamInterrupted => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.';
@override String get fileInfoNotAvailable => 'Bestand informatie niet beschikbaar';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Fout bij laden bestand info: ${error}';
@override String get errorLoadingSeries => 'Fout bij laden serie';
@override String get musicNotSupported => 'Muziek afspelen wordt nog niet ondersteund';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvNl extends TranslationsLiveTvEn {
@override String get favorites => 'Favorieten';
@override String get reorderFavorites => 'Favorieten herordenen';
@override String get favoritesLoadFailed => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Deelnemen aan lopende sessie';
@override String watchFromStart({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)';
@override String get watchLive => 'Live kijken';
@@ -2141,6 +2148,7 @@ extension on TranslationsNl {
'auth.quickConnectWaiting' => 'Wachten op goedkeuring…',
'auth.quickConnectCancel' => 'Annuleren',
'auth.quickConnectExpired' => 'Quick Connect is verlopen. Probeer opnieuw.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Annuleren',
'common.save' => 'Opslaan',
'common.close' => 'Sluiten',
@@ -2637,12 +2645,17 @@ extension on TranslationsNl {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch verwijderd: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ),
'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.',
'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.',
'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}',
'messages.errorLoadingSeries' => 'Fout bij laden serie',
'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund',
@@ -2982,6 +2995,7 @@ extension on TranslationsNl {
'liveTv.favorites' => 'Favorieten',
'liveTv.reorderFavorites' => 'Favorieten herordenen',
'liveTv.favoritesLoadFailed' => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Deelnemen aan lopende sessie',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)',
'liveTv.watchLive' => 'Live kijken',
@@ -3145,6 +3159,8 @@ extension on TranslationsNl {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} is aan het bufferen',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar',
'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Wachten tot anderen geladen zijn...',
'watchTogether.waitingForName' => ({required Object name}) => 'Wachten op ${name}...',
'watchTogether.recentRooms' => 'Recente kamers',
@@ -3152,8 +3168,6 @@ extension on TranslationsNl {
'watchTogether.removeRoom' => 'Verwijderen',
'watchTogether.guestSwitchUnavailable' => 'Kon niet schakelen — server niet beschikbaar voor synchronisatie',
'watchTogether.guestSwitchFailed' => 'Kon niet schakelen — inhoud niet gevonden op deze server',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Beheren',
'downloads.tvShows' => 'Series',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthPl extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Oczekiwanie na zatwierdzenie…';
@override String get quickConnectCancel => 'Anuluj';
@override String get quickConnectExpired => 'Quick Connect wygasł. Spróbuj ponownie.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -712,6 +713,11 @@ class _TranslationsMessagesPl extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.';
@override String get liveStreamInterrupted => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.';
@override String get fileInfoNotAvailable => 'Informacje o pliku niedostępne';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}';
@override String get errorLoadingSeries => 'Błąd ładowania serialu';
@override String get musicNotSupported => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane';
@@ -1147,6 +1153,7 @@ class _TranslationsLiveTvPl extends TranslationsLiveTvEn {
@override String get favorites => 'Ulubione';
@override String get reorderFavorites => 'Zmień kolejność ulubionych';
@override String get favoritesLoadFailed => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Dołącz do trwającej sesji';
@override String watchFromStart({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)';
@override String get watchLive => 'Oglądaj na żywo';
@@ -2147,6 +2154,7 @@ extension on TranslationsPl {
'auth.quickConnectWaiting' => 'Oczekiwanie na zatwierdzenie…',
'auth.quickConnectCancel' => 'Anuluj',
'auth.quickConnectExpired' => 'Quick Connect wygasł. Spróbuj ponownie.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Anuluj',
'common.save' => 'Zapisz',
'common.close' => 'Zamknij',
@@ -2643,12 +2651,17 @@ extension on TranslationsPl {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatycznie usunięto: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ),
'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.',
'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.',
'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}',
'messages.errorLoadingSeries' => 'Błąd ładowania serialu',
'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane',
@@ -2988,6 +3001,7 @@ extension on TranslationsPl {
'liveTv.favorites' => 'Ulubione',
'liveTv.reorderFavorites' => 'Zmień kolejność ulubionych',
'liveTv.favoritesLoadFailed' => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Dołącz do trwającej sesji',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)',
'liveTv.watchLive' => 'Oglądaj na żywo',
@@ -3151,6 +3165,8 @@ extension on TranslationsPl {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buforuje',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna',
'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Oczekiwanie na załadowanie u innych...',
'watchTogether.waitingForName' => ({required Object name}) => 'Oczekiwanie na ${name}...',
'watchTogether.recentRooms' => 'Ostatnie pokoje',
@@ -3158,8 +3174,6 @@ extension on TranslationsPl {
'watchTogether.removeRoom' => 'Usuń',
'watchTogether.guestSwitchUnavailable' => 'Nie można przełączyć — serwer niedostępny do synchronizacji',
'watchTogether.guestSwitchFailed' => 'Nie można przełączyć — nie znaleziono treści na tym serwerze',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Pobrania',
'downloads.manage' => 'Zarządzaj',
'downloads.tvShows' => 'Seriale TV',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthPt extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'A aguardar aprovação…';
@override String get quickConnectCancel => 'Cancelar';
@override String get quickConnectExpired => 'Quick Connect expirou. Tente novamente.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesPt extends TranslationsMessagesEn {
@override String get streamInterrupted => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.';
@override String get liveStreamInterrupted => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.';
@override String get fileInfoNotAvailable => 'Informações do arquivo não disponíveis';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Erro ao carregar info do arquivo: ${error}';
@override String get errorLoadingSeries => 'Erro ao carregar série';
@override String get musicNotSupported => 'Reprodução de música ainda não é suportada';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvPt extends TranslationsLiveTvEn {
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get favoritesLoadFailed => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Entrar na sessão em andamento';
@override String watchFromStart({required Object minutes}) => 'Assistir do início (${minutes} min atrás)';
@override String get watchLive => 'Assistir ao vivo';
@@ -2141,6 +2148,7 @@ extension on TranslationsPt {
'auth.quickConnectWaiting' => 'A aguardar aprovação…',
'auth.quickConnectCancel' => 'Cancelar',
'auth.quickConnectExpired' => 'Quick Connect expirou. Tente novamente.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Cancelar',
'common.save' => 'Salvar',
'common.close' => 'Fechar',
@@ -2637,12 +2645,17 @@ extension on TranslationsPt {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Removido automaticamente: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ),
'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.',
'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.',
'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}',
'messages.errorLoadingSeries' => 'Erro ao carregar série',
'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada',
@@ -2982,6 +2995,7 @@ extension on TranslationsPt {
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.favoritesLoadFailed' => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Entrar na sessão em andamento',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Assistir do início (${minutes} min atrás)',
'liveTv.watchLive' => 'Assistir ao vivo',
@@ -3145,6 +3159,8 @@ extension on TranslationsPt {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} está carregando',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} está em uma versão mais antiga do aplicativo — sincronização indisponível',
'watchTogether.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Aguardando outros carregarem...',
'watchTogether.waitingForName' => ({required Object name}) => 'Aguardando ${name}...',
'watchTogether.recentRooms' => 'Salas recentes',
@@ -3152,8 +3168,6 @@ extension on TranslationsPt {
'watchTogether.removeRoom' => 'Remover',
'watchTogether.guestSwitchUnavailable' => 'Não foi possível trocar — servidor indisponível para sincronização',
'watchTogether.guestSwitchFailed' => 'Não foi possível trocar — conteúdo não encontrado neste servidor',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Gerenciar',
'downloads.tvShows' => 'Séries de TV',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthRu extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Ожидание подтверждения…';
@override String get quickConnectCancel => 'Отмена';
@override String get quickConnectExpired => 'Срок Quick Connect истек. Попробуйте снова.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -712,6 +713,11 @@ class _TranslationsMessagesRu extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.';
@override String get liveStreamInterrupted => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.';
@override String get fileInfoNotAvailable => 'Информация о файле недоступна';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Ошибка загрузки информации о файле: ${error}';
@override String get errorLoadingSeries => 'Ошибка загрузки сериала';
@override String get musicNotSupported => 'Воспроизведение музыки пока не поддерживается';
@@ -1147,6 +1153,7 @@ class _TranslationsLiveTvRu extends TranslationsLiveTvEn {
@override String get favorites => 'Избранное';
@override String get reorderFavorites => 'Изменить порядок избранного';
@override String get favoritesLoadFailed => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Присоединиться к текущему сеансу';
@override String watchFromStart({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)';
@override String get watchLive => 'Смотреть в прямом эфире';
@@ -2147,6 +2154,7 @@ extension on TranslationsRu {
'auth.quickConnectWaiting' => 'Ожидание подтверждения…',
'auth.quickConnectCancel' => 'Отмена',
'auth.quickConnectExpired' => 'Срок Quick Connect истек. Попробуйте снова.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Отмена',
'common.save' => 'Сохранить',
'common.close' => 'Закрыть',
@@ -2643,12 +2651,17 @@ extension on TranslationsRu {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоудалено: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ),
'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
'messages.fileInfoNotAvailable' => 'Информация о файле недоступна',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}',
'messages.errorLoadingSeries' => 'Ошибка загрузки сериала',
'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается',
@@ -2988,6 +3001,7 @@ extension on TranslationsRu {
'liveTv.favorites' => 'Избранное',
'liveTv.reorderFavorites' => 'Изменить порядок избранного',
'liveTv.favoritesLoadFailed' => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Присоединиться к текущему сеансу',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)',
'liveTv.watchLive' => 'Смотреть в прямом эфире',
@@ -3151,6 +3165,8 @@ extension on TranslationsRu {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферизует',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} использует старую версию приложения — синхронизация недоступна',
'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Ожидание загрузки у других...',
'watchTogether.waitingForName' => ({required Object name}) => 'Ожидание ${name}...',
'watchTogether.recentRooms' => 'Недавние комнаты',
@@ -3158,8 +3174,6 @@ extension on TranslationsRu {
'watchTogether.removeRoom' => 'Удалить',
'watchTogether.guestSwitchUnavailable' => 'Не удалось переключиться — сервер недоступен для синхронизации',
'watchTogether.guestSwitchFailed' => 'Не удалось переключиться — содержимое не найдено на этом сервере',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Загрузки',
'downloads.manage' => 'Управление',
'downloads.tvShows' => 'Сериалы',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthSv extends TranslationsAuthEn {
@override String get quickConnectWaiting => 'Väntar på godkännande…';
@override String get quickConnectCancel => 'Avbryt';
@override String get quickConnectExpired => 'Quick Connect har gått ut. Försök igen.';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -710,6 +711,11 @@ class _TranslationsMessagesSv extends TranslationsMessagesEn {
@override String get streamInterrupted => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.';
@override String get liveStreamInterrupted => 'Livestreamen avbröts. Tryck på play för att försöka igen.';
@override String get fileInfoNotAvailable => 'Filinformation inte tillgänglig';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => 'Fel vid laddning av filinformation: ${error}';
@override String get errorLoadingSeries => 'Fel vid laddning av serie';
@override String get musicNotSupported => 'Musikuppspelning stöds inte ännu';
@@ -1143,6 +1149,7 @@ class _TranslationsLiveTvSv extends TranslationsLiveTvEn {
@override String get favorites => 'Favoriter';
@override String get reorderFavorites => 'Ordna om favoriter';
@override String get favoritesLoadFailed => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => 'Gå med i pågående session';
@override String watchFromStart({required Object minutes}) => 'Titta från början (${minutes} min sedan)';
@override String get watchLive => 'Titta live';
@@ -2141,6 +2148,7 @@ extension on TranslationsSv {
'auth.quickConnectWaiting' => 'Väntar på godkännande…',
'auth.quickConnectCancel' => 'Avbryt',
'auth.quickConnectExpired' => 'Quick Connect har gått ut. Försök igen.',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => 'Avbryt',
'common.save' => 'Spara',
'common.close' => 'Stäng',
@@ -2637,12 +2645,17 @@ extension on TranslationsSv {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatiskt borttagen: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ),
'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.',
'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.',
'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}',
'messages.errorLoadingSeries' => 'Fel vid laddning av serie',
'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu',
@@ -2982,6 +2995,7 @@ extension on TranslationsSv {
'liveTv.favorites' => 'Favoriter',
'liveTv.reorderFavorites' => 'Ordna om favoriter',
'liveTv.favoritesLoadFailed' => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => 'Gå med i pågående session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Titta från början (${minutes} min sedan)',
'liveTv.watchLive' => 'Titta live',
@@ -3145,6 +3159,8 @@ extension on TranslationsSv {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrar',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} använder en äldre appversion — synkronisering är inte tillgänglig',
'watchTogether.resumingWithout' => ({required Object name}) => 'Återupptar utan ${name}',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => 'Väntar på att andra laddar...',
'watchTogether.waitingForName' => ({required Object name}) => 'Väntar på ${name}...',
'watchTogether.recentRooms' => 'Senaste rum',
@@ -3152,8 +3168,6 @@ extension on TranslationsSv {
'watchTogether.removeRoom' => 'Ta bort',
'watchTogether.guestSwitchUnavailable' => 'Kunde inte byta — server inte tillgänglig för synkronisering',
'watchTogether.guestSwitchFailed' => 'Kunde inte byta — innehåll hittades inte på denna server',
_ => null,
} ?? switch (path) {
'downloads.title' => 'Nedladdningar',
'downloads.manage' => 'Hantera',
'downloads.tvShows' => 'TV-serier',
+17 -3
View File
@@ -120,6 +120,7 @@ class _TranslationsAuthZh extends TranslationsAuthEn {
@override String get quickConnectWaiting => '等待批准…';
@override String get quickConnectCancel => '取消';
@override String get quickConnectExpired => 'Quick Connect 已过期。请重试。';
@override String get localDataRecoveryRequired => '';
}
// Path: common
@@ -709,6 +710,11 @@ class _TranslationsMessagesZh extends TranslationsMessagesEn {
@override String get streamInterrupted => '视频流已中断。按播放键或拖动进度条重试。';
@override String get liveStreamInterrupted => '直播流已中断。按播放键重试。';
@override String get fileInfoNotAvailable => '文件信息不可用';
@override String get playbackAuthenticationRequired => '';
@override String get playbackServerUnavailable => '';
@override String get playbackDataInvalid => '';
@override String get playbackCancelled => '';
@override String get playbackFailed => '';
@override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}';
@override String get errorLoadingSeries => '加载系列时出错';
@override String get musicNotSupported => '尚不支持播放音乐';
@@ -1141,6 +1147,7 @@ class _TranslationsLiveTvZh extends TranslationsLiveTvEn {
@override String get favorites => '收藏';
@override String get reorderFavorites => '重新排序收藏';
@override String get favoritesLoadFailed => '无法加载收藏。请检查网络连接后重试。';
@override String get favoritesUpdateFailed => '';
@override String get joinSession => '加入正在进行的会话';
@override String watchFromStart({required Object minutes}) => '从头观看(${minutes}分钟前开始)';
@override String get watchLive => '观看直播';
@@ -2138,6 +2145,7 @@ extension on TranslationsZh {
'auth.quickConnectWaiting' => '等待批准…',
'auth.quickConnectCancel' => '取消',
'auth.quickConnectExpired' => 'Quick Connect 已过期。请重试。',
'auth.localDataRecoveryRequired' => '',
'common.cancel' => '取消',
'common.save' => '保存',
'common.close' => '关闭',
@@ -2634,12 +2642,17 @@ extension on TranslationsZh {
'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}',
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ),
'messages.removedFromContinueWatching' => '已从继续观看中移除',
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
_ => null,
} ?? switch (path) {
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
'messages.fileInfoNotAvailable' => '文件信息不可用',
'messages.playbackAuthenticationRequired' => '',
'messages.playbackServerUnavailable' => '',
'messages.playbackDataInvalid' => '',
'messages.playbackCancelled' => '',
'messages.playbackFailed' => '',
'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}',
'messages.errorLoadingSeries' => '加载系列时出错',
'messages.musicNotSupported' => '尚不支持播放音乐',
@@ -2979,6 +2992,7 @@ extension on TranslationsZh {
'liveTv.favorites' => '收藏',
'liveTv.reorderFavorites' => '重新排序收藏',
'liveTv.favoritesLoadFailed' => '无法加载收藏。请检查网络连接后重试。',
'liveTv.favoritesUpdateFailed' => '',
'liveTv.joinSession' => '加入正在进行的会话',
'liveTv.watchFromStart' => ({required Object minutes}) => '从头观看(${minutes}分钟前开始)',
'liveTv.watchLive' => '观看直播',
@@ -3142,6 +3156,8 @@ extension on TranslationsZh {
'watchTogether.participantBuffering' => ({required Object name}) => '${name} 正在缓冲',
'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} 正在使用较旧版本的应用,无法同步',
'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放',
_ => null,
} ?? switch (path) {
'watchTogether.waitingForParticipants' => '等待其他人加载...',
'watchTogether.waitingForName' => ({required Object name}) => '正在等待 ${name}...',
'watchTogether.recentRooms' => '最近的房间',
@@ -3149,8 +3165,6 @@ extension on TranslationsZh {
'watchTogether.removeRoom' => '移除',
'watchTogether.guestSwitchUnavailable' => '无法切换 — 服务器无法同步',
'watchTogether.guestSwitchFailed' => '无法切换 — 在此服务器上未找到内容',
_ => null,
} ?? switch (path) {
'downloads.title' => '下载',
'downloads.manage' => '管理',
'downloads.tvShows' => '电视剧',
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "Öppna Quick Connect i Jellyfin och ange den här koden.",
"quickConnectWaiting": "Väntar på godkännande…",
"quickConnectCancel": "Avbryt",
"quickConnectExpired": "Quick Connect har gått ut. Försök igen."
"quickConnectExpired": "Quick Connect har gått ut. Försök igen.",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "Avbryt",
@@ -551,6 +552,11 @@
"streamInterrupted": "Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.",
"liveStreamInterrupted": "Livestreamen avbröts. Tryck på play för att försöka igen.",
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
@@ -937,6 +943,7 @@
"favorites": "Favoriter",
"reorderFavorites": "Ordna om favoriter",
"favoritesLoadFailed": "Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.",
"favoritesUpdateFailed": "",
"joinSession": "Gå med i pågående session",
"watchFromStart": "Titta från början (${minutes} min sedan)",
"watchLive": "Titta live",
+8 -1
View File
@@ -16,7 +16,8 @@
"quickConnectInstructions": "在 Jellyfin 中打开 Quick Connect 并输入此代码。",
"quickConnectWaiting": "等待批准…",
"quickConnectCancel": "取消",
"quickConnectExpired": "Quick Connect 已过期。请重试。"
"quickConnectExpired": "Quick Connect 已过期。请重试。",
"localDataRecoveryRequired": ""
},
"common": {
"cancel": "取消",
@@ -550,6 +551,11 @@
"streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。",
"liveStreamInterrupted": "直播流已中断。按播放键重试。",
"fileInfoNotAvailable": "文件信息不可用",
"playbackAuthenticationRequired": "",
"playbackServerUnavailable": "",
"playbackDataInvalid": "",
"playbackCancelled": "",
"playbackFailed": "",
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
"errorLoadingSeries": "加载系列时出错",
"musicNotSupported": "尚不支持播放音乐",
@@ -935,6 +941,7 @@
"favorites": "收藏",
"reorderFavorites": "重新排序收藏",
"favoritesLoadFailed": "无法加载收藏。请检查网络连接后重试。",
"favoritesUpdateFailed": "",
"joinSession": "加入正在进行的会话",
"watchFromStart": "从头观看(${minutes}分钟前开始)",
"watchLive": "观看直播",
+79 -15
View File
@@ -62,6 +62,7 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'services/jellyfin_api_cache.dart';
import 'services/plex_api_cache.dart';
import 'database/app_database.dart';
import 'database/tvos_database_recovery_store.dart';
import 'screens/video_player_screen.dart';
import 'utils/app_logger.dart';
import 'utils/managed_http_client.dart';
@@ -78,6 +79,7 @@ import 'utils/log_redaction_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
const String gitCommit = String.fromEnvironment('GIT_COMMIT');
const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT');
const String _sentryDist = String.fromEnvironment('SENTRY_DIST');
@@ -126,7 +128,7 @@ Future<void> main() async {
final packageInfo = await PackageInfo.fromPlatform();
await SentryFlutter.init((options) {
options.dsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
options.dsn = _sentryDsn;
options.release = gitCommit.isNotEmpty
? 'plezy@${gitCommit.substring(0, 7)}'
: 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
@@ -211,6 +213,8 @@ Future<void> _bootstrapApp() async {
await Future.wait(futures);
final storage = await storageFuture;
markStartupPhase('platform-services');
final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV());
markStartupPhase('database-recovery');
// Configure image cache — keep budget modest to leave headroom for Skia
// decode buffers. Runs after the futures so the effects tier is resolved.
@@ -219,7 +223,6 @@ Future<void> _bootstrapApp() async {
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
// is available — keeps the deprecated legacy slots out of runtime paths.
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
setLoggerLevel(debugEnabled);
@@ -280,8 +283,17 @@ Future<void> _bootstrapApp() async {
return const ColoredBox(color: Color(0xFF000000));
};
final appDatabase = databaseBootstrap.database;
markStartupPhase('pre-runApp');
runApp(MainApp(settings: settings, storage: storage));
runApp(
MainApp(
settings: settings,
storage: storage,
appDatabase: appDatabase,
databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome,
),
);
}
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
@@ -297,7 +309,7 @@ Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
}
FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
// Drop event if user opted out of crash reporting
// Drop event if user opted out of crash reporting.
final instance = SettingsService.instanceOrNull;
if (instance != null && !instance.read(SettingsService.crashReporting)) return null;
@@ -459,8 +471,16 @@ Future<String?> _rootPinPrompt(Profile profile, {String? errorMessage}) {
class MainApp extends StatefulWidget {
final SettingsService settings;
final StorageService storage;
final AppDatabase appDatabase;
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
const MainApp({super.key, required this.settings, required this.storage});
const MainApp({
super.key,
required this.settings,
required this.storage,
required this.appDatabase,
required this.databaseRecoveryOutcome,
});
@override
State<MainApp> createState() => _MainAppState();
@@ -505,7 +525,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_serverManager = MultiServerManager();
_aggregationService = DataAggregationService(_serverManager);
_appDatabase = AppDatabase();
_appDatabase = widget.appDatabase;
PlexApiCache.initialize(_appDatabase);
JellyfinApiCache.initialize(_appDatabase);
@@ -673,10 +693,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_isAutoDeleteRunning = true;
try {
await downloadProvider.refreshMetadataFromCache();
final activeKey = VideoPlayerScreenState.activeId;
final activeGlobalKey = VideoPlayerScreenState.activeGlobalKey;
final settings = SettingsService.instanceOrNull;
if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) {
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey);
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeGlobalKey: activeGlobalKey);
if (deleted.isNotEmpty) {
final msg = deleted.length == 1
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
@@ -886,7 +906,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// binge-watching coalesces into one pass.
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
if (event.changeType != WatchStateChangeType.watched) return;
if (VideoPlayerScreenState.activeId == event.itemId) return;
if (VideoPlayerScreenState.activeGlobalKey == event.globalKey) return;
_pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event));
@@ -943,7 +963,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// profile-scoped session in ProfileSessionScreen.
ChangeNotifierProvider(create: (context) => ShaderProvider()),
],
child: const _AppShell(),
child: _AppShell(databaseRecoveryOutcome: widget.databaseRecoveryOutcome),
);
}
}
@@ -953,7 +973,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
/// [ProfileSessionScreen], not here, so root auth/PIN/global dialogs survive a
/// profile switch.
class _AppShell extends StatelessWidget {
const _AppShell();
const _AppShell({required this.databaseRecoveryOutcome});
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
@override
Widget build(BuildContext context) {
@@ -985,7 +1007,7 @@ class _AppShell extends StatelessWidget {
themeMode: themeProvider.materialThemeMode,
navigatorKey: rootNavigatorKey,
navigatorObservers: [BackKeySuppressorObserver()],
home: const OrientationAwareSetup(),
home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome),
// Siri Remote select + gamepad A report as
// LogicalKeyboardKey.{select,gameButtonA} which aren't
// in Flutter's default shortcut set — Material-level
@@ -1067,8 +1089,15 @@ class _AppleTvScale extends StatelessWidget {
}
}
@visibleForTesting
bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
}
class OrientationAwareSetup extends StatefulWidget {
const OrientationAwareSetup({super.key});
const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome});
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
@override
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
@@ -1087,12 +1116,22 @@ class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
@override
Widget build(BuildContext context) {
return const SetupScreen();
return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome);
}
}
class SetupScreen extends StatefulWidget {
const SetupScreen({super.key});
const SetupScreen({
super.key,
required this.databaseRecoveryOutcome,
this.initializeAuthServices = true,
this.debugRecoveryRequiredRouter,
});
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
final bool initializeAuthServices;
@visibleForTesting
final FutureOr<void> Function(BuildContext context, String message)? debugRecoveryRequiredRouter;
@override
State<SetupScreen> createState() => _SetupScreenState();
@@ -1125,6 +1164,31 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
}
Future<void> _loadSavedCredentials() async {
if (shouldBypassSetupForDatabaseRecovery(widget.databaseRecoveryOutcome)) {
final message = t.auth.localDataRecoveryRequired;
final debugRouter = widget.debugRecoveryRequiredRouter;
if (debugRouter != null) {
await Future.sync(() => debugRouter(context, message));
return;
}
await Future<void>.delayed(Duration.zero);
if (mounted) {
unawaited(
Navigator.pushReplacement(
context,
fadeRoute(
AuthScreen(
initialErrorMessage: message,
initializeServices: widget.initializeAuthServices,
databaseRecoveryRequired: true,
),
),
),
);
}
return;
}
_setStatus(t.common.checkingNetwork);
final storage = await StorageService.getInstance();
+10 -1
View File
@@ -50,5 +50,14 @@ class DownloadResolution {
final String? mediaSourceId;
final List<DownloadSubtitleSpec> externalSubtitles;
const DownloadResolution({required this.videoUrl, this.mediaSourceId, this.externalSubtitles = const []});
/// Whether [externalSubtitles] is authoritative. A false value keeps the
/// supplementary-download queue pending so it can retry enrichment later.
final bool externalSubtitlesResolved;
const DownloadResolution({
required this.videoUrl,
this.mediaSourceId,
this.externalSubtitles = const [],
this.externalSubtitlesResolved = true,
});
}
+3 -2
View File
@@ -183,8 +183,9 @@ abstract class LiveTvSupport {
FavoriteChannelPersistenceMode get favoritePersistenceMode;
/// Read the user's favorite channels for this server. Plex pulls from the
/// cloud-synced list; Jellyfin queries `IsFavorite=true` with locally
/// stored ordering.
/// cloud-synced list; Jellyfin reads its locally stored ordering. A
/// successful read returns the complete list, including `[]` when no
/// favorites are stored. Unavailable or invalid reads complete with an error.
Future<List<FavoriteChannel>> fetchFavoriteChannels();
/// Persist the favorites list (and order, where supported). Plex pushes
+49 -36
View File
@@ -1,3 +1,4 @@
import '../exceptions/media_server_exceptions.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../services/api_cache.dart';
@@ -41,27 +42,24 @@ const int defaultHubPreviewLimit = 20;
/// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata
/// edit adapters) live on concrete clients or feature modules.
///
/// ## Error contract (write methods)
/// ## Mutation error and result contracts
///
/// All write methods (`markWatched`, `markUnwatched`, `removeFromContinueWatching`,
/// `rate`, `createPlaylist`, `addToPlaylist`, `deletePlaylist`,
/// `movePlaylistItem`, `removeFromPlaylist`, `createCollection`,
/// `addToCollection`, `removeFromCollection`, `deleteCollection`,
/// `deleteMediaItem`) follow the same contract:
/// HTTP status, timeout, connection, decode, and cancellation failures from
/// the shared transport surface as [MediaServerHttpException]. Calls for an
/// unsupported advertised capability may throw [UnsupportedError] where the
/// method documents that boundary.
///
/// - HTTP 4xx/5xx → throw [MediaServerHttpException].
/// - Network/IO failure → throw the underlying exception.
/// - Business "not applicable" (e.g. wrong-backend item handed to a
/// write call) → return `false` without throwing.
/// - Success → return the created entity / `true`.
/// Result semantics follow each method's declared family. Completion of a
/// `Future<void>` mutation is success and carries no business-result value.
/// Nullable creation methods return `null` only after an accepted request
/// produced no usable created entity or id; request failures throw. Boolean
/// mutations return `true` on their accepted success path, and return `false`
/// only for local preconditions explicitly documented by that method rather
/// than as a substitute for request failure.
///
/// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that
/// can't be parsed; auth/server errors throw rather than silently dropping
/// to `null`.
///
/// Callers that need to differentiate "operation impossible" from "server
/// error" should `try`/`catch` the result and inspect the exception's
/// `statusCode`.
/// Outcome of a health probe. Distinguishes "session expired" (token was
/// rejected) from a generic transport failure, so the manager can route the
@@ -369,9 +367,11 @@ abstract class MediaServerClient {
/// per-playlist item ids where the server exposes them.
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort});
/// Create a new playlist seeded with [items]. Returns the created
/// playlist on success, `null` on failure. Plex builds a metadata URI
/// from the item ids; Jellyfin posts `Ids=<comma-joined>`.
/// Create a new playlist seeded with [items]. Returns the created playlist
/// when it can be recovered from an accepted response, or `null` when that
/// response contains no usable created playlist. Request failures throw.
/// Plex builds a metadata URI from the item ids; Jellyfin posts
/// `Ids=<comma-joined>`.
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items});
/// Append [items] to an existing playlist. Returns `true` on success.
@@ -432,9 +432,10 @@ abstract class MediaServerClient {
});
/// Create a new collection in [libraryId] seeded with [items]. Returns the
/// created collection's id on success, `null` on failure. [itemKind] is
/// only used by Plex (it disambiguates the section type — movie/show/
/// season/episode); Jellyfin ignores it.
/// created collection id when it can be recovered from an accepted response,
/// or `null` when that response contains no usable id. Request failures
/// throw. [itemKind] is only used by Plex (it disambiguates the section type
/// — movie/show/season/episode); Jellyfin ignores it.
Future<String?> createCollection({
required String libraryId,
required String title,
@@ -609,11 +610,11 @@ abstract class MediaServerClient {
/// Resolve the video URL, media info, and external subtitle list for
/// playback. Backends own the per-backend particulars: Plex runs the
/// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset]
/// is non-original; Jellyfin asks PlaybackInfo for a matching stream when a
/// non-original preset is selected. Throws
/// [PlaybackException] when the item can't be resolved (no MediaSources,
/// no playable URL, transcode decision unavailable).
/// transcode-decision flow for non-original quality; Jellyfin negotiates
/// both original and non-original playback through PlaybackInfo. Typed
/// request, cancellation, and malformed-payload failures propagate. Only an
/// applicable successful decision may select a direct-play fallback.
/// Unusable successful playback metadata throws [PlaybackException].
///
/// Offline-file substitution is handled centrally in
/// `PlaybackInitializationService` — backends always produce online
@@ -630,7 +631,11 @@ abstract class MediaServerClient {
/// any external subtitle tracks that should be saved alongside it.
///
/// [mediaIndex] selects among multiple media versions when an item has them.
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0});
///
/// A successful applicable response may contain no URL. Request,
/// cancellation, and malformed-payload failures throw rather than returning
/// a partial resolution.
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
/// The artwork files the download pipeline should persist for [item] so
/// the offline UI can render its poster, clear logo, and background art.
@@ -641,8 +646,9 @@ abstract class MediaServerClient {
/// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse,
/// MX Player, etc.) can fetch directly. Plex builds this from the chosen
/// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
/// endpoint with `Static=true` so transcoding is bypassed. Returns null
/// when the backend can't resolve a playable URL for the item.
/// endpoint with `Static=true` so transcoding is bypassed. Returns null only
/// when a successful response has no playable URL for the item. Request,
/// cancellation, and malformed-payload failures throw.
///
/// Deliberately separate from the in-app playback funnel
/// (`PlaybackSourceResolver`): external players can't send custom headers,
@@ -726,8 +732,9 @@ mixin MediaServerCacheMixin implements MediaServerClient {
required T? Function(MediaServerResponse response) parseResponse,
bool cacheResponse = true,
}) async {
final cacheScope = ServerId(cacheServerId);
if (isOfflineMode) {
final cached = await cache.get(ServerId(cacheServerId), cacheKey);
final cached = await cache.get(cacheScope, cacheKey);
if (cached != null) return parseCache(cached);
return null;
}
@@ -735,12 +742,12 @@ mixin MediaServerCacheMixin implements MediaServerClient {
final response = await networkCall();
throwIfHttpError(response);
if (cacheResponse) {
await _putCacheResponse(cacheKey, response.data);
await _putCacheResponse(cacheScope, cacheKey, response.data);
}
return parseResponse(response);
} catch (e) {
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
final cached = await cache.get(ServerId(cacheServerId), cacheKey);
final cached = await cache.get(cacheScope, cacheKey);
if (cached != null) return parseCache(cached);
rethrow;
}
@@ -750,27 +757,33 @@ mixin MediaServerCacheMixin implements MediaServerClient {
/// only on miss. Use when freshness is non-critical and prior fetches are
/// likely to have populated the cache (e.g. playback after the detail
/// screen pre-warmed the row).
///
/// [cacheScope] must be captured from the same request context as
/// [networkCall]. The cache lookup may yield before a miss is known, so
/// sampling a live profile inside [networkCall] can cross profile identities.
Future<T?> fetchWithCacheFirst<T>({
required ServerId cacheScope,
required String cacheKey,
required Future<MediaServerResponse> Function() networkCall,
required T? Function(dynamic cachedData) parseCache,
required T? Function(MediaServerResponse response) parseResponse,
bool cacheResponse = true,
}) async {
final cached = await cache.get(ServerId(cacheServerId), cacheKey);
final cached = await cache.get(cacheScope, cacheKey);
if (cached != null) return parseCache(cached);
if (isOfflineMode) return null;
final response = await networkCall();
throwIfHttpError(response);
if (cacheResponse) {
await _putCacheResponse(cacheKey, response.data);
await _putCacheResponse(cacheScope, cacheKey, response.data);
}
return parseResponse(response);
}
Future<void> _putCacheResponse(String cacheKey, dynamic data) async {
Future<void> _putCacheResponse(ServerId cacheScope, String cacheKey, dynamic data) async {
try {
if (data is Map<String, dynamic>) {
await cache.put(ServerId(cacheServerId), cacheKey, data);
await cache.put(cacheScope, cacheKey, data);
} else if (data != null) {
appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}');
}
+3 -3
View File
@@ -10,9 +10,9 @@ class MediaSourceInfo {
final int? partId;
final MediaDisplayCriteria? displayCriteria;
/// Jellyfin source id for the *selected* version (null on Plex). Lets the
/// trickplay loader request the right tile sheet when an item has multiple
/// `MediaSources`.
/// Backend-opaque source id for the selected version. Plex uses the
/// authoritative `MediaVersion.id`; Jellyfin uses the selected
/// `MediaSources` id.
final String? mediaSourceId;
/// Jellyfin default stream indexes for this source. A subtitle index of -1
+20 -12
View File
@@ -114,25 +114,33 @@ class MediaVersion {
String get _codecPart => (videoCodec ?? '').toLowerCase();
/// Find the best matching version index from a set of accepted signatures.
/// Tier 1: exact match. Tier 2: resolution+codec. Tier 3: resolution only.
/// Returns null if no accepted signature matches.
///
/// Matching runs globally by tier: exact signature, resolution+codec, then
/// resolution only. Within a tier, accepted-signature iteration order wins
/// first, followed by candidate-list order. Malformed signatures are skipped.
static int? findMatchingIndex(List<MediaVersion> versions, Set<String> acceptedSignatures) {
if (versions.isEmpty || acceptedSignatures.isEmpty) return null;
for (final sig in acceptedSignatures) {
final parts = sig.split(':');
final accepted = <({String signature, String resolution, String codec})>[];
for (final signature in acceptedSignatures) {
final parts = signature.split(':');
if (parts.length != 3) continue;
final targetRes = parts.first;
final targetCodec = parts[1];
accepted.add((signature: signature, resolution: parts[0], codec: parts[1]));
}
for (int i = 0; i < versions.length; i++) {
if (versions[i].signature == sig) return i;
for (final target in accepted) {
for (var i = 0; i < versions.length; i++) {
if (versions[i].signature == target.signature) return i;
}
for (int i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i;
}
for (final target in accepted) {
for (var i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == target.resolution && versions[i]._codecPart == target.codec) return i;
}
for (int i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == targetRes) return i;
}
for (final target in accepted) {
for (var i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == target.resolution) return i;
}
}
+3 -2
View File
@@ -35,11 +35,12 @@ String liveTvChannelScopeKey(LiveTvChannel channel) =>
List<LiveTvChannel> filterLiveTvChannelsForFavorites({
required List<LiveTvChannel> channels,
required bool favoritesOnly,
required bool favoritesLoaded,
required Iterable<FavoriteChannel> favorites,
required String Function(LiveTvChannel channel) sourceForChannel,
}) {
if (!favoritesOnly || favorites.isEmpty) return channels;
if (!favoritesOnly || !favoritesLoaded) return channels;
if (favorites.isEmpty) return const [];
final channelMap = {
for (final channel in channels) favoriteChannelKey(sourceForChannel(channel), channel.key): channel,
};
+12 -1
View File
@@ -30,6 +30,7 @@ import '../services/music/music_playback_service.dart';
import '../services/music/music_playback_service_impl.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/storage_service.dart';
import '../services/system_shelf_service.dart';
import '../utils/app_logger.dart';
import '../watch_together/providers/watch_together_provider.dart';
import '../widgets/music/mini_player.dart';
@@ -101,13 +102,22 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
/// doing it from inside MainScreen can't work, the remount unmounts it
/// before any settle-await completes.
void _onSessionProfileChanged(String? activeId) {
final shelf = SystemShelfService();
if (!_seenFirstActiveId) {
_seenFirstActiveId = true;
_lastSessionActiveId = activeId;
if (activeId != null) shelf.beginProfileSession(activeId);
return;
}
if (_lastSessionActiveId == activeId) return;
final oldOwner = _lastSessionActiveId;
if (oldOwner == activeId) return;
if (oldOwner != null) {
// endProfileSession invalidates synchronously and queues its clear before
// the new owner is admitted below.
unawaited(shelf.endProfileSession(oldOwner));
}
_lastSessionActiveId = activeId;
if (activeId != null) shelf.beginProfileSession(activeId);
unawaited(ApiCache.clearRegisteredVolatile());
}
@@ -225,6 +235,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
context.read<HiddenLibrariesProvider>(),
context.read<LibrariesProvider>(),
isProfileBinding: () => activeProfile.isBinding,
profileId: activeId,
);
},
),
+24 -9
View File
@@ -645,7 +645,12 @@ class ActiveProfileBinder {
// profile token. A partial pass stays on the splash and awaits the
// per-server tokens from plex.tv instead of reporting shared servers
// offline with a token that cannot authenticate to them.
optimistic = await _bindOptimisticallyFromCache(account: account, userToken: token, profileLabel: profileLabel);
optimistic = await _bindOptimisticallyFromCache(
account: account,
userToken: token,
profileId: profileId,
profileLabel: profileLabel,
);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
final cachedServerIds = account.servers.map((server) => server.clientIdentifier).toSet();
if (optimistic != null && setEquals(optimistic.visibleServerIds, cachedServerIds)) {
@@ -673,7 +678,7 @@ class ActiveProfileBinder {
'$profileLabel (${servers.length} servers)',
);
unawaited(_persistRefreshedServers(account, servers));
final result = await _connectFromServers(account, token, servers, profileLabel);
final result = await _connectFromServers(account, token, servers, profileLabel, profileId: profileId);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
await markUsed?.call();
return result;
@@ -687,7 +692,13 @@ class ActiveProfileBinder {
usingCachedToken = false;
continue;
}
final result = await _connectFromServers(account, token, const <PlexServer>[], profileLabel);
final result = await _connectFromServers(
account,
token,
const <PlexServer>[],
profileLabel,
profileId: profileId,
);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
await markUsed?.call();
return result;
@@ -726,6 +737,7 @@ class ActiveProfileBinder {
account,
token,
profileLabel,
profileId: profileId,
error: fetched.error,
stackTrace: fetched.stackTrace,
);
@@ -772,6 +784,7 @@ class ActiveProfileBinder {
PlexAccountConnection account,
String userToken,
String profileLabel, {
required String profileId,
Object? error,
StackTrace? stackTrace,
}) async {
@@ -783,22 +796,23 @@ class ActiveProfileBinder {
);
final servers = _cachedServersCompatibleWithUserToken(account, userToken, profileLabel);
if (servers.isEmpty) return const _ProfileBindResult.empty();
return _connectFromServers(account, userToken, servers, profileLabel);
return _connectFromServers(account, userToken, servers, profileLabel, profileId: profileId);
}
Future<_ProfileBindResult> _connectFromServers(
PlexAccountConnection account,
String userToken,
List<PlexServer> servers,
String profileLabel,
) async {
String profileLabel, {
required String profileId,
}) async {
if (servers.isEmpty) {
appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}');
return const _ProfileBindResult.empty();
}
final stopwatch = Stopwatch()..start();
final updatedConn = account.copyWith(servers: servers);
final boundIds = await serverManager.refreshTokensForProfile(updatedConn);
final boundIds = await serverManager.refreshTokensForProfile(updatedConn, profileId: profileId);
appLogger.i(
'ActiveProfileBinder: bound ${boundIds.length}/${servers.length} Plex servers for $profileLabel',
error: {'elapsedMs': stopwatch.elapsedMilliseconds},
@@ -888,6 +902,7 @@ class ActiveProfileBinder {
Future<_ProfileBindResult?> _bindOptimisticallyFromCache({
required PlexAccountConnection account,
required String userToken,
required String profileId,
required String profileLabel,
}) async {
if (account.servers.isEmpty) return null;
@@ -898,7 +913,7 @@ class ActiveProfileBinder {
'while resources refresh',
error: {'servers': cachedServers.length, 'totalServers': account.servers.length},
);
return _connectFromServers(account, userToken, cachedServers, profileLabel);
return _connectFromServers(account, userToken, cachedServers, profileLabel, profileId: profileId);
}
/// Apply the background resource refresh after an optimistic cached bind:
@@ -972,7 +987,7 @@ class ActiveProfileBinder {
// optimistic pass left offline. Newly-online expected servers are
// promoted into the visibility filter by MultiServerProvider when the
// status emission this triggers lands.
await serverManager.refreshTokensForProfile(account.copyWith(servers: fresh));
await serverManager.refreshTokensForProfile(account.copyWith(servers: fresh), profileId: profileId);
}().catchError((Object error, StackTrace stackTrace) {
appLogger.w(
'ActiveProfileBinder: background reconcile failed for $profileLabel',
+234 -22
View File
@@ -23,12 +23,19 @@ import 'profile_registry.dart';
/// local profiles first, then live home users; if neither matches we fall
/// back to the first profile in the merged list.
class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
ActiveProfileProvider({required this._registry, required this._plexHome, required this._connections, this._storage});
ActiveProfileProvider({
required this._registry,
required this._plexHome,
required this._connections,
this._storage,
this._activeProfileIdWriter,
});
final ProfileRegistry _registry;
final PlexHomeService _plexHome;
final ConnectionRegistry _connections;
StorageService? _storage;
final Future<void> Function(String profileId)? _activeProfileIdWriter;
Profile? _active;
List<Profile> _profiles = const [];
@@ -45,6 +52,11 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
bool _isBinding = false;
bool _lastBindingSucceeded = true;
final List<Completer<bool>> _bindingSettleWaiters = [];
Future<void>? _identityMutationQueue;
int _identityMutationGeneration = 0;
int _committedIdentityGeneration = 0;
int _pendingIdentityMutations = 0;
final Map<int, Completer<void>> _identityMutationReservations = {};
Profile? get active => _active;
String? get activeId => _active?.id;
@@ -52,6 +64,13 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
bool get hasMultipleProfiles => _profiles.length > 1;
bool get isInitialized => _initialized;
/// Monotonically identifies the last active-profile identity that
/// successfully committed. Failed and cancelled activation attempts do not
/// advance it.
int get committedIdentityGeneration => _committedIdentityGeneration;
int get identityMutationGeneration => _identityMutationGeneration;
/// True while [ActiveProfileBinder] is wiring servers/tokens for the
/// active profile. The picker reads this so it can stay open (and stay
/// behind any PIN dialog the binder pops) until binding settles.
@@ -209,6 +228,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
}
void _resolveActive() {
if (_pendingIdentityMutations > 0) return;
if (_profiles.isEmpty) {
_active = null;
return;
@@ -237,6 +257,25 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
_active = null;
}
/// Claims ownership for an identity change that must perform asynchronous
/// preparation before it can enter the serialized mutation queue.
///
/// The claim is synchronous so a newer user request can invalidate older
/// preparation immediately. Callers must always pair this with
/// [finishIdentityMutationRequest].
int beginIdentityMutationRequest() {
final generation = ++_identityMutationGeneration;
_identityMutationReservations[generation] = Completer<void>();
return generation;
}
bool isIdentityMutationRequestCurrent(int generation) => generation == _identityMutationGeneration;
void finishIdentityMutationRequest(int generation) {
final reservation = _identityMutationReservations.remove(generation);
if (reservation != null && !reservation.isCompleted) reservation.complete();
}
/// Activate [profile]. PIN-protected local profiles must supply a matching
/// PIN; for Plex Home profiles the binder enforces the PIN via
/// `/home/users/{uuid}/switch` after activation.
@@ -249,33 +288,198 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
}
final storage = _storage;
if (storage == null) return false;
await storage.setActiveProfileId(profile.id);
final now = DateTime.now();
await storage.markProfileUsed(profile.id, now);
final activated = profile.copyWith(lastUsedAt: now);
_active = activated;
_profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]);
safeNotifyListeners();
appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})');
if (profile.isLocal) {
// Local rows also bump the DB's lastUsedAt so the in-DB sortable column
// stays accurate — the in-memory mark above keeps the picker snappy.
unawaited(
_registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) {
appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s);
}),
);
return _serializeIdentityMutation<bool>((generation) async {
if (generation != _identityMutationGeneration) return false;
final now = DateTime.now();
await storage.markProfileUsed(profile.id, now);
if (generation != _identityMutationGeneration) return false;
final previousActiveProfileId = storage.getActiveProfileId();
try {
await _writeActiveProfileId(storage, profile.id);
} catch (_) {
await _restoreActiveProfileId(storage, previousActiveProfileId);
rethrow;
}
if (generation != _identityMutationGeneration) {
await _restoreActiveProfileId(storage, previousActiveProfileId);
return false;
}
final activated = profile.copyWith(lastUsedAt: now);
_active = activated;
_committedIdentityGeneration = generation;
_profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]);
safeNotifyListeners();
appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})');
if (profile.isLocal) {
// Local rows also bump the DB's lastUsedAt so the in-DB sortable column
// stays accurate — the in-memory mark above keeps the picker snappy.
unawaited(
_registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) {
appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s);
}),
);
}
return true;
});
}
/// Restore the profile that owned the current authenticated session when a
/// later activation fails. The caller must supply the profile captured
/// before that activation; no PIN prompt is repeated for the session that
/// was already unlocked.
Future<int?> restoreAfterFailedActivation(
Profile profile, {
required String expectedActiveId,
required int expectedCommittedGeneration,
int? requestGeneration,
}) async {
final storage = _storage;
if (storage == null) {
throw StateError('ActiveProfileProvider is not initialized');
}
return true;
var generation = requestGeneration;
while (_active?.id == expectedActiveId && _committedIdentityGeneration == expectedCommittedGeneration) {
if (generation != null && generation != _identityMutationGeneration) {
// A newer request may still be preparing before it enters the queue
// (for example, clearing its former shelf owner). Do not reclaim
// ownership until that request has finished. If it fails without
// committing, the genuinely current failed identity can still be
// restored on the next pass.
await _awaitIdentityWorkAfter(generation);
if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) {
return null;
}
generation = null;
}
late int attemptedGeneration;
Future<int?> restore(int ownedGeneration) {
attemptedGeneration = ownedGeneration;
return _restoreFailedActivation(
storage,
profile,
expectedActiveId,
expectedCommittedGeneration,
ownedGeneration,
);
}
final restoredGeneration = generation == null
? await _serializeIdentityMutation<int?>(restore)
: await _queueIdentityMutation<int?>(generation, restore);
if (restoredGeneration != null) return restoredGeneration;
if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) {
return null;
}
await _awaitIdentityWorkAfter(attemptedGeneration);
generation = null;
}
return null;
}
Future<int?> _restoreFailedActivation(
StorageService storage,
Profile profile,
String expectedActiveId,
int expectedCommittedGeneration,
int generation,
) async {
if (_active?.id != expectedActiveId ||
_committedIdentityGeneration != expectedCommittedGeneration ||
generation != _identityMutationGeneration) {
return null;
}
final previousActiveProfileId = storage.getActiveProfileId();
try {
await _writeActiveProfileId(storage, profile.id);
} catch (_) {
await _restoreActiveProfileId(storage, previousActiveProfileId);
rethrow;
}
if (generation != _identityMutationGeneration) {
await _restoreActiveProfileId(storage, previousActiveProfileId);
return null;
}
_committedIdentityGeneration = generation;
_active = profile;
_profiles = sortProfilesByLastUsed([
for (final candidate in _profiles) candidate.id == profile.id ? profile : candidate,
]);
safeNotifyListeners();
appLogger.i('ActiveProfileProvider: restored ${profile.displayName} (${profile.id}) after failed activation');
return generation;
}
/// Clear the selected profile in both storage and memory so the picker
/// can force an explicit choice on the next screen.
Future<void> clearActiveProfile() async {
final storage = _storage ??= await StorageService.getInstance();
await storage.clearActiveProfileId();
_active = null;
safeNotifyListeners();
final generation = beginIdentityMutationRequest();
try {
final storage = _storage ??= await StorageService.getInstance();
if (!isIdentityMutationRequestCurrent(generation)) return;
await _queueIdentityMutation<void>(generation, (ownedGeneration) async {
if (ownedGeneration != _identityMutationGeneration) return;
final previousActiveProfileId = storage.getActiveProfileId();
await storage.clearActiveProfileId();
if (ownedGeneration != _identityMutationGeneration) {
await _restoreActiveProfileId(storage, previousActiveProfileId);
return;
}
_committedIdentityGeneration = ownedGeneration;
_active = null;
safeNotifyListeners();
});
} finally {
finishIdentityMutationRequest(generation);
}
}
Future<void> _writeActiveProfileId(StorageService storage, String profileId) {
return _activeProfileIdWriter?.call(profileId) ?? storage.setActiveProfileId(profileId);
}
Future<void> _restoreActiveProfileId(StorageService storage, String? profileId) {
if (profileId == null) return storage.clearActiveProfileId();
return _writeActiveProfileId(storage, profileId);
}
Future<T> _serializeIdentityMutation<T>(Future<T> Function(int generation) mutation) {
final generation = ++_identityMutationGeneration;
return _queueIdentityMutation(generation, mutation);
}
Future<T> _queueIdentityMutation<T>(int generation, Future<T> Function(int generation) mutation) {
final previous = _identityMutationQueue;
_pendingIdentityMutations++;
final operation = () async {
if (previous != null) await previous;
try {
return await mutation(generation);
} finally {
_pendingIdentityMutations--;
}
}();
_identityMutationQueue = operation.then<void>((_) {}).catchError((Object _, StackTrace _) {});
return operation;
}
Future<void> _awaitIdentityWorkAfter(int generation) async {
while (true) {
final reservations = [
for (final entry in _identityMutationReservations.entries)
if (entry.key > generation) entry.value.future,
];
final queue = _identityMutationQueue;
if (reservations.isNotEmpty) await Future.wait(reservations);
if (queue != null) await queue;
final hasNewerReservation = _identityMutationReservations.keys.any((candidate) => candidate > generation);
if (!hasNewerReservation && identical(queue, _identityMutationQueue)) return;
}
}
@visibleForTesting
@@ -299,6 +503,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
if (!c.isCompleted) c.complete(_lastBindingSucceeded);
}
_bindingSettleWaiters.clear();
for (final reservation in _identityMutationReservations.values) {
if (!reservation.isCompleted) reservation.complete();
}
_identityMutationReservations.clear();
}
@override
@@ -309,6 +517,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
if (!c.isCompleted) c.complete(_lastBindingSucceeded);
}
_bindingSettleWaiters.clear();
for (final reservation in _identityMutationReservations.values) {
if (!reservation.isCompleted) reservation.complete();
}
_identityMutationReservations.clear();
_initializeFuture = null;
_localSub?.cancel();
_connSub?.cancel();
+250 -42
View File
@@ -40,6 +40,15 @@ class PlexHomeService {
Timer? _refreshTimer;
Future<void>? _startFuture;
bool _started = false;
final Map<String, int> _refreshGenerations = {};
final Map<String, Future<bool>> _activeRefreshes = {};
final Map<String, Future<void>> _commitBarriers = {};
final Map<String, String> _durablyCommittedCacheJson = {};
final Set<String> _knownConnectionIds = {};
int _lifecycleEpoch = 0;
bool _disposed = false;
bool _storageCacheNeedsReload = false;
bool _clearing = false;
/// Snapshot of the current cache (immutable view).
Map<String, List<PlexHomeUser>> get current => Map.unmodifiable(_byConnection);
@@ -66,11 +75,12 @@ class PlexHomeService {
}
Future<void> start() {
if (_started) return Future.value();
if (_disposed || _started) return Future.value();
final pending = _startFuture;
if (pending != null) return pending;
final future = _start().catchError((Object error, StackTrace stackTrace) {
final epoch = _lifecycleEpoch;
final future = _start(epoch).catchError((Object error, StackTrace stackTrace) {
_startFuture = null;
Error.throwWithStackTrace(error, stackTrace);
});
@@ -85,22 +95,34 @@ class PlexHomeService {
/// copied `plex_home_users_{connectionId}` cache and new connection row.
Future<void> reloadFromStorage() async {
await start();
final epoch = _lifecycleEpoch;
if (!_isLifecycleCurrent(epoch)) return;
_storage ??= await StorageService.getInstance();
await _reloadStorageCacheIfNeeded(_storage!);
if (!_isLifecycleCurrent(epoch)) return;
final current = await _connections.list();
if (!_isLifecycleCurrent(epoch)) return;
final plexIds = current.whereType<PlexAccountConnection>().map((c) => c.id).toSet();
var changed = false;
for (final id in _byConnection.keys.toList()) {
if (!plexIds.contains(id)) {
_byConnection.remove(id);
_durablyCommittedCacheJson.remove(id);
changed = true;
}
}
for (final conn in current.whereType<PlexAccountConnection>()) {
final cached = _readCache(conn.id);
if (cached == null) continue;
if (!_isLifecycleCurrent(epoch)) return;
final raw = _storage!.getPlexHomeUsersCacheJson(conn.id);
final cached = _decodeCache(conn.id, raw);
if (cached == null || raw == null) {
_durablyCommittedCacheJson.remove(conn.id);
continue;
}
_durablyCommittedCacheJson[conn.id] = raw;
final previous = _byConnection[conn.id];
if (previous != null && encodePlexHomeUsersCacheJson(previous) == encodePlexHomeUsersCacheJson(cached)) {
continue;
@@ -109,19 +131,31 @@ class PlexHomeService {
changed = true;
}
if (changed) _emit();
if (changed && _isLifecycleCurrent(epoch)) _emit();
}
Future<void> _start() async {
Future<void> _start(int epoch) async {
_storage ??= await StorageService.getInstance();
if (!_isLifecycleCurrent(epoch)) return;
await _reloadStorageCacheIfNeeded(_storage!);
final initial = await _connections.list();
for (final conn in initial.whereType<PlexAccountConnection>()) {
final cached = _readCache(conn.id);
if (cached != null) _byConnection[conn.id] = cached;
if (!_isLifecycleCurrent(epoch)) return;
final plexConnections = initial.whereType<PlexAccountConnection>().toList();
_knownConnectionIds
..clear()
..addAll(plexConnections.map((connection) => connection.id));
for (final conn in plexConnections) {
final raw = _storage!.getPlexHomeUsersCacheJson(conn.id);
final cached = _decodeCache(conn.id, raw);
if (cached != null && raw != null) {
_byConnection[conn.id] = cached;
_durablyCommittedCacheJson[conn.id] = raw;
}
}
_emit();
if (!_isLifecycleCurrent(epoch)) return;
_connSub = _connections.watchConnections().listen(_onChange);
_refreshTimer = Timer.periodic(_refreshInterval, (_) => unawaited(_refreshAll()));
@@ -131,42 +165,67 @@ class PlexHomeService {
}
Future<void> _onChange(List<Connection> current) async {
final epoch = _lifecycleEpoch;
if (!_isLifecycleCurrent(epoch)) return;
final storage = _storage;
if (storage == null) return;
final plexConns = current.whereType<PlexAccountConnection>().toList();
final currentIds = plexConns.map((c) => c.id).toSet();
// Snapshot what's tracked *now*, before any await. Recomputing after
// the await loop would race a concurrent `_fetchAndCache` writing to
// `_byConnection` — newly-added accounts whose users that fetch was
// loading would appear "tracked" and the refresh below would skip them.
// the await loop would race a concurrent refresh writing to
// `_byConnection`.
final trackedBefore = _byConnection.keys.toSet();
final removed = trackedBefore.difference(currentIds);
final removed = _knownConnectionIds.difference(currentIds);
final toFetch = plexConns.where((c) => !trackedBefore.contains(c.id)).toList();
_knownConnectionIds
..clear()
..addAll(currentIds);
var changed = false;
for (final id in removed) {
_invalidateConnection(id);
await _waitForCommit(id);
if (!_isLifecycleCurrent(epoch)) return;
// A remove followed quickly by an upsert of the same id can arrive while
// the old refresh commit is settling. Re-check the registry before
// deleting cache state; the replacement event may have observed the
// still-populated in-memory slot and therefore have skipped its own
// refresh.
final replacement = await _connections.get(id);
if (!_isLifecycleCurrent(epoch)) return;
if (replacement is PlexAccountConnection) {
unawaited(_scheduleBackgroundRefresh(replacement));
continue;
}
_byConnection.remove(id);
_durablyCommittedCacheJson.remove(id);
await storage.clearPlexHomeUsersCache(id);
if (!_isLifecycleCurrent(epoch)) return;
// Also drop any join rows referencing the gone parent account —
// their cached `/switch` user-tokens become invalid the moment
// the parent account goes away, and the rows would otherwise
// linger as orphans.
await _profileConnections.removeAllForConnection(id);
if (!_isLifecycleCurrent(epoch)) return;
changed = true;
}
if (changed) _emit();
for (final conn in toFetch) {
unawaited(_fetchAndCache(conn));
if (!_isLifecycleCurrent(epoch)) return;
unawaited(_scheduleBackgroundRefresh(conn));
}
}
Future<void> _refreshAll() async {
final epoch = _lifecycleEpoch;
if (!_isLifecycleCurrent(epoch)) return;
final list = await _connections.list();
if (!_isLifecycleCurrent(epoch)) return;
for (final conn in list.whereType<PlexAccountConnection>()) {
unawaited(_fetchAndCache(conn));
unawaited(_scheduleBackgroundRefresh(conn));
}
}
@@ -174,17 +233,62 @@ class PlexHomeService {
/// Returns whether the fetch succeeded (callers that REQUIRE home users —
/// e.g. first sign-in, which can't build any profile without them — must
/// not conflate a failed fetch with "no users").
Future<bool> refresh(PlexAccountConnection conn) => _fetchAndCache(conn);
Future<bool> refresh(PlexAccountConnection conn) => _startRefresh(conn);
Future<bool> _fetchAndCache(PlexAccountConnection conn) async {
if (conn.accountToken.isEmpty) {
appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token');
return false;
}
final storage = _storage ?? await StorageService.getInstance();
_storage = storage;
Future<bool> _scheduleBackgroundRefresh(PlexAccountConnection conn) {
final active = _activeRefreshes[conn.id];
return active ?? _startRefresh(conn);
}
Future<bool> _startRefresh(PlexAccountConnection conn) {
if (_disposed || _clearing) return Future.value(false);
final generation = (_refreshGenerations[conn.id] ?? 0) + 1;
_refreshGenerations[conn.id] = generation;
final epoch = _lifecycleEpoch;
final completer = Completer<bool>();
final future = completer.future;
_activeRefreshes[conn.id] = future;
unawaited(_completeRefresh(conn, generation, epoch, future, completer));
return future;
}
Future<void> _completeRefresh(
PlexAccountConnection conn,
int generation,
int epoch,
Future<bool> owner,
Completer<bool> completer,
) async {
try {
completer.complete(await _fetchAndCache(conn, generation, epoch, owner));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
if (identical(_activeRefreshes[conn.id], owner)) {
final _ = _activeRefreshes.remove(conn.id);
}
}
}
Future<bool> _fetchAndCache(PlexAccountConnection conn, int generation, int epoch, Future<bool> owner) async {
bool isCurrent() =>
!_disposed &&
_lifecycleEpoch == epoch &&
_refreshGenerations[conn.id] == generation &&
identical(_activeRefreshes[conn.id], owner);
try {
if (!isCurrent()) return false;
if (conn.accountToken.isEmpty) {
appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token');
return false;
}
final storage = _storage ?? await StorageService.getInstance();
if (!isCurrent()) return false;
_storage = storage;
final users = await _fetchHomeUsers(conn.accountToken);
if (!isCurrent()) return false;
// The account may have been removed while the fetch was in flight —
// caching now would resurrect its home users (and virtual profiles)
// as ghosts until the next removal event.
@@ -192,30 +296,111 @@ class PlexHomeService {
appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}');
return false;
}
final encoded = encodePlexHomeUsersCache(users);
// Unchanged fetches (the hourly ticker, mostly) must not emit: every
// emission fans out through ActiveProfileProvider into a full
// recompute/notify cascade across the app.
if (_byConnection.containsKey(conn.id) &&
storage.getPlexHomeUsersCacheJson(conn.id) == encodePlexHomeUsersCacheJson(users)) {
if (!isCurrent()) return false;
// A SharedPreferences write becomes synchronously visible before its
// persistence future settles. Wait for that transaction (including any
// supersession rollback) before treating the visible value as committed.
final priorCommit = _commitBarriers[conn.id];
if (priorCommit != null) await priorCommit;
await _reloadStorageCacheIfNeeded(storage);
if (!isCurrent()) return false;
final encodedJson = encodePlexHomeUsersCacheJson(users);
final published = _byConnection[conn.id];
// A cache hit is valid only when this service observed the persistence
// future complete and published those exact users in memory. The
// SharedPreferences cache alone may contain an optimistic value from a
// failed platform write.
if (_durablyCommittedCacheJson[conn.id] == encodedJson &&
published != null &&
encodePlexHomeUsersCacheJson(published) == encodedJson &&
storage.getPlexHomeUsersCacheJson(conn.id) == encodedJson) {
appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}');
return true;
}
_byConnection[conn.id] = users;
await storage.savePlexHomeUsersCache(conn.id, encoded);
_emit();
appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}');
return true;
final previousCache = _readCache(conn.id);
final commit = Completer<void>();
final barrier = commit.future;
_commitBarriers[conn.id] = barrier;
try {
if (!isCurrent()) return false;
await _saveCache(storage, conn.id, users);
_durablyCommittedCacheJson[conn.id] = encodedJson;
final latestConnection = await _connections.get(conn.id);
final connectionUnchanged =
latestConnection is PlexAccountConnection && latestConnection.accountToken == conn.accountToken;
if (!isCurrent() || !connectionUnchanged) {
_durablyCommittedCacheJson.remove(conn.id);
if (previousCache == null) {
await storage.clearPlexHomeUsersCache(conn.id);
} else {
await _saveCache(storage, conn.id, previousCache);
_durablyCommittedCacheJson[conn.id] = encodePlexHomeUsersCacheJson(previousCache);
}
if (latestConnection is PlexAccountConnection && _isLifecycleCurrent(epoch)) {
unawaited(
Future<void>.delayed(Duration.zero, () {
if (_isLifecycleCurrent(epoch)) unawaited(_scheduleBackgroundRefresh(latestConnection));
}),
);
}
return false;
}
_byConnection[conn.id] = users;
if (!isCurrent()) return false;
_emit();
appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}');
return true;
} finally {
commit.complete();
if (identical(_commitBarriers[conn.id], barrier)) {
final _ = _commitBarriers.remove(conn.id);
}
}
} catch (e, st) {
appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: st);
return false;
}
}
List<PlexHomeUser>? _readCache(String connectionId) {
final storage = _storage;
if (storage == null) return null;
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
bool _isLifecycleCurrent(int epoch) => !_disposed && !_clearing && _lifecycleEpoch == epoch;
void _invalidateConnection(String connectionId) {
_refreshGenerations[connectionId] = (_refreshGenerations[connectionId] ?? 0) + 1;
_activeRefreshes.remove(connectionId);
}
Future<void> _waitForCommit(String connectionId) async {
final pending = _commitBarriers[connectionId];
if (pending != null) await pending;
}
Future<void> _saveCache(StorageService storage, String connectionId, List<PlexHomeUser> users) async {
try {
await storage.savePlexHomeUsersCache(connectionId, encodePlexHomeUsersCache(users));
} catch (_) {
_storageCacheNeedsReload = true;
try {
await _reloadStorageCacheIfNeeded(storage);
} catch (_) {
// A later refresh retries the durable reload before inspecting cache.
}
rethrow;
}
}
Future<void> _reloadStorageCacheIfNeeded(StorageService storage) async {
if (!_storageCacheNeedsReload) return;
await storage.prefs.reloadCache();
_storageCacheNeedsReload = false;
}
List<PlexHomeUser>? _readCache(String connectionId) =>
_decodeCache(connectionId, _storage?.getPlexHomeUsersCacheJson(connectionId));
List<PlexHomeUser>? _decodeCache(String connectionId, String? raw) {
if (raw == null) return null;
try {
return decodePlexHomeUsersCache(raw);
@@ -274,17 +459,40 @@ class PlexHomeService {
/// method only handles the user-list cache that's still in
/// [StorageService].
Future<void> clearAll() async {
_byConnection.clear();
final storage = _storage ?? await StorageService.getInstance();
await storage.clearAllPlexHomeUsersCache();
_emit();
if (_disposed || _clearing) return;
_clearing = true;
_lifecycleEpoch++;
_activeRefreshes.clear();
final epoch = _lifecycleEpoch;
try {
final pendingCommits = _commitBarriers.values.toList();
if (pendingCommits.isNotEmpty) await Future.wait(pendingCommits);
if (_disposed || _lifecycleEpoch != epoch) return;
_byConnection.clear();
_durablyCommittedCacheJson.clear();
final storage = _storage ?? await StorageService.getInstance();
if (_disposed || _lifecycleEpoch != epoch) return;
_storage = storage;
await storage.clearAllPlexHomeUsersCache();
if (_disposed || _lifecycleEpoch != epoch) return;
_emit();
} finally {
if (!_disposed && _lifecycleEpoch == epoch) _clearing = false;
}
}
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_clearing = false;
_lifecycleEpoch++;
_activeRefreshes.clear();
_refreshTimer?.cancel();
_refreshTimer = null;
await _connSub?.cancel();
_connSub = null;
final pendingCommits = _commitBarriers.values.toList();
if (pendingCommits.isNotEmpty) await Future.wait(pendingCommits);
_startFuture = null;
if (!_controller.isClosed) await _controller.close();
_started = false;
+147 -24
View File
@@ -6,6 +6,8 @@ import '../connection/connection_registry.dart';
import '../i18n/strings.g.dart';
import '../screens/profile/pin_entry_dialog.dart';
import '../utils/snackbar_helper.dart';
import '../utils/app_logger.dart';
import '../services/system_shelf_service.dart';
import 'active_profile_binder.dart';
import 'active_profile_provider.dart';
import 'plex_home_switch.dart';
@@ -16,6 +18,14 @@ import 'profile_connection_registry.dart';
/// of a PIN dialog) is not an error and must not surface a failure message.
enum ProfileActivationOutcome { activated, cancelled, failed }
class _ProfileActivationResult {
const _ProfileActivationResult(this.outcome, {this.rollbackProfile, this.activationGeneration});
final ProfileActivationOutcome outcome;
final Profile? rollbackProfile;
final int? activationGeneration;
}
/// Activate [profile] from a UI surface, prompting for the PIN when the
/// profile is protected. Loops on wrong-PIN entries until the user submits
/// the right PIN or backs out.
@@ -28,53 +38,118 @@ enum ProfileActivationOutcome { activated, cancelled, failed }
/// failed PIN never flips `_active`. The minted user-token is saved and
/// the profile is marked pre-verified on the binder, so it reuses the cached
/// token instead of re-prompting for the same PIN.
Future<ProfileActivationOutcome> activateProfileWithPin(BuildContext context, Profile profile) async {
final active = context.read<ActiveProfileProvider>();
final binder = context.read<ActiveProfileBinder>();
Future<_ProfileActivationResult> _activateProfileWithPin(BuildContext context, Profile profile) async {
if (profile.isPlexHome) {
if (profile.plexProtected) {
final verified = await _preVerifyPlexHomePin(context, profile);
if (!context.mounted) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
if (verified != PlexHomeSwitchStatus.success) {
return verified == PlexHomeSwitchStatus.cancelled
? ProfileActivationOutcome.cancelled
: ProfileActivationOutcome.failed;
return _ProfileActivationResult(
verified == PlexHomeSwitchStatus.cancelled
? ProfileActivationOutcome.cancelled
: ProfileActivationOutcome.failed,
);
}
}
binder.markUserInitiatedActivation(profile.id);
return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed;
return _activateVerifiedProfile(context, profile);
}
if (!profile.isPinProtected) {
binder.markUserInitiatedActivation(profile.id);
return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed;
return _activateVerifiedProfile(context, profile);
}
String? errorMessage;
while (true) {
if (!context.mounted) return ProfileActivationOutcome.cancelled;
if (!context.mounted) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
if (pin == null) return ProfileActivationOutcome.cancelled; // user backed out
if (!context.mounted) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
if (pin == null) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
final hash = profile.pinHash;
if (hash != null && verifyPin(pin, hash)) {
binder.markUserInitiatedActivation(profile.id);
return await active.activate(profile, pin: pin)
? ProfileActivationOutcome.activated
: ProfileActivationOutcome.failed;
return _activateVerifiedProfile(context, profile, pin: pin);
}
errorMessage = t.profiles.incorrectPinTryAgain;
}
}
Future<_ProfileActivationResult> _activateVerifiedProfile(BuildContext context, Profile profile, {String? pin}) async {
final active = context.read<ActiveProfileProvider>();
final binder = context.read<ActiveProfileBinder>();
final shelf = SystemShelfService();
final oldOwner = active.activeId;
final requestGeneration = active.beginIdentityMutationRequest();
int? activationGeneration;
try {
if (oldOwner != null && oldOwner != profile.id) {
await shelf.endProfileSession(oldOwner);
if (!active.isIdentityMutationRequestCurrent(requestGeneration)) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
}
// Capture the rollback owner only once this verified request has been
// admitted. Protected-profile verification may have awaited while another
// profile became authoritative, so anything captured by the UI caller is
// stale by this point.
final rollbackProfile = active.active;
binder.markUserInitiatedActivation(profile.id);
final activation = active.activate(profile, pin: pin);
activationGeneration = active.identityMutationGeneration;
final activated = await activation;
if (activated) {
return _ProfileActivationResult(
ProfileActivationOutcome.activated,
rollbackProfile: rollbackProfile,
activationGeneration: activationGeneration,
);
}
} catch (error, stackTrace) {
final stillCurrent = activationGeneration == null
? active.isIdentityMutationRequestCurrent(requestGeneration)
: active.identityMutationGeneration == activationGeneration;
if (stillCurrent) {
appLogger.w('Failed to activate profile ${profile.id}', error: error, stackTrace: stackTrace);
}
} finally {
active.finishIdentityMutationRequest(requestGeneration);
}
final stillCurrent = activationGeneration == null
? active.isIdentityMutationRequestCurrent(requestGeneration)
: active.identityMutationGeneration == activationGeneration;
if (!stillCurrent) {
return const _ProfileActivationResult(ProfileActivationOutcome.cancelled);
}
// Activation may fail or throw while the prior identity is still
// authoritative. Admit it again, but never replay rows captured before the
// failed switch.
if (oldOwner != null && active.activeId == oldOwner) {
shelf.beginProfileSession(oldOwner);
}
return const _ProfileActivationResult(ProfileActivationOutcome.failed);
}
/// Activate [profile] from a UI surface, then wait until the active profile's
/// server/token binding has settled. Shows the standard switch failure message
/// for activation and binding failures — but not for a PIN-dialog cancel,
/// which is the user changing their mind, not an error.
Future<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
final activeProvider = context.read<ActiveProfileProvider>();
final outcome = await activateProfileWithPin(context, profile);
final binder = context.read<ActiveProfileBinder>();
final shelf = SystemShelfService();
final activation = await _activateProfileWithPin(context, profile);
if (!context.mounted) return false;
switch (outcome) {
switch (activation.outcome) {
case ProfileActivationOutcome.cancelled:
return false;
case ProfileActivationOutcome.failed:
@@ -84,13 +159,61 @@ Future<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
break;
}
final previousProfile = activation.rollbackProfile;
var activationGeneration = activation.activationGeneration;
if (activationGeneration == null) return false;
bool isCurrentActivation(String expectedProfileId) =>
activeProvider.committedIdentityGeneration == activationGeneration &&
activeProvider.activeId == expectedProfileId;
if (!isCurrentActivation(profile.id)) return false;
final bound = await activeProvider.awaitBindingSettle();
if (!context.mounted) return false;
if (!bound) {
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName));
return false;
if (!isCurrentActivation(profile.id)) return false;
if (bound) return true;
if (previousProfile != null && previousProfile.id != profile.id && isCurrentActivation(profile.id)) {
final rollbackRequestGeneration = activeProvider.beginIdentityMutationRequest();
try {
await shelf.endProfileSession(profile.id);
if (!isCurrentActivation(profile.id)) return false;
final rollbackGeneration = await activeProvider.restoreAfterFailedActivation(
previousProfile,
expectedActiveId: profile.id,
expectedCommittedGeneration: activationGeneration,
requestGeneration: rollbackRequestGeneration,
);
if (rollbackGeneration == null) return false;
activationGeneration = rollbackGeneration;
if (!isCurrentActivation(previousProfile.id)) return false;
binder.markUserInitiatedActivation(previousProfile.id);
final rebind = binder.rebindActive();
final restored = await activeProvider.awaitBindingSettle();
if (!isCurrentActivation(previousProfile.id)) {
await rebind;
return false;
}
await rebind;
if (!isCurrentActivation(previousProfile.id)) return false;
if (restored) {
shelf.beginProfileSession(previousProfile.id);
}
} catch (error, stackTrace) {
appLogger.w(
'Failed to restore ${previousProfile.id} after profile switch failure',
error: error,
stackTrace: stackTrace,
);
} finally {
activeProvider.finishIdentityMutationRequest(rollbackRequestGeneration);
}
}
return true;
if (context.mounted && activeProvider.committedIdentityGeneration == activationGeneration) {
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName));
}
return false;
}
/// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On
+33 -15
View File
@@ -68,9 +68,9 @@ Future<void> removeAllProfileConnectionsAndCleanup({
}
}
/// Profile ids affected by a Plex account removal, so the caller can sweep
/// per-profile data (downloads, sync rules, queued watch actions) that this
/// layer doesn't own.
/// Profile ids affected by a Plex account removal. Planning is read-only so
/// callers can finish failure-prone cleanup before committing join/account
/// deletion.
typedef PlexAccountRemoval = ({
/// The account's virtual Plex Home profiles — they cease to exist.
Set<String> removedVirtualProfileIds,
@@ -80,20 +80,9 @@ typedef PlexAccountRemoval = ({
Set<String> borrowerProfileIds,
});
/// Sign out of a Plex account: remove the account [Connection], every join
/// row referencing it, and everything owned by its virtual Plex Home
/// profiles — including borrowed Jellyfin connections left unreferenced,
/// which previously survived as orphans and wedged the session (#1423).
///
/// All cleanup is explicit and completes before this returns; correctness
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
/// runs later and no-ops.
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
required PlexAccountConnection account,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
final rows = await profileConnections.listAll();
final removedVirtualProfileIds = <String>{
@@ -104,7 +93,36 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
for (final row in rows)
if (row.connectionId == account.id && !removedVirtualProfileIds.contains(row.profileId)) row.profileId,
};
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
}
/// Sign out of a Plex account: remove the account [Connection], every join
/// row referencing it, and everything owned by its virtual Plex Home
/// profiles — including borrowed Jellyfin connections left unreferenced,
/// which previously survived as orphans and wedged the session (#1423).
///
/// Pass a read-only [plannedRemoval] from
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
/// must finish before this destructive commit. Omitting it preserves the
/// atomic add/cancel-account cleanup path.
///
/// All cleanup is explicit and completes before this returns; correctness
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
/// runs later and no-ops.
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
required PlexAccountConnection account,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
PlexAccountRemoval? plannedRemoval,
}) async {
final removal =
plannedRemoval ??
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
final removedVirtualProfileIds = removal.removedVirtualProfileIds;
final borrowerProfileIds = removal.borrowerProfileIds;
final rows = await profileConnections.listAll();
// Remove direct join rows first so per-profile pref cleanup observes each
// row going away; the FK cascade from the connection delete is then a no-op.
for (final row in rows.where((r) => r.connectionId == account.id)) {
+75 -55
View File
@@ -67,29 +67,31 @@ class ProfileConnectionRegistry {
/// Fast path: when no default-flip is requested, skips the transaction
/// (one cheap SELECT to detect first-row, then a single insert).
Future<void> upsert(ProfileConnection pc, {bool makeDefault = false}) async {
final wantsDefault = makeDefault || pc.isDefault;
if (!wantsDefault) {
// Preserve the row's existing `isDefault` on update so token/metadata
// refreshes don't clobber the default flag. First-row inserts inherit
// default automatically.
final existing = await get(pc.profileId, pc.connectionId);
final bool isDefault;
if (existing != null) {
isDefault = existing.isDefault;
} else {
isDefault = !await _hasAnyForProfile(pc.profileId);
await _db.runIdentityMutation(() async {
final wantsDefault = makeDefault || pc.isDefault;
if (!wantsDefault) {
// Preserve the row's existing `isDefault` on update so token/metadata
// refreshes don't clobber the default flag. First-row inserts inherit
// default automatically.
final existing = await get(pc.profileId, pc.connectionId);
final bool isDefault;
if (existing != null) {
isDefault = existing.isDefault;
} else {
isDefault = !await _hasAnyForProfile(pc.profileId);
}
await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: isDefault));
appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId}');
return;
}
await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: isDefault));
appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId}');
return;
}
await _db.transaction(() async {
await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(pc.profileId))).write(
const ProfileConnectionsCompanion(isDefault: Value(false)),
);
await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: true));
await _db.transaction(() async {
await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(pc.profileId))).write(
const ProfileConnectionsCompanion(isDefault: Value(false)),
);
await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: true));
});
appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId} (default)');
});
appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId} (default)');
}
Future<bool> _hasAnyForProfile(String profileId) async {
@@ -121,36 +123,44 @@ class ProfileConnectionRegistry {
/// Cache the freshly-acquired user token (e.g. after a `/home/users/switch`
/// call). Updates `tokenAcquiredAt` to now.
Future<void> recordToken(String profileId, String connectionId, String token) async {
await (_db.update(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).write(
ProfileConnectionsCompanion(
userToken: Value(await CredentialVault.protect(token)),
tokenAcquiredAt: Value(DateTime.now().millisecondsSinceEpoch),
),
);
await _db.runIdentityMutation(() async {
await (_db.update(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).write(
ProfileConnectionsCompanion(
userToken: Value(await CredentialVault.protect(token)),
tokenAcquiredAt: Value(DateTime.now().millisecondsSinceEpoch),
),
);
});
}
/// Reset the stored token to the empty-string lazy-fetch sentinel (used
/// when the vault can no longer decrypt it).
Future<void> _clearToken(String profileId, String connectionId) async {
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(const ProfileConnectionsCompanion(userToken: Value(''), tokenAcquiredAt: Value(null)));
await _db.runIdentityMutation(() async {
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(const ProfileConnectionsCompanion(userToken: Value(''), tokenAcquiredAt: Value(null)));
});
}
/// Mark the row as recently used.
Future<void> markUsed(String profileId, String connectionId) async {
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(ProfileConnectionsCompanion(lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch)));
await _db.runIdentityMutation(() async {
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(ProfileConnectionsCompanion(lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch)));
});
}
Future<void> remove(String profileId, String connectionId) async {
await (_db.delete(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go();
await _promoteDefaultIfMissing(profileId);
await _db.runIdentityMutation(() async {
await (_db.delete(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go();
await _promoteDefaultIfMissing(profileId);
});
}
/// Re-promote a default for [profileId] when it has join rows but none is
@@ -171,22 +181,26 @@ class ProfileConnectionRegistry {
/// join rows silently when a Connection is removed, so a profile can be left
/// with surviving rows but no default flag.
Future<void> promoteMissingDefaults() async {
final profileIds = (await _db.select(_db.profileConnections).get()).map((r) => r.profileId).toSet();
for (final profileId in profileIds) {
await _promoteDefaultIfMissing(profileId);
}
await _db.runIdentityMutation(() async {
final profileIds = (await _db.select(_db.profileConnections).get()).map((r) => r.profileId).toSet();
for (final profileId in profileIds) {
await _promoteDefaultIfMissing(profileId);
}
});
}
/// Make [connectionId] the default for [profileId]. Clears the flag on
/// every other row for the same profile.
Future<void> setDefault(String profileId, String connectionId) async {
await _db.transaction(() async {
await (_db.update(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId))).write(const ProfileConnectionsCompanion(isDefault: Value(false)));
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(const ProfileConnectionsCompanion(isDefault: Value(true)));
await _db.runIdentityMutation(() async {
await _db.transaction(() async {
await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).write(
const ProfileConnectionsCompanion(isDefault: Value(false)),
);
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId)))
.write(const ProfileConnectionsCompanion(isDefault: Value(true)));
});
});
}
@@ -196,15 +210,21 @@ class ProfileConnectionRegistry {
/// stays the explicit path for callers that drop the rows first, and either
/// way repairs any profile the removal left without a default.
Future<int> removeAllForConnection(String connectionId) async {
final removed = await (_db.delete(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).go();
await promoteMissingDefaults();
return removed;
return _db.runIdentityMutation(() async {
final removed = await (_db.delete(
_db.profileConnections,
)..where((t) => t.connectionId.equals(connectionId))).go();
await promoteMissingDefaults();
return removed;
});
}
/// Wipe the entire join table. Used by sign-out so a fresh sign-in starts
/// with no stale (profile, connection, token) rows.
Future<void> clear() async {
await _db.delete(_db.profileConnections).go();
await _db.runIdentityMutation(() async {
await _db.delete(_db.profileConnections).go();
});
}
Future<ProfileConnection> _rowToModel(ProfileConnectionRow row) async {
@@ -217,7 +237,7 @@ class ProfileConnectionRegistry {
// Clear it to the empty-string lazy-fetch sentinel so the binder
// re-acquires a token on next use instead of re-failing every boot.
appLogger.w('ProfileConnectionRegistry: clearing undecryptable token for ${row.profileId}/${row.connectionId}');
unawaited(_clearToken(row.profileId, row.connectionId));
await _clearToken(row.profileId, row.connectionId);
}
return ProfileConnection(
profileId: row.profileId,
+27 -17
View File
@@ -42,39 +42,49 @@ class ProfileRegistry {
}
Future<void> upsert(Profile profile) async {
final row = ProfilesCompanion(
id: Value(profile.id),
kind: Value(profile.kind.id),
displayName: Value(profile.displayName),
avatarThumbUrl: Value(profile.avatarThumbUrl),
configJson: Value(jsonEncode(profile.toConfigJson())),
sortOrder: Value(profile.sortOrder),
createdAt: Value(profile.createdAt.millisecondsSinceEpoch),
lastUsedAt: Value(profile.lastUsedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.profiles).insertOnConflictUpdate(row);
await _db.runIdentityMutation(() async {
final row = ProfilesCompanion(
id: Value(profile.id),
kind: Value(profile.kind.id),
displayName: Value(profile.displayName),
avatarThumbUrl: Value(profile.avatarThumbUrl),
configJson: Value(jsonEncode(profile.toConfigJson())),
sortOrder: Value(profile.sortOrder),
createdAt: Value(profile.createdAt.millisecondsSinceEpoch),
lastUsedAt: Value(profile.lastUsedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.profiles).insertOnConflictUpdate(row);
});
appLogger.d('ProfileRegistry: upserted ${profile.kind.id}/${profile.id}');
}
Future<void> remove(String id) async {
await (_db.delete(_db.profiles)..where((t) => t.id.equals(id))).go();
await _db.runIdentityMutation(() async {
await (_db.delete(_db.profiles)..where((t) => t.id.equals(id))).go();
});
appLogger.d('ProfileRegistry: removed $id');
}
Future<void> markUsed(String id, DateTime at) async {
await (_db.update(
_db.profiles,
)..where((t) => t.id.equals(id))).write(ProfilesCompanion(lastUsedAt: Value(at.millisecondsSinceEpoch)));
await _db.runIdentityMutation(() async {
await (_db.update(
_db.profiles,
)..where((t) => t.id.equals(id))).write(ProfilesCompanion(lastUsedAt: Value(at.millisecondsSinceEpoch)));
});
}
/// One-shot cleanup: drop any `kind='plex_home'` rows left over from the
/// pre-refactor data model. Plex Home users are no longer persisted.
Future<int> dropAllPlexHomeRows() async {
return (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go();
return _db.runIdentityMutation(
() => (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go(),
);
}
Future<void> clear() async {
await _db.delete(_db.profiles).go();
await _db.runIdentityMutation(() async {
await _db.delete(_db.profiles).go();
});
}
Profile? _rowToProfile(ProfileRow row) {
+10 -3
View File
@@ -46,8 +46,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
this._multiServer,
this._hiddenLibraries,
this._libraries, {
required this.profileId,
required this.isProfileBinding,
Future<void> Function(List<MediaItem>)? syncSystemShelf,
Future<void> Function(String profileId, List<MediaItem>)? syncSystemShelf,
}) : _syncSystemShelfOverride = syncSystemShelf {
_loadCoordinator = CoalescedLoadCoordinator<String>(onFull: _loadOnce, onDelta: _loadDeltaOnce);
// Late server connects (reconnect after outage, slow wave) refresh
@@ -78,6 +79,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final MultiServerProvider _multiServer;
final HiddenLibrariesProvider _hiddenLibraries;
final LibrariesProvider _libraries;
final String? profileId;
/// Whether the profile binder is still wiring servers — a no-servers load
/// during binding stays in the loading state instead of flashing an error,
@@ -85,7 +87,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// instead of flashing the empty placeholder (main_screen primes another
/// load once binding settles).
final bool Function() isProfileBinding;
final Future<void> Function(List<MediaItem>)? _syncSystemShelfOverride;
final Future<void> Function(String profileId, List<MediaItem>)? _syncSystemShelfOverride;
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
StreamSubscription<DeletionEvent>? _deletionSubscription;
@@ -607,6 +609,8 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// follow-up pass with the latest items.
Future<void> _syncSystemShelf(List<MediaItem> onDeck) async {
if (isDisposed) return;
final owner = profileId;
if (owner == null) return;
_pendingSystemShelfItems = List<MediaItem>.unmodifiable(onDeck);
if (_systemShelfSyncFuture != null) {
await _systemShelfSyncFuture;
@@ -619,6 +623,8 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
Future<void> _drainSystemShelfSyncQueue() async {
final owner = profileId;
if (owner == null) return;
try {
while (_pendingSystemShelfItems != null) {
final onDeck = _pendingSystemShelfItems!;
@@ -628,7 +634,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
try {
final syncOverride = _syncSystemShelfOverride;
if (syncOverride != null) {
await syncOverride(onDeck);
await syncOverride(owner, onDeck);
continue;
}
final settings = await SettingsService.getInstance();
@@ -640,6 +646,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
})
.toList(growable: false);
await SystemShelfService().syncFromContinueWatching(
owner,
syncableOnDeck,
_clientForShelfItem,
hideSpoilers: settings.read(SettingsService.hideSpoilers),
+24 -41
View File
@@ -16,6 +16,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
final AppDatabase _database;
final WatchStateStore _watchStateStore = WatchStateStore();
late final StreamSubscription<WatchStateEvent> _watchStateSubscription;
Future<void> _watchStateWriteTail = Future<void>.value();
final Map<String, MediaItem> items = {};
final Map<String, DownloadedArtwork> artworkPaths = {};
@@ -73,11 +74,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
/// Rehydrates queued offline watch actions into the canonical hierarchy-aware
/// watch-state layer for the active profile.
Future<void> hydrateOfflineWatchOverlay({
required Map<String, DownloadProgress> downloads,
required bool Function(String globalKey) ownsDownloadKey,
bool Function()? isStale,
}) async {
Future<void> hydrateOfflineWatchOverlay({bool Function()? isStale}) async {
bool stale() => isStale?.call() ?? false;
try {
@@ -108,11 +105,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
if (parsed == null) continue;
var scope = scopesByServer[parsed.serverId];
if (!scopesByServer.containsKey(parsed.serverId)) {
scope = await _offlineWatchScopeForServer(
parsed.serverId,
downloads: downloads,
ownsDownloadKey: ownsDownloadKey,
);
scope = await _offlineWatchScopeForServer(parsed.serverId);
scopesByServer[parsed.serverId] = scope;
}
scopes[key] = scope;
@@ -158,21 +151,17 @@ class _DownloadMetadataStore extends ChangeNotifier {
}
}
Future<String?> _offlineWatchScopeForServer(
String serverId, {
required Map<String, DownloadProgress> downloads,
required bool Function(String globalKey) ownsDownloadKey,
}) async {
final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId));
if (activeScope != null && activeScope.isNotEmpty) return activeScope;
for (final globalKey in downloads.keys.toList(growable: false)) {
if (!ownsDownloadKey(globalKey)) continue;
final parsed = parseGlobalKey(globalKey);
if (parsed?.serverId != serverId) continue;
final downloadedScope = (await _database.getDownloadedMedia(globalKey))?.clientScopeId;
if (downloadedScope != null && downloadedScope.isNotEmpty) return downloadedScope;
}
return null;
Future<String?> _offlineWatchScopeForServer(String serverId) async {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return null;
return _downloadManager.profileClientScopeIdForServer(ServerId(serverId), profileId);
}
Future<void> waitForWatchStateWrites() async {
// The notifier's broadcast stream delivers asynchronously. Yield once so
// all events queued by the caller are represented in the write tail.
await Future<void>.delayed(Duration.zero);
await _watchStateWriteTail;
}
void _onWatchStateChanged(WatchStateEvent event) {
@@ -187,7 +176,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
_watchScopesByServer[event.serverId] = activeScope;
_watchStateStore.setActiveClientScopesByServer(_watchScopesByServer);
}
if (base == null) return;
if (base == null || activeScope == null || activeScope.isEmpty) return;
if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) {
return;
}
@@ -197,21 +186,15 @@ class _DownloadMetadataStore extends ChangeNotifier {
isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true);
if (!shouldPersistToCache) return;
unawaited(
() async {
if (base.backend == MediaBackend.plex &&
await _database.hasDownloadOwner(globalKey, excludingProfileId: _activeProfileId)) {
return;
}
await ApiCache.forBackend(base.backend).applyWatchState(
serverId: ServerId(event.cacheServerId ?? event.serverId),
itemId: event.itemId,
isWatched: isWatched,
);
}().catchError((Object error) {
appLogger.w('Failed to apply watch state to cache for $globalKey', error: error);
}),
);
_watchStateWriteTail = _watchStateWriteTail
.then(
(_) => ApiCache.forBackend(
base.backend,
).applyWatchState(serverId: ServerId(activeScope), itemId: event.itemId, isWatched: isWatched),
)
.catchError((Object error, StackTrace stackTrace) {
appLogger.w('Failed to apply watch state to cache for $globalKey', error: error, stackTrace: stackTrace);
});
}
@override
+249 -79
View File
@@ -33,6 +33,8 @@ import '../mixins/disposable_change_notifier_mixin.dart';
part 'download_metadata_store.dart';
typedef _QueueOwnership = ({String profileId, int generation});
/// Filter mode for batch downloads (shows/seasons).
/// Use [all] to download everything, or [unwatched] with an optional maxCount.
enum DownloadFilter { all, unwatched }
@@ -80,7 +82,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Map<String, DownloadedArtwork> get _artworkPaths => _metadataStore.artworkPaths;
// Track items currently being queued (building download queue)
final Set<String> _queueing = {};
final Map<String, _QueueOwnership> _queueing = {};
// Public download keys owned by the active profile. Physical download rows
// stay app-wide; this set controls profile-visible state.
@@ -97,6 +99,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
int _profileGeneration = 0;
Future<void>? _profileScopedReloadFuture;
_QueueOwnership _captureQueueOwnership() => (profileId: _requireActiveProfileId(), generation: _profileGeneration);
bool _isQueueOwnershipCurrent(_QueueOwnership ownership) =>
_activeProfileId == ownership.profileId && _profileGeneration == ownership.generation;
OfflineModeSource? _offlineSource;
DownloadProvider({required this._downloadManager, required this._database})
@@ -141,13 +148,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Ensures persisted downloads have been loaded from disk.
Future<void> ensureInitialized() => _initFuture;
Future<void> setDownloadLocation({required String path, required String pathType}) {
return _downloadManager.setDownloadLocation(path: path, pathType: pathType);
}
Future<void> resetDownloadLocation() {
return _downloadManager.resetDownloadLocation();
}
/// Switch the visible sync-rule scope to [profileId]. Physical downloads are
/// intentionally not reloaded because they are shared across profiles.
void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return;
_profileGeneration++;
_queueing.clear();
_ownedDownloadKeys.clear();
_syncRules.clear();
_metadata.clear();
_activeProfileId = profileId;
_metadataStore.setActiveProfileId(profileId);
_profileGeneration++;
safeNotifyListeners();
final reload = _reloadProfileScopedStateForActiveProfile();
_profileScopedReloadFuture = reload;
unawaited(reload);
@@ -159,6 +179,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await _initFuture;
if (_activeProfileId != targetProfileId || _profileGeneration != targetGeneration) return;
await _loadProfileScopedState();
await refreshMetadataFromCache();
await _applyOfflineWatchOverlay(expectedProfileGeneration: targetGeneration);
if (_activeProfileId == targetProfileId && _profileGeneration == targetGeneration) {
safeNotifyListeners();
@@ -180,19 +201,53 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Claim [globalKey] for an explicit [profileId] — sync rules claim for
/// the RULE'S owner, not whoever is active when the pass lands, so a
/// mid-run profile switch can't leak ownership across profiles.
Future<bool> _claimDownloadForProfile(String globalKey, String profileId) async {
if (_activeProfileId == profileId && _ownedDownloadKeys.contains(globalKey)) return false;
await _database.addDownloadOwner(profileId: profileId, globalKey: globalKey);
// _ownedDownloadKeys mirrors only the active profile's rows.
if (_activeProfileId != profileId) return false;
Future<bool> _claimDownloadForProfile(String globalKey, _QueueOwnership ownership, MediaServerClient client) async {
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (_ownedDownloadKeys.contains(globalKey)) return false;
await _database.addDownloadOwner(
profileId: ownership.profileId,
globalKey: globalKey,
backendId: client.backend.id,
clientScopeId: client.cacheServerId,
);
if (!_isQueueOwnershipCurrent(ownership)) return false;
_ownedDownloadKeys.add(globalKey);
return true;
}
Future<bool> _releaseDownloadForActiveProfile(String globalKey) async {
final profileId = _requireActiveProfileId();
if (!_ownedDownloadKeys.contains(globalKey)) return false;
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
Future<bool> _releaseDownloadForProfile(
String globalKey,
String profileId, {
bool onlyIfShared = false,
DownloadOwnerItem? ownerHint,
}) async {
DownloadOwnerItem? owner = ownerHint;
if (onlyIfShared) {
// Capture the departing cache namespace before the atomic database
// release; the ownership row is gone by the time cache cleanup runs.
owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
final result = await _database.removeSharedDownloadOwnerAndRebindIncompleteMedia(
profileId: profileId,
globalKey: globalKey,
);
if (!result.hasRemainingOwner) return false;
owner = result.removedOwner ?? owner;
} else {
owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
}
final parsed = parseGlobalKey(globalKey);
if (parsed != null && owner != null) {
await _downloadManager.deleteMetadataForOwner(
globalKey: globalKey,
serverId: parsed.serverId,
itemId: parsed.ratingKey,
profileId: profileId,
backendId: owner.backend,
clientScopeId: owner.clientScopeId,
);
}
if (_activeProfileId == profileId) {
_ownedDownloadKeys.remove(globalKey);
}
@@ -217,6 +272,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_profileGeneration++;
await _initFuture;
await _profileScopedReloadFuture;
await _downloadManager.preparePlexMetadataForLogoutTransfer();
await _database.clearAllDownloadOwners();
_ownedDownloadKeys.clear();
_syncRules.clear();
@@ -241,16 +297,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
for (final globalKey in ownedKeys) {
if (!shouldRelease(globalKey)) continue;
final meta = _metadata[globalKey];
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
if (_activeProfileId == profileId) {
_ownedDownloadKeys.remove(globalKey);
}
if (await _database.hasDownloadOwner(globalKey)) {
final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (releasedAsShared) {
changed = true;
continue;
}
// Keep the final durable owner until physical deletion succeeds. A
// retry can then resume cleanup without orphaning the shared row.
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.deleteDownload(globalKey);
await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
@@ -283,7 +340,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (downloads != null) _downloads.addAll(downloads);
if (metadata != null) _metadata.addAll(metadata);
if (artwork != null) _artworkPaths.addAll(artwork);
if (queueing != null) _queueing.addAll(queueing);
if (queueing != null) {
final ownership = _captureQueueOwnership();
for (final globalKey in queueing) {
_queueing[globalKey] = ownership;
}
}
if (deletionProgress != null) _deletionProgress.addAll(deletionProgress);
if (ownedDownloadKeys != null) {
_ownedDownloadKeys.addAll(ownedDownloadKeys);
@@ -295,6 +357,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
@visibleForTesting
Future<void> debugHydrateOfflineWatchOverlay() => _applyOfflineWatchOverlay();
@visibleForTesting
Future<void> debugWaitForProfileScopedReload() async {
await _profileScopedReloadFuture;
}
@visibleForTesting
Future<void> debugWaitForWatchStateWrites() => _metadataStore.waitForWatchStateWrites();
/// Load all persisted downloads and metadata from the database/cache
Future<void> _loadPersistedDownloads() async {
try {
@@ -309,6 +379,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_queueing.clear();
_deletionProgress.clear();
_ownedDownloadKeys.clear();
await _loadDownloadOwners();
final storageService = DownloadStorageService.instance;
@@ -320,7 +391,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// Bulk-load all pinned metadata across both backends in a single pass
// instead of per-item DB calls.
final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true);
final allMetadata = await _downloadManager.getAllPinnedMetadata(
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
for (final item in downloads) {
_downloads[item.globalKey] = DownloadProgress(
@@ -334,11 +408,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath);
await _hydrateDownloadMetadata(item.globalKey, allMetadata, downloadRecord: item);
if (_ownsDownloadKey(item.globalKey)) {
await _hydrateDownloadMetadata(item.globalKey, allMetadata);
}
}
// Load sync rules from database
await _loadProfileScopedState();
await _loadSyncRules();
// Apply queued offline watch actions on top of the server-time metadata
// we just loaded, so re-entries reflect locally-marked watched/unwatched
@@ -359,8 +435,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// hierarchy-aware watch-state layer.
Future<void> _applyOfflineWatchOverlay({int? expectedProfileGeneration}) {
return _metadataStore.hydrateOfflineWatchOverlay(
downloads: _downloads,
ownsDownloadKey: _ownsDownloadKey,
isStale: expectedProfileGeneration == null
? null
: () => isDisposed || expectedProfileGeneration != _profileGeneration,
@@ -377,39 +451,45 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<_MetadataHydrationResult> _hydrateDownloadMetadata(
String globalKey,
Map<String, MediaItem> allMetadata, {
DownloadedMediaItem? downloadRecord,
bool fetchOnMiss = false,
bool Function()? isStale,
}) async {
final parsed = parseGlobalKey(globalKey);
if (parsed == null) return (metadata: null, networkFilled: false, stale: false);
var record = downloadRecord;
if (record == null) {
record = await _downloadManager.getDownloadedMedia(globalKey);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
}
var cached =
allMetadata[globalKey] ??
await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
await _downloadManager.lookupMetadata(
parsed.serverId,
parsed.ratingKey,
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
var networkFilled = false;
if (cached == null && fetchOnMiss && _downloads.containsKey(globalKey)) {
cached = await _downloadManager.fetchAndPinMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
cached = await _downloadManager.fetchAndPinMetadata(
parsed.serverId,
parsed.ratingKey,
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
networkFilled = cached != null;
}
if (cached == null) {
// Parent rows can be shared by multiple downloaded siblings. A missing
// leaf invalidates only that leaf; profile changes clear the whole store.
_metadata.remove(globalKey);
}
if (cached != null) {
_metadata[globalKey] = cached;
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,
clientScopeId: _downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? record?.clientScopeId,
);
final clientScopeId = await _downloadManager.profileClientScopeIdForServer(parsed.serverId, _activeProfileId);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
_loadParentMetadataFromMap(cached, allMetadata, clientScopeId: clientScopeId);
}
}
return (metadata: cached, networkFilled: networkFilled, stale: false);
@@ -816,7 +896,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
/// Check if an item is currently being queued (building download queue)
bool isQueueing(String globalKey) => _queueing.contains(globalKey);
bool isQueueing(String globalKey) => _queueing.containsKey(globalKey);
/// Get the completed download record for an item, or null when the item
/// isn't fully downloaded or isn't owned by the active profile. Callers use
@@ -902,9 +982,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}) async {
if (!_downloadManager.downloadsSupported) return 0;
final ownership = _captureQueueOwnership();
final globalKey = metadata.globalKey;
final config = versionConfig ?? DownloadVersionConfig();
if (!_queueing.add(globalKey)) return 0;
if (_queueing.containsKey(globalKey)) return 0;
_queueing[globalKey] = ownership;
safeNotifyListeners();
try {
@@ -913,18 +995,30 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
if (metadata.isMovie || metadata.isEpisode || metadata.kind == MediaKind.track) {
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
final queued = await _queueSingleDownload(
metadata,
client,
ownership: ownership,
mediaIndex: config.mediaIndex,
);
return queued ? 1 : 0;
} else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) {
return await _withStashedMetadata(metadata, () => _queueMusicContainerDownload(metadata, client));
return await _withStashedMetadata(
metadata,
ownership,
() => _queueMusicContainerDownload(metadata, client, ownership),
);
} else if (metadata.isShow || metadata.isSeason) {
return await _withStashedMetadata(
metadata,
ownership,
() => _expandAndQueue(
container: metadata,
client: client,
ownership: ownership,
versionConfig: config,
filter: filter,
maxCount: maxCount,
@@ -936,22 +1030,33 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
throw Exception('Cannot download ${metadata.kind.id}');
}
} finally {
_queueing.remove(globalKey);
safeNotifyListeners();
if (_queueing[globalKey] == ownership) {
_queueing.remove(globalKey);
safeNotifyListeners();
}
}
}
Future<T> _withStashedMetadata<T>(MediaItem metadata, Future<T> Function() operation) async {
Future<T> _withStashedMetadata<T>(
MediaItem metadata,
_QueueOwnership ownership,
Future<T> Function() operation,
) async {
if (!_isQueueOwnershipCurrent(ownership)) {
throw StateError('Queue ownership is stale');
}
final globalKey = metadata.globalKey;
final previous = _metadata[globalKey];
_metadata[globalKey] = metadata;
try {
return await operation();
} catch (_) {
if (previous == null) {
_metadata.remove(globalKey);
} else {
_metadata[globalKey] = previous;
if (_isQueueOwnershipCurrent(ownership)) {
if (previous == null) {
_metadata.remove(globalKey);
} else {
_metadata[globalKey] = previous;
}
}
rethrow;
}
@@ -971,9 +1076,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}) async {
if (!_downloadManager.downloadsSupported) return 0;
final ownership = _captureQueueOwnership();
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
final unwatchedOnly = filter == DownloadFilter.unwatched;
final relatedContext = _RelatedMetadataDownloadContext();
@@ -981,11 +1088,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> queueItem(MediaItem item) async {
if (unwatchedOnly && !item.isUnwatchedOrInProgress) return;
final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext);
final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext);
if (queued) count++;
}
for (final item in items) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) {
await queueItem(item);
} else if (item.isShow || item.isSeason) {
@@ -993,15 +1101,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// One-shot recursive expansion for both shows and seasons.
final episodes = <MediaItem>[];
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final ep in episodes) {
await queueItem(ep);
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) {
if (!expandShows) continue;
// Same one-shot expansion for music containers (album/artist →
// tracks) via the shared recursive-leaves call.
for (final track in await client.fetchPlayableDescendants(item.id)) {
final tracks = await client.fetchPlayableDescendants(item.id);
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final track in tracks) {
await queueItem(_ensureServerId(track, item.serverId));
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else {
// Skip clips, nested collections/playlists, unknown types.
@@ -1016,14 +1129,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<bool> _queueSingleDownload(
MediaItem metadata,
MediaServerClient client, {
required _QueueOwnership ownership,
int mediaIndex = 0,
DownloadVersionConfig? versionConfig,
_RelatedMetadataDownloadContext? relatedContext,
String? claimForProfileId,
}) async {
if (!_downloadManager.downloadsSupported) return false;
final ownerProfileId = claimForProfileId ?? _requireActiveProfileId();
if (!_isQueueOwnershipCurrent(ownership)) return false;
var metadataToStore = metadata.serverId == null ? metadata.copyWith(serverId: client.serverId) : metadata;
final globalKey = metadataToStore.globalKey;
@@ -1035,7 +1148,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
existing.status == DownloadStatus.completed ||
existing.status == DownloadStatus.queued ||
existing.status == DownloadStatus.paused) {
final claimed = await _claimDownloadForProfile(globalKey, ownerProfileId);
try {
await _downloadManager.saveMetadata(metadataToStore, client);
} catch (e) {
// Claiming an already-present physical download must also work
// offline. Cache enrichment is best effort; ownership is durable.
appLogger.w('Failed to pin metadata while claiming $globalKey', error: e);
}
if (!_isQueueOwnershipCurrent(ownership)) return false;
final claimed = await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (claimed) safeNotifyListeners();
return claimed;
}
@@ -1065,6 +1187,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e);
}
}
if (!_isQueueOwnershipCurrent(ownership)) return false;
// Smart version matching for series/season downloads
var resolvedIndex = mediaIndex;
@@ -1076,8 +1199,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
resolvedIndex = matchedIndex;
} else if (versionConfig.onVersionMismatch != null) {
final pickedIndex = await versionConfig.onVersionMismatch!(metadataToStore, versions);
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (pickedIndex == null) return false;
resolvedIndex = pickedIndex;
if (!_isQueueOwnershipCurrent(ownership)) return false;
versionConfig.acceptedSignatures.add(versions[pickedIndex].signature);
}
}
@@ -1089,20 +1214,25 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await _fetchAndStoreParentMetadata(
metadataToStore,
client,
ownership: ownership,
context: relatedContext ?? _RelatedMetadataDownloadContext(),
);
if (!_isQueueOwnershipCurrent(ownership)) return false;
}
// Store full metadata for display
if (!_isQueueOwnershipCurrent(ownership)) return false;
_metadata[globalKey] = metadataToStore;
await _claimDownloadForProfile(globalKey, ownerProfileId);
await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false;
// Update local state immediately for UI feedback
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
safeNotifyListeners();
// Actually trigger download via DownloadManagerService
if (!_isQueueOwnershipCurrent(ownership)) return false;
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
return true;
}
@@ -1113,6 +1243,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> _fetchAndStoreParentMetadata(
MediaItem leaf,
MediaServerClient client, {
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext context,
}) async {
final serverId = leaf.serverId;
@@ -1122,12 +1253,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
serverId: ServerId(serverId),
ratingKey: leaf.grandparentId,
client: client,
ownership: ownership,
context: context,
);
if (!_isQueueOwnershipCurrent(ownership)) return;
await _fetchAndStoreRelatedMetadata(
serverId: ServerId(serverId),
ratingKey: leaf.parentId,
client: client,
ownership: ownership,
context: context,
);
}
@@ -1137,9 +1271,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
required ServerId serverId,
required String? ratingKey,
required MediaServerClient client,
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext context,
}) async {
if (ratingKey == null) return;
if (ratingKey == null || !_isQueueOwnershipCurrent(ownership)) return;
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
MediaItem? metadata = _metadata[globalKey];
@@ -1156,15 +1291,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
appLogger.w('Failed to fetch metadata for $ratingKey', error: e);
}
}
if (metadata == null) return;
if (metadata == null || !_isQueueOwnershipCurrent(ownership)) return;
final withServer = metadata.copyWith(serverId: serverId);
_metadata[globalKey] = withServer;
if (!_isQueueOwnershipCurrent(ownership)) return;
await _downloadManager.saveMetadata(withServer, client);
if (!_isQueueOwnershipCurrent(ownership)) return;
final thumbPath = withServer.thumbPath;
if (fetchedFreshMetadata || context.ensuredArtworkKeys.add(globalKey)) {
if (!_isQueueOwnershipCurrent(ownership)) return;
await _downloadManager.downloadArtworkForMetadata(withServer, client);
if (!_isQueueOwnershipCurrent(ownership)) return;
}
_artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath);
}
@@ -1173,13 +1312,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on
/// both backends — Plex branches album→/children, Jellyfin retries
/// tag-only artists by album-artist credit.
Future<int> _queueMusicContainerDownload(MediaItem container, MediaServerClient client) async {
Future<int> _queueMusicContainerDownload(
MediaItem container,
MediaServerClient client,
_QueueOwnership ownership,
) async {
final tracks = await client.fetchPlayableDescendants(container.id);
if (!_isQueueOwnershipCurrent(ownership)) return 0;
final relatedContext = _RelatedMetadataDownloadContext();
int count = 0;
for (final track in tracks) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
final trackWithServer = _ensureServerId(track, container.serverId);
final queued = await _queueSingleDownload(trackWithServer, client, relatedContext: relatedContext);
final queued = await _queueSingleDownload(
trackWithServer,
client,
ownership: ownership,
relatedContext: relatedContext,
);
if (queued) count++;
}
return count;
@@ -1195,9 +1345,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (!metadata.isShow && !metadata.isSeason) {
throw Exception('queueMissingEpisodes only supports shows/seasons');
}
final ownership = _captureQueueOwnership();
final queued = await _expandAndQueue(
container: metadata,
client: client,
ownership: ownership,
versionConfig: versionConfig,
filter: DownloadFilter.all,
maxCount: null,
@@ -1215,6 +1367,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<int> _expandAndQueue({
required MediaItem container,
required MediaServerClient client,
required _QueueOwnership ownership,
required DownloadVersionConfig? versionConfig,
required DownloadFilter filter,
required int? maxCount,
@@ -1236,9 +1389,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
fallback: container,
includeSpecials: effectiveIncludeSpecials,
);
if (!_isQueueOwnershipCurrent(ownership)) return 0;
int count = 0;
for (final episode in episodes) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
if (maxCount != null && count >= maxCount) break;
final episodeWithServer = _ensureServerId(episode, container.serverId);
@@ -1257,6 +1412,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final queued = await _queueSingleDownload(
episodeWithServer,
client,
ownership: ownership,
versionConfig: versionConfig,
relatedContext: relatedContext,
);
@@ -1298,12 +1454,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (!_ownsDownloadKey(globalKey)) return;
final progress = _downloads[globalKey];
if (progress != null) {
final released = await _releaseDownloadForActiveProfile(globalKey);
final hasOtherOwners = await _database.hasDownloadOwner(globalKey);
final profileId = _requireActiveProfileId();
final removedMeta = _metadata[globalKey];
if (!hasOtherOwners) {
await _downloadManager.cancelDownload(globalKey);
await _database.deleteDownload(globalKey);
var released = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (!released) {
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.cancelAndRemoveDownload(globalKey);
released = await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
@@ -1330,17 +1487,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
if (!_ownsDownloadKey(globalKey)) return;
final released = await _releaseDownloadForActiveProfile(globalKey);
final hasOtherOwners = await _database.hasDownloadOwner(globalKey);
if (hasOtherOwners) {
final profileId = _requireActiveProfileId();
final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (releasedAsShared) {
if (notify && meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
if (notify && released) safeNotifyListeners();
if (notify) safeNotifyListeners();
return;
}
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.deleteDownload(globalKey);
await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
@@ -1437,10 +1596,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// initial `_loadPersistedDownloads` may have raced with connection setup
// (Jellyfin's cache reads need a [Connections] row) and skipped entries;
// this lets a later refresh actually populate them.
final keys = <String>{..._metadata.keys, ..._downloads.keys};
if (keys.isEmpty) return;
final keys = <String>{..._downloads.keys.where(_ownsDownloadKey)};
if (keys.isEmpty) {
await _applyOfflineWatchOverlay(expectedProfileGeneration: profileGeneration);
return;
}
final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true);
final allMetadata = await _downloadManager.getAllPinnedMetadata(
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale()) return;
int cacheHits = 0;
int networkFills = 0;
@@ -1480,8 +1645,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Auto-delete downloaded episodes/movies that are now marked as watched.
///
/// Only deletes individual episodes and movies, never show/season containers.
/// [activeId] is excluded from deletion to protect the currently playing item.
Future<List<String>> autoDeleteWatchedDownloads({String? activeId}) async {
/// [activeGlobalKey] is excluded from deletion to protect the currently playing item.
Future<List<String>> autoDeleteWatchedDownloads({String? activeGlobalKey}) async {
final deletedTitles = <String>[];
final completedKeys = _downloads.entries
@@ -1496,7 +1661,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (!meta.isWatched) continue;
// Don't delete the episode that's currently playing
if (activeId != null && meta.id == activeId) continue;
if (activeGlobalKey != null && meta.globalKey == activeGlobalKey) continue;
try {
appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)');
@@ -1659,6 +1824,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return [];
final ownership = _captureQueueOwnership();
if (_syncRules.isEmpty) return [];
final relatedContext = _RelatedMetadataDownloadContext();
@@ -1671,13 +1837,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// A profile switch mid-pass must not keep queueing the old
// profile's rules; whatever does get queued is claimed for the
// rule's owner, never the new active profile.
if (_activeProfileId != profileId) return false;
if (!_isQueueOwnershipCurrent(ownership)) return false;
return _queueSingleDownload(
episode,
client,
ownership: ownership,
mediaIndex: mediaIndex,
relatedContext: relatedContext,
claimForProfileId: profileId,
);
},
isOffline: _offlineSource?.isOffline ?? false,
@@ -1697,6 +1863,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return null;
final ownership = _captureQueueOwnership();
if (!_syncRules.containsKey(globalKey)) return null;
final relatedContext = _RelatedMetadataDownloadContext();
@@ -1707,13 +1874,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
downloads: downloads,
metadata: Map.unmodifiable(_metadata),
queueSingleDownload: (episode, client, {int mediaIndex = 0}) async {
if (_activeProfileId != profileId) return false;
if (!_isQueueOwnershipCurrent(ownership)) return false;
return _queueSingleDownload(
episode,
client,
ownership: ownership,
mediaIndex: mediaIndex,
relatedContext: relatedContext,
claimForProfileId: profileId,
);
},
isOffline: _offlineSource?.isOffline ?? false,
@@ -1742,14 +1909,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> _loadDownloadOwners() async {
try {
final profileId = _activeProfileId;
final generation = _profileGeneration;
if (profileId == null || profileId.isEmpty) {
_ownedDownloadKeys.clear();
return;
}
await _database.adoptLegacyDownloadsForProfile(profileId);
if (_activeProfileId != profileId) return;
bool isStillActive() => _activeProfileId == profileId && _profileGeneration == generation;
await _database.adoptLegacyDownloadsForProfile(profileId, isStillActive: isStillActive);
await _downloadManager.adoptTransferredPlexMetadataForProfile(profileId, isStillActive: isStillActive);
if (!isStillActive()) return;
final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId);
if (_activeProfileId != profileId) return;
if (!isStillActive()) return;
_ownedDownloadKeys
..clear()
..addAll(ownedKeys);
+66 -11
View File
@@ -21,10 +21,27 @@ import '../services/trackers/tracker_session.dart';
import '../services/trackers/tracker_username_enricher.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
typedef TrackerSessionConnectPipeline =
Future<bool> Function({
required String logLabel,
required Future<TrackerSession?> Function() authorize,
required Future<TrackerSession> Function(TrackerSession raw) enrich,
required Future<void> Function(TrackerSession enriched) save,
required void Function(TrackerSession enriched) assign,
});
/// Owns the active MAL / AniList / Simkl sessions for the currently-selected
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
/// sessions from their stores and pushes them to their trackers.
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
TrackersProvider() : this._(runConnectPipeline<TrackerSession>);
@visibleForTesting
TrackersProvider.forTesting({required TrackerSessionConnectPipeline connectPipeline}) : this._(connectPipeline);
TrackersProvider._(this._connectPipeline);
final TrackerSessionConnectPipeline _connectPipeline;
final MalAuthService _malAuth = MalAuthService();
final AnilistAuthService _anilistAuth = AnilistAuthService();
final SimklAuthService _simklAuth = SimklAuthService();
@@ -40,6 +57,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
int _profileBindingGeneration = 0;
TrackerService? _connecting;
Completer<void>? _cancelCompleter;
int _connectGeneration = 0;
// Bumped on every rebind so a late callback from a disposed client (e.g. an
// in-flight MAL token refresh that resolves after a profile switch) can't
@@ -81,11 +99,11 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Cancel an in-flight connect. Completing the completer both wakes the
/// blocking `Future.any` race and flips `isCompleted` for the next sync check.
void cancelConnect() {
final c = _cancelCompleter;
if (c != null && !c.isCompleted) c.complete();
_invalidateConnect();
}
Future<void> onActiveProfileChanged(String? newUserUuid) async {
_invalidateConnect();
// Drop any in-flight scrobble state and release the resolver (which
// holds a PlexClient + session cache) before binding to the new profile.
TrackerCoordinator.instance.cancelInFlight();
@@ -139,7 +157,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
},
);
Future<void> disconnectMal() => _clearAndRebind(_malStore, () {
Future<void> disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () {
_mal = null;
_rebindMal();
});
@@ -160,7 +178,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
},
);
Future<void> disconnectAnilist() => _clearAndRebind(_anilistStore, () {
Future<void> disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () {
_anilist = null;
_rebindAnilist();
});
@@ -181,7 +199,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
},
);
Future<void> disconnectSimkl() => _clearAndRebind(_simklStore, () {
Future<void> disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () {
_simkl = null;
_rebindSimkl();
});
@@ -194,18 +212,35 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
required TrackerAccountStore store,
required void Function(TrackerSession session) assign,
}) async {
if (_connecting != null || alreadyConnected) return false;
if (isDisposed || _connecting != null || alreadyConnected) return false;
final userUuid = _activeUserUuid;
final generation = ++_connectGeneration;
_connecting = service;
_cancelCompleter = Completer<void>();
safeNotifyListeners();
var assigned = false;
try {
return await runConnectPipeline<TrackerSession>(
final completed = await _connectPipeline(
logLabel: service.name,
authorize: authorize,
authorize: () async {
final session = await authorize();
return _isCurrentConnect(service, userUuid, generation) ? session : null;
},
enrich: enrich,
save: (s) => store.save(_activeUserUuid, s),
assign: assign,
save: (session) async {
if (!_isCurrentConnect(service, userUuid, generation)) return;
await store.save(userUuid, session);
},
assign: (session) {
if (!_isCurrentConnect(service, userUuid, generation)) return;
assign(session);
TrackerCoordinator.instance.invalidateResolverCache();
assigned = true;
},
);
return completed && assigned;
} finally {
final c = _cancelCompleter;
if (c != null && !c.isCompleted) c.complete();
@@ -215,7 +250,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
Future<void> _clearAndRebind(TrackerAccountStore store, void Function() clearAndRebind) async {
Future<void> _clearAndRebind(
TrackerService service,
TrackerAccountStore store,
void Function() clearAndRebind,
) async {
_invalidateConnect(service);
final userUuid = _activeUserUuid;
// `clearAndRebind` bumps the affected service's rebind generation, which is
// what stops an in-flight profile load from resurrecting the cleared
@@ -226,6 +266,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await store.clear(userUuid);
}
void _invalidateConnect([TrackerService? service]) {
if (service != null && _connecting != service) return;
++_connectGeneration;
final c = _cancelCompleter;
if (c != null && !c.isCompleted) c.complete();
}
bool _isCurrentConnect(TrackerService service, String userUuid, int generation) {
return !isDisposed && _connecting == service && userUuid == _activeUserUuid && generation == _connectGeneration;
}
bool _isCurrentProfileBinding(String userUuid, int generation) {
return !isDisposed && userUuid == _activeUserUuid && generation == _profileBindingGeneration;
}
@@ -266,6 +317,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
void _rebindMal() {
if (isDisposed) return;
final (boundUuid, isCurrent) = _beginRebind(_malRebind);
MalTracker.instance.rebindSession(
_mal,
@@ -282,6 +334,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
void _rebindAnilist() {
if (isDisposed) return;
final (boundUuid, isCurrent) = _beginRebind(_anilistRebind);
AnilistTracker.instance.rebindSession(
_anilist,
@@ -292,6 +345,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
void _rebindSimkl() {
if (isDisposed) return;
final (boundUuid, isCurrent) = _beginRebind(_simklRebind);
SimklTracker.instance.rebindSession(
_simkl,
@@ -315,6 +369,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
@override
void dispose() {
_invalidateConnect();
_malAuth.dispose();
_anilistAuth.dispose();
_simklAuth.dispose();
+18 -26
View File
@@ -33,7 +33,7 @@ import '../utils/app_logger.dart';
/// account-owner's token would silently return the *owner's* settings —
/// wrong defaults for kid profiles, parental restrictions, etc.
class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
UserProfileProvider({this._storageService});
UserProfileProvider({this._storageService, this._authService});
MediaServerUserProfile? _profileSettings;
bool _isInitialized = false;
@@ -209,17 +209,15 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi
return client is JellyfinClient ? client : null;
}
/// Resolve the *active Home user's* plex.tv token, in priority order:
/// 1. The [ProfileConnection]'s `userToken`. For Plex Home profiles
/// this is the parent connection's row (written by
/// `_bindPlexHome`); for local profiles bound to a Plex account
/// it's the default join row (`listForProfile` orders default
/// first).
/// 2. The parent / first plex account's token as a last resort —
/// wrong user identity, but at least keeps the call from
/// no-op'ing for fresh installs that haven't completed a bind yet.
/// Returns `null` only when the device has no Plex account at all
/// (Jellyfin-only setup) or no profile is active.
/// Resolve the Plex credential for the active profile without crossing
/// identity boundaries.
///
/// A Plex Home profile may use only the switched token stored on its exact
/// parent [ProfileConnection]. A missing or empty switched token returns
/// `null`; the parent account token represents a different user.
///
/// Local Plezy profiles keep their explicitly selected Plex account fallback
/// because that account is the identity selected by the local profile.
Future<String?> _resolveActivePlexUserToken({
({ProfileConnection profileConnection, Connection connection})? preferred,
}) async {
@@ -230,29 +228,23 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi
final profile = activeProfile.active;
if (profile == null) return null;
final plexAccounts = (await connections.list()).whereType<PlexAccountConnection>().toList();
if (plexAccounts.isEmpty) return null;
final connectionList = await connections.list();
final pcRegistry = _profileConnectionRegistry;
if (profile.kind == ProfileKind.plexHome) {
final parentId = profile.parentConnectionId;
final uuid = profile.plexHomeUserUuid;
if (parentId == null || uuid == null) return null;
if (pcRegistry != null) {
final pc = await pcRegistry.get(profile.id, parentId);
if (pc?.hasToken == true) return pc!.userToken;
if (!connectionList.whereType<PlexAccountConnection>().any((account) => account.id == parentId)) {
return null;
}
// Pre-bind fallback: the binder hasn't run yet (or it failed), so
// there's no user-scoped token. Return the parent account token —
// it'll fetch the *owner's* settings, but that's still better than
// no settings at all on first launch.
for (final acc in plexAccounts) {
if (acc.id == parentId) return acc.accountToken;
}
return null;
final pc = await pcRegistry?.get(profile.id, parentId);
return pc?.hasToken == true ? pc!.userToken : null;
}
final plexAccounts = connectionList.whereType<PlexAccountConnection>().toList();
if (plexAccounts.isEmpty) return null;
// Local profile — read the user-token off the default ProfileConnection
// (listForProfile orders default first). Each connection persists its
// own minted token, so this is already user-scoped.
+16 -12
View File
@@ -113,18 +113,14 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
}
_WatchStatePatchEntry? _entryFor(String globalKey) {
_WatchStatePatchEntry? scopedEntry;
final parsed = parseGlobalKey(globalKey);
if (parsed != null) {
final scoped = _activeClientScopesByServer[parsed.serverId];
if (scoped != null && scoped.isNotEmpty) {
scopedEntry = _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey));
return _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey)) ?? _exactEntryFor(globalKey);
}
}
final unscopedEntry = _exactEntryFor(globalKey);
if (scopedEntry == null) return unscopedEntry;
if (unscopedEntry == null) return scopedEntry;
return scopedEntry.isNewerThan(unscopedEntry) ? scopedEntry : unscopedEntry;
return _exactEntryFor(globalKey);
}
WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
@@ -205,14 +201,22 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
void _onWatchStateEvent(WatchStateEvent event) {
final snapshot = WatchStateResolver.fromEvent(event);
if (snapshot.isEmpty) return;
final patch = WatchStatePatch.fromSnapshot(snapshot);
final cacheServerId = event.cacheServerId;
final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId
? buildGlobalKey(ServerId(cacheServerId), event.itemId)
final activeScope = _activeClientScopesByServer[event.serverId];
final eventScope = event.cacheServerId;
if (activeScope != null &&
activeScope.isNotEmpty &&
eventScope != null &&
eventScope.isNotEmpty &&
eventScope != event.serverId &&
eventScope != activeScope) {
return;
}
final resolvedScope = activeScope != null && activeScope.isNotEmpty ? activeScope : eventScope;
final key = resolvedScope != null && resolvedScope.isNotEmpty && resolvedScope != event.serverId
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
: event.globalKey;
_patches[key] = _WatchStatePatchEntry(
patch,
WatchStatePatch.fromSnapshot(snapshot),
updatedAt: DateTime.now().millisecondsSinceEpoch,
sequence: ++_sequence,
isSessionEvent: true,
+5 -1
View File
@@ -44,6 +44,9 @@ class PlexPinAuthFlow extends StatefulWidget {
/// the user doesn't have to navigate to the QR button with the remote.
final bool autoStartQrOnTV;
/// Test seam for rendering the initial actions without platform services.
final bool initializeService;
/// Override the QR-vs-browser default before any user interaction. Useful
/// for callers that want to force one mode (the add-account screen
/// auto-starts QR on TV; the legacy login screen offers both).
@@ -62,6 +65,7 @@ class PlexPinAuthFlow extends StatefulWidget {
this.mobileQrSize = 200,
this.desktopQrSize = 300,
this.autoStartQrOnTV = true,
this.initializeService = true,
this.initialUseQr,
this.initialButtonsBuilder,
});
@@ -82,7 +86,7 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
void initState() {
super.initState();
_useQr = widget.initialUseQr ?? PlatformDetector.isTV();
unawaited(_initService());
if (widget.initializeService) unawaited(_initService());
}
Future<void> _initService() async {
+49 -10
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../connection/plex_account_setup.dart';
import '../database/app_database.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../profiles/active_profile_binder.dart';
import '../profiles/active_profile_provider.dart';
@@ -31,8 +32,16 @@ import 'profile/profile_switch_screen.dart';
import 'settings/add_jellyfin_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
const AuthScreen({
super.key,
this.initialErrorMessage,
this.initializeServices = true,
this.databaseRecoveryRequired = false,
});
final String? initialErrorMessage;
final bool initializeServices;
final bool databaseRecoveryRequired;
@override
State<AuthScreen> createState() => _AuthScreenState();
}
@@ -43,13 +52,16 @@ class _AuthScreenState extends State<AuthScreen> {
// Reuse a one-shot service for the debug-token verify path; the Plex
// PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance.
PlexAuthService? _verifyOnlyService;
Future<void>? _recoveryAcknowledgement;
bool _recoveryAcknowledged = false;
@override
void initState() {
super.initState();
_errorMessage = widget.initialErrorMessage;
// Debug-token verification only — release builds must not hold an idle
// auth service (and its HTTP client) for a dialog that can't open.
if (kDebugMode) unawaited(_initVerifyService());
if (kDebugMode && widget.initializeServices) unawaited(_initVerifyService());
}
Future<void> _initVerifyService() async {
@@ -87,11 +99,33 @@ class _AuthScreenState extends State<AuthScreen> {
await activeProfiles.activate(profile);
}
Future<bool> _prepareDatabaseRecoveryForSignIn() async {
if (!widget.databaseRecoveryRequired || _recoveryAcknowledged) return true;
final acknowledgement = _recoveryAcknowledgement ??= context
.read<AppDatabase>()
.acknowledgeTvosDatabaseRecoveryRequired();
try {
await acknowledgement;
_recoveryAcknowledged = true;
if (mounted) setState(() => _errorMessage = null);
return mounted;
} catch (_) {
_recoveryAcknowledgement = null;
if (mounted) setState(() => _errorMessage = t.auth.localDataRecoveryRequired);
return false;
}
}
Future<void> _startPlexAfterRecovery(VoidCallback start) async {
if (await _prepareDatabaseRecoveryForSignIn()) start();
}
/// Persist the new Plex account into the connection pipeline, resolve the
/// initial active profile when possible, and navigate to the main screen.
/// The top-level [ActiveProfileBinder] picks up the active profile id and
/// connects servers via [MultiServerManager.refreshTokensForProfile].
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
if (!await _prepareDatabaseRecoveryForSignIn()) return;
if (!mounted) return;
setState(() {
@@ -198,6 +232,8 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _connectToJellyfin() async {
if (!await _prepareDatabaseRecoveryForSignIn()) return;
if (!mounted) return;
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen()));
if (!mounted || added != true) return;
// The connection persisted and the manager registered the client; move
@@ -304,6 +340,7 @@ class _AuthScreenState extends State<AuthScreen> {
return PlexPinAuthFlow(
onTokenReceived: _connectToAllServersAndNavigate,
autoStartQrOnTV: false,
initializeService: widget.initializeServices,
initialButtonsBuilder: _buildInitialButtons,
);
}
@@ -311,6 +348,8 @@ class _AuthScreenState extends State<AuthScreen> {
Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) {
final isTV = PlatformDetector.isTV();
final isAppleTV = PlatformDetector.isAppleTV();
void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser));
void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr));
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
@@ -318,10 +357,10 @@ class _AuthScreenState extends State<AuthScreen> {
if (isTV) ...[
FocusableButton(
autofocus: true,
onPressed: busy ? null : startQr,
onPressed: busy ? null : startQrAfterRecovery,
useBackgroundFocus: true,
child: ElevatedButton(
onPressed: busy ? null : startQr,
onPressed: busy ? null : startQrAfterRecovery,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Row(
mainAxisAlignment: .center,
@@ -337,9 +376,9 @@ class _AuthScreenState extends State<AuthScreen> {
if (!isAppleTV) ...[
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startBrowser,
onPressed: busy ? null : startBrowserAfterRecovery,
child: OutlinedButton(
onPressed: busy ? null : startBrowser,
onPressed: busy ? null : startBrowserAfterRecovery,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.useBrowser),
),
@@ -347,10 +386,10 @@ class _AuthScreenState extends State<AuthScreen> {
],
] else ...[
FocusableButton(
onPressed: busy ? null : startBrowser,
onPressed: busy ? null : startBrowserAfterRecovery,
useBackgroundFocus: true,
child: ElevatedButton.icon(
onPressed: busy ? null : startBrowser,
onPressed: busy ? null : startBrowserAfterRecovery,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
@@ -358,9 +397,9 @@ class _AuthScreenState extends State<AuthScreen> {
),
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startQr,
onPressed: busy ? null : startQrAfterRecovery,
child: OutlinedButton(
onPressed: busy ? null : startQr,
onPressed: busy ? null : startQrAfterRecovery,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.showQRCode),
),
@@ -9,6 +9,7 @@ import '../media/media_server_client.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../services/media_list_playback_launcher.dart';
import '../services/jellyfin_sequential_launcher.dart';
import '../widgets/loading_indicator_box.dart';
import '../utils/app_logger.dart';
import '../utils/error_message_utils.dart';
@@ -95,7 +96,11 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
final item = mediaItem;
final launcher = MediaListPlaybackLauncher.forItem(context, item);
await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false);
await launcher.launchFromCollectionOrPlaylist(
item: item,
shuffle: shuffle,
showLoadingIndicator: launcher is JellyfinSequentialLauncher,
);
}
@override
@@ -536,10 +536,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
// values pre-cached and a hardcoded client-side sort list. Both flow
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
final client = context.getMediaClientForLibrary(widget.library);
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
try {
final client = context.getMediaClientForLibrary(widget.library);
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
final storage = await StorageService.getInstance();
final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
@@ -605,7 +605,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
void _loadJellyfinFiltersInBackground(int generation) {
final client = context.getMediaClientForLibrary(widget.library);
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
if (client == null) return;
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id, libraryKind: widget.library.kind)
@@ -1612,7 +1613,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Prefetch 2 rows beyond visible area
final prefetchEnd = visibleEnd + 2 * _scrollMetrics.columnCount;
final client = getMediaClientForLibrary();
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
if (client == null) return;
final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context);
final episodePosterMode = context.settingsRead(SettingsService.episodePosterMode);
+68 -40
View File
@@ -78,10 +78,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Future<void>? _favoritesLoadFuture;
final SerialFutureQueue _favoritesMutationQueue = SerialFutureQueue();
bool _favoritesLoaded = false;
bool _favoritesWritable = false;
List<LiveTvChannel> get _filteredChannels => filterLiveTvChannelsForFavorites(
channels: _channels,
favoritesOnly: _showFavoritesOnly,
favoritesLoaded: _favoritesLoaded,
favorites: _favoriteChannels,
sourceForChannel: _sourceForChannel,
);
@@ -434,55 +436,78 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Future<void> _loadFavorites(MultiServerProvider multiServer) async {
final loadGeneration = ++_favoritesLoadGeneration;
_favoritesLoaded = false;
try {
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer);
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
final merged = <FavoriteChannel>[];
final fetchedStores = <String>{};
final seenFavorites = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
final liveTv = client.liveTv;
_favoritesWritable = false;
final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource);
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer);
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
final merged = <FavoriteChannel>[];
final successfulStores = <String>{};
final failedStores = <String>{};
final seenFavorites = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
final liveTv = client.liveTv;
final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo);
storeByLiveServer[liveServerKey] = storeKey;
modeByStore[storeKey] = liveTv.favoritePersistenceMode;
try {
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo);
sourceByLiveServer[liveServerKey] = source;
storeByLiveServer[liveServerKey] = storeKey;
storeBySource[source] = storeKey;
modeByStore[storeKey] = liveTv.favoritePersistenceMode;
if (!fetchedStores.add(storeKey)) continue;
if (successfulStores.contains(storeKey)) continue;
final serverFavorites = await liveTv.fetchFavoriteChannels();
successfulStores.add(storeKey);
failedStores.remove(storeKey);
for (final favorite in serverFavorites) {
storeBySource[favorite.source] = storeKey;
if (seenFavorites.add(favorite.stableKey)) merged.add(favorite);
}
} catch (error, stackTrace) {
if (!successfulStores.contains(storeKey)) failedStores.add(storeKey);
appLogger.e('Failed to load favorite channels for $storeKey', error: error, stackTrace: stackTrace);
}
if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
setState(() {
_favoriteSourceByLiveServer
..clear()
..addAll(sourceByLiveServer);
_favoriteStoreByLiveServer
..clear()
..addAll(storeByLiveServer);
_favoriteStoreBySource
..clear()
..addAll(storeBySource);
_favoriteModeByStore
..clear()
..addAll(modeByStore);
_favoriteChannels = merged;
_refreshFavoriteKeys();
});
_favoritesLoaded = true;
appLogger.d('Live TV: loaded ${merged.length} favorite channels');
} catch (e) {
appLogger.e('Failed to load favorite channels', error: e);
}
// A failed store keeps its last committed in-memory slice. Healthy stores
// still refresh, but mutations stay disabled until every store has loaded
// so a later persist cannot replace the failed store with an empty list.
for (final favorite in _favoriteChannels) {
final storeKey = previousStoreBySource[favorite.source];
if (storeKey != null && failedStores.contains(storeKey) && seenFavorites.add(favorite.stableKey)) {
merged.add(favorite);
}
}
if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
setState(() {
_favoriteSourceByLiveServer
..clear()
..addAll(sourceByLiveServer);
_favoriteStoreByLiveServer
..clear()
..addAll(storeByLiveServer);
_favoriteStoreBySource
..clear()
..addAll(storeBySource);
_favoriteModeByStore
..clear()
..addAll(modeByStore);
_favoriteChannels = merged;
_refreshFavoriteKeys();
_favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty;
_favoritesWritable = failedStores.isEmpty;
});
appLogger.d(
'Live TV: loaded ${merged.length} favorite channels'
'${failedStores.isEmpty ? '' : ' (${failedStores.length} store(s) deferred)'}',
);
}
void _toggleFavoritesFilter() {
@@ -517,7 +542,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
.run(() async {
if (pendingLoad != null) await pendingLoad;
if (!mounted) return;
if (!_favoritesLoaded) {
if (!_favoritesWritable) {
showErrorSnackBar(context, t.liveTv.favoritesLoadFailed);
return;
}
@@ -526,6 +551,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
})
.catchError((Object error, StackTrace stackTrace) {
appLogger.e('Failed to mutate favorite channels', error: error, stackTrace: stackTrace);
if (mounted) {
showErrorSnackBar(context, t.liveTv.favoritesUpdateFailed);
}
}),
);
}
+53 -23
View File
@@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../models/plex/plex_home_user.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_avatar.dart';
@@ -19,9 +20,11 @@ import '../../profiles/profile_connection_registry.dart';
import '../../profiles/profile_registry.dart';
import '../../profiles/profiles_view.dart';
import '../../providers/download_provider.dart';
import '../../providers/discover_provider.dart';
import '../../providers/hidden_libraries_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../services/storage_service.dart';
import '../../services/system_shelf_service.dart';
import '../../utils/snackbar_helper.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/app_icon.dart';
@@ -168,32 +171,59 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
final pcRegistry = context.read<ProfileConnectionRegistry>();
final connRegistry = context.read<ConnectionRegistry>();
final storage = context.read<StorageService>();
final serverManager = context.read<MultiServerProvider>().serverManager;
final multiServer = context.read<MultiServerProvider>();
final hiddenLibraries = context.read<HiddenLibrariesProvider?>();
final discover = context.read<DiscoverProvider?>();
final binder = context.read<ActiveProfileBinder>();
final active = context.read<ActiveProfileProvider>();
final shelf = SystemShelfService();
final endedOwner = active.activeId == _profile.id ? _profile.id : null;
// Release downloads only for servers the profile actually loses — the
// same server can stay reachable through another connection (a second
// Plex account sharing the server, another Jellyfin user).
final retainedServerIds = await _retainedServerIds(
excludingConnectionId: conn.id,
profileConnections: pcRegistry,
connections: connRegistry,
);
await downloads.releaseDownloadsForProfileServers(
_profile.id,
_serverIdsForConnection(conn).difference(retainedServerIds),
);
await removeProfileConnectionAndCleanup(
profileId: _profile.id,
connection: conn,
profileConnections: pcRegistry,
connections: connRegistry,
storage: storage,
serverManager: serverManager,
);
await hiddenLibraries?.refresh();
unawaited(binder.rebindIfActive(_profile.id));
if (endedOwner != null) {
await shelf.endProfileSession(endedOwner);
}
try {
// Release downloads only for servers the profile actually loses — the
// same server can stay reachable through another connection (a second
// Plex account sharing the server, another Jellyfin user).
final retainedServerIds = await _retainedServerIds(
excludingConnectionId: conn.id,
profileConnections: pcRegistry,
connections: connRegistry,
);
await downloads.releaseDownloadsForProfileServers(
_profile.id,
_serverIdsForConnection(conn).difference(retainedServerIds),
);
await removeProfileConnectionAndCleanup(
profileId: _profile.id,
connection: conn,
profileConnections: pcRegistry,
connections: connRegistry,
storage: storage,
serverManager: multiServer.serverManager,
);
await hiddenLibraries?.refresh();
await binder.rebindIfActive(_profile.id);
if (endedOwner != null && active.activeId == endedOwner) {
shelf.beginProfileSession(endedOwner);
if (multiServer.hasConnectedServers) await discover?.load();
}
} catch (_) {
if (endedOwner != null && active.activeId == endedOwner) {
try {
await binder.rebindIfActive(endedOwner);
if (active.activeId == endedOwner) {
shelf.beginProfileSession(endedOwner);
if (multiServer.hasConnectedServers) await discover?.load();
}
} catch (_) {
// Keep the shelf empty when the surviving profile cannot be rebound.
}
}
rethrow;
}
}
/// Server ids the profile keeps after removing [excludingConnectionId]:
+104 -25
View File
@@ -15,6 +15,7 @@ import '../../profiles/profile_connection_registry.dart';
import '../../profiles/profile_registry.dart';
import '../../providers/companion_remote_provider.dart';
import '../../providers/download_provider.dart';
import '../../providers/discover_provider.dart';
import '../../providers/hidden_libraries_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../providers/playback_state_provider.dart';
@@ -22,6 +23,7 @@ import '../../providers/user_profile_provider.dart';
import '../../services/api_cache.dart';
import '../../services/multi_server_manager.dart';
import '../../services/storage_service.dart';
import '../../services/system_shelf_service.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/snackbar_helper.dart';
@@ -38,10 +40,12 @@ class SessionTeardownScope {
final ConnectionRegistry connections;
final MultiServerProvider multiServer;
final HiddenLibrariesProvider? hiddenLibraries;
final DiscoverProvider? discover;
final DownloadProvider downloads;
final AppDatabase database;
final StorageService storage;
final NavigatorState navigator;
final SystemShelfService shelf;
MultiServerManager get serverManager => multiServer.serverManager;
@@ -54,9 +58,11 @@ class SessionTeardownScope {
connections = context.read<ConnectionRegistry>(),
multiServer = context.read<MultiServerProvider>(),
hiddenLibraries = context.read<HiddenLibrariesProvider?>(),
discover = context.read<DiscoverProvider?>(),
downloads = context.read<DownloadProvider>(),
database = context.read<AppDatabase>(),
storage = context.read<StorageService>(),
shelf = SystemShelfService(),
navigator = Navigator.of(context, rootNavigator: true);
}
@@ -69,7 +75,11 @@ class SessionTeardownScope {
///
/// Returns true when it navigated to [AuthScreen] — the caller must stop
/// touching its own UI in that case.
Future<bool> settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindIfActiveKept = false}) async {
Future<bool> settleSessionAfterRemoval(
SessionTeardownScope scope, {
bool rebindIfActiveKept = false,
String? endedShelfOwner,
}) async {
final result = await resolvePostRemovalState(
profileRegistry: scope.profileRegistry,
profileConnections: scope.profileConnections,
@@ -81,7 +91,7 @@ Future<bool> settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI
if (result.route == PostRemovalRoute.signedOut) {
await scope.active.clearActiveProfile();
unawaited(scope.binder.rebindActive());
await scope.binder.rebindActive();
if (scope.navigator.mounted) {
unawaited(
scope.navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const AuthScreen()), (_) => false),
@@ -93,7 +103,13 @@ Future<bool> settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI
final activeId = scope.storage.getActiveProfileId();
final activeStillExists = activeId != null && result.profiles.any((p) => p.id == activeId);
if (activeStillExists) {
if (rebindIfActiveKept) unawaited(scope.binder.rebindActive());
if (rebindIfActiveKept) {
if (endedShelfOwner == activeId) {
await resumeFreshSystemShelf(scope, activeId);
} else {
await scope.binder.rebindActive();
}
}
} else {
// Auto-activation must not bypass PIN gates ([activate] rejects local
// PIN profiles without a pin; protected Plex Home profiles would PIN
@@ -112,13 +128,41 @@ Future<bool> settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI
final activated = next != null && await scope.active.activate(next);
if (!activated) {
await scope.active.clearActiveProfile();
unawaited(scope.binder.rebindActive());
await scope.binder.rebindActive();
}
}
await scope.hiddenLibraries?.refresh();
return false;
}
/// Rebinds a surviving owner before admitting fresh shelf publication.
///
/// A failed rebind deliberately leaves the owner invalidated and the native
/// shelf empty.
Future<void> resumeFreshSystemShelf(SessionTeardownScope scope, String profileId) async {
if (scope.active.activeId != profileId) return;
try {
await scope.binder.rebindIfActive(profileId);
if (scope.active.activeId != profileId) return;
scope.shelf.beginProfileSession(profileId);
if (scope.multiServer.hasConnectedServers) {
await scope.discover?.load();
}
} catch (error, stackTrace) {
appLogger.w('Failed to restore system shelf after profile teardown', error: error, stackTrace: stackTrace);
}
}
Future<String?> _activeProfileUsingConnection(SessionTeardownScope scope, String connectionId) async {
final active = scope.active.active;
if (active == null) return null;
final usesConnection =
active.parentConnectionId == connectionId ||
(await scope.profileConnections.listForProfile(active.id)).any((row) => row.connectionId == connectionId);
if (!usesConnection || scope.active.activeId != active.id) return null;
return active.id;
}
Future<bool> confirmAndDeleteProfile(
BuildContext context, {
required Profile profile,
@@ -146,22 +190,33 @@ Future<bool> confirmAndDeleteProfile(
/// connections), last-used marker, and user-scoped prefs.
Future<void> deleteProfile(BuildContext context, Profile profile) async {
final scope = SessionTeardownScope.of(context);
final endedOwner = scope.active.activeId == profile.id ? profile.id : null;
if (endedOwner != null) {
await scope.shelf.endProfileSession(endedOwner);
}
await scope.downloads.deleteDownloadsForProfile(profile.id);
await scope.database.deleteSyncRulesForProfile(profile.id);
await scope.database.deleteWatchActionsForProfile(profile.id);
await removeAllProfileConnectionsAndCleanup(
profileId: profile.id,
profileConnections: scope.profileConnections,
connections: scope.connections,
storage: scope.storage,
serverManager: scope.serverManager,
);
await scope.profileRegistry.remove(profile.id);
await scope.storage.clearProfileLastUsed(profile.id);
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
try {
await scope.downloads.deleteDownloadsForProfile(profile.id);
await scope.database.deleteSyncRulesForProfile(profile.id);
await scope.database.deleteWatchActionsForProfile(profile.id);
await removeAllProfileConnectionsAndCleanup(
profileId: profile.id,
profileConnections: scope.profileConnections,
connections: scope.connections,
storage: scope.storage,
serverManager: scope.serverManager,
);
await scope.profileRegistry.remove(profile.id);
await scope.storage.clearProfileLastUsed(profile.id);
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
await settleSessionAfterRemoval(scope);
await settleSessionAfterRemoval(scope, endedShelfOwner: endedOwner);
} catch (_) {
if (endedOwner != null) {
await resumeFreshSystemShelf(scope, endedOwner);
}
rethrow;
}
}
/// Sign out of a Plex account after confirmation: the account connection,
@@ -186,31 +241,50 @@ Future<bool> confirmAndSignOutPlexAccount(BuildContext context, {required String
if (!confirmed || !context.mounted) return false;
final scope = SessionTeardownScope.of(context);
String? endedOwner;
try {
final removal = await removePlexAccountConnectionAndCleanup(
final removal = await planPlexAccountConnectionRemoval(
account: account,
profileConnections: scope.profileConnections,
connections: scope.connections,
storage: scope.storage,
serverManager: scope.serverManager,
);
endedOwner = await _activeProfileUsingConnection(scope, accountConnectionId);
if (endedOwner != null) {
await scope.shelf.endProfileSession(endedOwner);
}
// Physical download cleanup can fail. Finish it while the account and
// every ownership join still exist so a retry can resolve the same plan
// instead of stranding files without an owner.
for (final profileId in removal.removedVirtualProfileIds) {
await scope.downloads.deleteDownloadsForProfile(profileId);
await scope.database.deleteSyncRulesForProfile(profileId);
await scope.database.deleteWatchActionsForProfile(profileId);
}
final accountServerIds = {for (final server in account.servers) server.clientIdentifier};
for (final profileId in removal.borrowerProfileIds) {
await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds);
}
final navigatedAway = await settleSessionAfterRemoval(scope, rebindIfActiveKept: true);
await removePlexAccountConnectionAndCleanup(
account: account,
profileConnections: scope.profileConnections,
connections: scope.connections,
storage: scope.storage,
serverManager: scope.serverManager,
plannedRemoval: removal,
);
for (final profileId in removal.removedVirtualProfileIds) {
await scope.database.deleteSyncRulesForProfile(profileId);
await scope.database.deleteWatchActionsForProfile(profileId);
}
final navigatedAway = await settleSessionAfterRemoval(scope, rebindIfActiveKept: true, endedShelfOwner: endedOwner);
if (!navigatedAway && context.mounted) {
showSuccessSnackBar(context, t.profiles.signedOutPlex);
}
return true;
} catch (e, st) {
if (endedOwner != null) {
await resumeFreshSystemShelf(scope, endedOwner);
}
appLogger.w('Plex sign-out failed for $accountConnectionId', error: e, stackTrace: st);
if (context.mounted) {
showErrorSnackBar(context, t.profiles.signOutFailed);
@@ -228,6 +302,11 @@ Future<void> logoutAllProfiles(BuildContext context) async {
final companionRemote = context.read<CompanionRemoteProvider>();
final playbackState = context.read<PlaybackStateProvider>();
final activeOwner = scope.active.activeId;
if (activeOwner != null) {
await scope.shelf.endProfileSession(activeOwner);
}
await companionRemote.resetForLogout();
await userProfileProvider.logout();
// Downloads are device-local data, not credentials. Keep their physical
+9 -13
View File
@@ -19,7 +19,6 @@ import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_connection.dart';
import '../../profiles/profile_registry.dart';
import '../../services/jellyfin_auth_service.dart';
import '../../services/jellyfin_endpoint_discovery.dart';
import '../../services/jellyfin_lan_discovery_service.dart';
@@ -352,14 +351,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
}
/// Shared persistence path for both username/password and Quick Connect:
/// upsert the connection, attach a ProfileConnection to the bound profile,
/// register with the live manager when binding to the active profile, and
/// pop with success.
/// atomically provision the optional first-run profile, connection, and
/// ownership row, then bind and pop only after durable success.
Future<void> _persistAndExit(JellyfinConnection connection) async {
if (!mounted) return;
// Bind to the target profile (caller's choice) or the active one. On a
// first-run Jellyfin-only sign-in there is no profile yet, so create and
// activate a local profile before registering the server.
final activeProvider = context.read<ActiveProfileProvider>();
await activeProvider.initialize();
if (!mounted) return;
@@ -381,29 +376,28 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
return;
}
}
Profile? firstRunProfile;
if (shouldCreateLocalJellyfinProfile(
targetProfile: targetProfile,
activeProfile: boundProfile,
hasProfiles: activeProvider.profiles.isNotEmpty,
)) {
final now = DateTime.now();
final profile = Profile.local(
firstRunProfile = Profile.local(
id: 'local-${const Uuid().v4()}',
displayName: connection.userName.isNotEmpty ? connection.userName : connection.serverName,
sortOrder: now.millisecondsSinceEpoch,
createdAt: now,
);
await context.read<ProfileRegistry>().upsert(profile);
await activeProvider.activate(profile);
if (!mounted) return;
boundProfile = activeProvider.active ?? profile;
boundProfile = firstRunProfile;
}
final bindProfile = boundProfile;
if (bindProfile == null) {
setErrorText(t.messages.noProfilesAvailable);
return;
}
final boundToActive = bindProfile.id == activeProvider.activeId;
await persistAndBindConnection(
context: context,
@@ -416,8 +410,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
tokenAcquiredAt: DateTime.now(),
),
addToManager: null,
firstRunProfile: firstRunProfile,
);
final boundToActive = bindProfile.id == activeProvider.activeId;
if (!mounted) return;
if (boundToActive) {
await context.read<ActiveProfileBinder>().rebindIfActive(bindProfile.id);
+114 -22
View File
@@ -1,49 +1,87 @@
import 'dart:async';
import '../../media/ids.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../../connection/connection.dart';
import '../../connection/connection_registry.dart';
import '../../database/app_database.dart';
import '../../media/ids.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_connection.dart';
import '../../profiles/profile_connection_registry.dart';
import '../../profiles/profile_registry.dart';
import '../../providers/libraries_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
/// Persist a freshly-authenticated [connection] and (optionally) wire it into
/// the active session.
/// Durably provision a freshly-authenticated [connection] and its optional
/// [bindToProfile] ownership row.
///
/// Steps, all guarded by `context.mounted`:
/// [firstRunProfile], [connection], and [bindToProfile] are committed in one
/// shared database transaction. The new profile is activated only after that
/// relational commit. If activation rejects or throws, the relational bundle
/// and the exact prior active-profile marker are restored before the original
/// error is rethrown.
///
/// 1. Upsert [connection] into [ConnectionRegistry] — always.
/// 2. If [bindToProfile] is non-null, upsert a [ProfileConnection] join row
/// so the target profile owns the connection on next activation.
/// 3. If [addToManager] is non-null, invoke it to register the runtime client
/// with [MultiServerProvider]. When the manager reports success and
/// [visibleServerId] is set, extend the visibility filter so the new
/// server shows up immediately. On success the helper kicks off
/// [LibrariesProvider.loadLibraries] (fire-and-forget).
///
/// Returns whether the manager accepted the connection — callers use this to
/// branch their follow-up navigation. The helper itself does not navigate.
/// All durable collaborators are captured before the first await, so a route
/// unmount cannot interrupt the command between artifacts. Runtime manager,
/// visibility, and library-loading effects remain post-commit and mounted
/// gated. The helper itself does not navigate.
Future<bool> persistAndBindConnection({
required BuildContext context,
required Connection connection,
required ProfileConnection? bindToProfile,
required Future<bool> Function()? addToManager,
Profile? firstRunProfile,
String? visibleServerId,
}) async {
// Snapshot the collaborators up front: persistence must complete even if
// the screen unmounts mid-await — a connection upserted without its join
// row is an orphan the profile never sees. Only the session-facing steps
// below stay gated on `mounted`.
final db = context.read<AppDatabase>();
final profiles = context.read<ProfileRegistry>();
final connections = context.read<ConnectionRegistry>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final activeProfiles = context.read<ActiveProfileProvider>();
final storage = context.read<StorageService>();
await connections.upsert(connection);
if (bindToProfile != null) {
await profileConnections.upsert(bindToProfile);
final priorActiveProfileId = storage.getActiveProfileId();
final priorConnection = await connections.get(connection.id);
await db.runIdentityMutation(
() => db.transaction(() async {
if (firstRunProfile != null) {
await profiles.upsert(firstRunProfile);
}
await connections.upsert(connection);
if (bindToProfile != null) {
await profileConnections.upsert(bindToProfile);
}
}),
);
if (firstRunProfile != null) {
try {
final activated = await activeProfiles.activate(firstRunProfile);
if (!activated) {
throw StateError('The first-run profile could not be activated');
}
} catch (error, stackTrace) {
await _compensateFailedActivation(
db: db,
profiles: profiles,
connections: connections,
profileConnections: profileConnections,
activeProfiles: activeProfiles,
storage: storage,
firstRunProfile: firstRunProfile,
bindToProfile: bindToProfile,
attemptedConnection: connection,
priorConnection: priorConnection,
priorActiveProfileId: priorActiveProfileId,
);
Error.throwWithStackTrace(error, stackTrace);
}
}
if (!context.mounted || addToManager == null) return false;
@@ -57,3 +95,57 @@ Future<bool> persistAndBindConnection({
unawaited(context.read<LibrariesProvider>().loadLibraries());
return true;
}
Future<void> _compensateFailedActivation({
required AppDatabase db,
required ProfileRegistry profiles,
required ConnectionRegistry connections,
required ProfileConnectionRegistry profileConnections,
required ActiveProfileProvider activeProfiles,
required StorageService storage,
required Profile firstRunProfile,
required ProfileConnection? bindToProfile,
required Connection attemptedConnection,
required Connection? priorConnection,
required String? priorActiveProfileId,
}) async {
try {
await db.runIdentityMutation(
() => db.transaction(() async {
if (bindToProfile != null) {
await profileConnections.remove(bindToProfile.profileId, bindToProfile.connectionId);
}
await profiles.remove(firstRunProfile.id);
if (priorConnection == null) {
await connections.remove(attemptedConnection.id);
} else {
await connections.upsert(priorConnection);
}
}),
);
} catch (error, stackTrace) {
appLogger.e('First-run relational compensation failed', error: error, stackTrace: stackTrace);
}
try {
await storage.clearProfileLastUsed(firstRunProfile.id);
} catch (error, stackTrace) {
appLogger.e('First-run recency compensation failed', error: error, stackTrace: stackTrace);
}
try {
if (priorActiveProfileId == null) {
await storage.clearActiveProfileId();
} else {
await storage.setActiveProfileId(priorActiveProfileId);
}
} catch (error, stackTrace) {
appLogger.e('First-run active marker compensation failed', error: error, stackTrace: stackTrace);
}
try {
await activeProfiles.reloadFromStorage();
} catch (error, stackTrace) {
appLogger.e('First-run active profile reload failed', error: error, stackTrace: stackTrace);
}
}
+14 -9
View File
@@ -17,6 +17,7 @@ import '../main_screen.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../mixins/refreshable.dart';
import '../../providers/hidden_libraries_provider.dart';
import '../../providers/download_provider.dart';
import '../../providers/libraries_provider.dart';
import '../../services/donation_service.dart';
import '../../services/download_storage_service.dart';
@@ -55,7 +56,10 @@ import 'settings_utils.dart';
import '../../widgets/loading_indicator_box.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
const SettingsScreen({super.key, this.downloadDirectoryWritableChecker});
@visibleForTesting
final Future<bool> Function(Directory directory)? downloadDirectoryWritableChecker;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -620,7 +624,10 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
if (selectedPath != null) {
if (pathType == 'file') {
final dir = Directory(selectedPath);
final isWritable = await DownloadStorageService.instance.isDirectoryWritable(dir);
final isWritable =
await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(
dir,
);
if (!isWritable) {
if (mounted) {
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
@@ -629,9 +636,8 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
}
}
await _settingsService.write(settings.SettingsService.customDownloadPath, selectedPath);
await _settingsService.write(settings.SettingsService.customDownloadPathType, pathType);
await DownloadStorageService.instance.refreshCustomPath();
if (!mounted) return;
await context.read<DownloadProvider>().setDownloadLocation(path: selectedPath, pathType: pathType);
if (mounted) {
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
@@ -647,9 +653,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
}
Future<void> _resetDownloadLocation() async {
await _settingsService.write(settings.SettingsService.customDownloadPath, null);
await _settingsService.write(settings.SettingsService.customDownloadPathType, null);
await DownloadStorageService.instance.refreshCustomPath();
await context.read<DownloadProvider>().resetDownloadLocation();
if (mounted) {
// ignore: no-empty-block - setState triggers rebuild to reflect reset path
@@ -685,7 +689,8 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
confirmText: t.common.reset,
isDestructive: true,
);
if (!confirmed) return;
if (!mounted || !confirmed) return;
await context.read<DownloadProvider>().resetDownloadLocation();
await _settingsService.resetAllSettings();
await _keyboardService?.resetToDefaults();
if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
@@ -1,3 +1,5 @@
import 'dart:async';
/// Per-screen state for Android display frame-rate matching: the retry
/// counter for backends that detect fps only after rendering, whether a
/// switch was already applied for the current item, and the MediaSession
@@ -15,21 +17,21 @@ class FrameRateMatcher {
/// the post-first-frame path bails instead of switching twice.
bool applied = false;
bool _suppressMediaPause = false;
Timer? _mediaPauseSuppressionTimer;
/// Whether a MediaSession PauseEvent should be ignored right now because
/// the display is (or may still be) renegotiating HDMI. Fire Stick (and
/// similar Android TV devices) send onPause() through the MediaSession
/// callback when the display mode changes for frame rate matching.
bool get suppressesMediaPause => _suppressMediaPause;
bool get suppressesMediaPause => _mediaPauseSuppressionTimer?.isActive ?? false;
/// Arm the pause-suppression window around an HDMI renegotiation. The
/// window outlasts the switch by a safety margin on top of the user's
/// configured extra delay.
void beginSuppressWindow(int delaySec) {
_suppressMediaPause = true;
Future.delayed(Duration(seconds: 2 + delaySec + 1), () {
_suppressMediaPause = false;
_mediaPauseSuppressionTimer?.cancel();
_mediaPauseSuppressionTimer = Timer(Duration(seconds: 2 + delaySec + 1), () {
_mediaPauseSuppressionTimer = null;
});
}
@@ -38,4 +40,10 @@ class FrameRateMatcher {
retries = 0;
applied = false;
}
/// Cancel any active suppression window when the owning screen is disposed.
void dispose() {
_mediaPauseSuppressionTimer?.cancel();
_mediaPauseSuppressionTimer = null;
}
}
@@ -0,0 +1,29 @@
import '../../media/live_tv_support.dart';
import '../../models/livetv_capture_buffer.dart';
/// Sends one live-TV timeline report and commits its capture window only while
/// the dispatching session and scheduling generation still own the screen.
Future<void> runLiveTimelineReport({
required LiveTvPlaybackSession requestSession,
required int requestGeneration,
required String state,
required int positionMs,
required LiveTvPlaybackSession? Function() currentSession,
required int Function() currentGeneration,
required bool Function() isMounted,
required void Function(CaptureBuffer buffer) commit,
}) async {
final updatedBuffer = await requestSession.reportTimeline(
state: state,
positionMs: positionMs,
durationMs: requestSession.program.durationMs ?? 0,
);
if (updatedBuffer == null ||
state == 'stopped' ||
!isMounted() ||
currentGeneration() != requestGeneration ||
!identical(currentSession(), requestSession)) {
return;
}
commit(updatedBuffer);
}
@@ -0,0 +1,80 @@
import 'package:os_media_controls/os_media_controls.dart';
/// Screen-owned authorization boundary for user-originated OS media commands.
///
/// Lifecycle/audio-route events are handled before this router. Recognized
/// commands are consumed even when denied so they cannot reach a background
/// route or stale player owner.
final class VideoPlayerMediaControlRouter {
const VideoPlayerMediaControlRouter({
required this.canControlPlayback,
required this.canNavigateMediaItems,
required this.onPlay,
required this.onPause,
required this.onTogglePlayPause,
required this.onSeek,
required this.onNext,
required this.onPrevious,
required this.onStop,
required this.onSkipForward,
required this.onSkipBackward,
required this.onSetSpeed,
});
final bool Function() canControlPlayback;
final bool Function() canNavigateMediaItems;
final void Function() onPlay;
final void Function() onPause;
final void Function() onTogglePlayPause;
final void Function(Duration position) onSeek;
final void Function() onNext;
final void Function() onPrevious;
final void Function() onStop;
final void Function(Duration? interval) onSkipForward;
final void Function(Duration? interval) onSkipBackward;
final void Function(double speed) onSetSpeed;
bool route(MediaControlEvent event) {
if (event is StopEvent) {
onStop();
return true;
}
if (event is NextTrackEvent) {
if (canNavigateMediaItems()) onNext();
return true;
}
if (event is PreviousTrackEvent) {
if (canNavigateMediaItems()) onPrevious();
return true;
}
if (event is PlayEvent) {
if (canControlPlayback()) onPlay();
return true;
}
if (event is PauseEvent) {
if (canControlPlayback()) onPause();
return true;
}
if (event is TogglePlayPauseEvent) {
if (canControlPlayback()) onTogglePlayPause();
return true;
}
if (event is SeekEvent) {
if (canControlPlayback()) onSeek(event.position);
return true;
}
if (event is SkipForwardEvent) {
if (canControlPlayback()) onSkipForward(event.interval);
return true;
}
if (event is SkipBackwardEvent) {
if (canControlPlayback()) onSkipBackward(event.interval);
return true;
}
if (event is SetSpeedEvent) {
if (canControlPlayback()) onSetSpeed(event.speed);
return true;
}
return false;
}
}
+25 -33
View File
@@ -112,29 +112,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
children: [
FocusableButton(
autofocus: true,
onPressed: () {
final playerToDispose = player;
player = null;
if (playerToDispose != null) unawaited(playerToDispose.dispose());
_setPlayerState(() {
_playerInitializationError = null;
_isPlayerInitialized = false;
});
unawaited(_initializePlayer());
},
child: FilledButton(
onPressed: () {
final playerToDispose = player;
player = null;
if (playerToDispose != null) unawaited(playerToDispose.dispose());
_setPlayerState(() {
_playerInitializationError = null;
_isPlayerInitialized = false;
});
unawaited(_initializePlayer());
},
child: Text(t.common.retry),
),
onPressed: _retryPlayerInitialization,
child: FilledButton(onPressed: _retryPlayerInitialization, child: Text(t.common.retry)),
),
const SizedBox(width: 12),
FocusableButton(
@@ -240,21 +219,30 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
final newSize = Size(constraints.maxWidth, constraints.maxHeight);
_scheduleVideoLayoutUpdate(newSize);
// Compute canControl from Watch Together provider (reactive)
bool canControl = true;
var authority = (canControlPlayback: true, canNavigateMediaItems: true);
try {
canControl = context.select<WatchTogetherProvider, bool>(
(wt) => wt.isInSession ? wt.canControl() : true,
);
} catch (e) {
// Watch Together not available, default to can control
authority = context
.select<WatchTogetherProvider, ({bool canControlPlayback, bool canNavigateMediaItems})>(
(wt) => (
canControlPlayback: !wt.isInSession || wt.canControl(),
canNavigateMediaItems: !wt.isInSession || wt.isHost,
),
);
} catch (_) {
// Watch Together is optional outside the main app shell.
}
if (_lastMediaControlAuthority != authority) {
_lastMediaControlAuthority = authority;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_syncMediaControlsAvailability());
});
}
VoidCallback? onNext;
if (widget.isLive) {
onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null;
} else {
onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null;
onNext = (_nextEpisode != null && authority.canNavigateMediaItems) ? _playNext : null;
}
VoidCallback? onPrevious;
@@ -262,7 +250,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null;
} else {
final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null;
onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null;
onPrevious = (canRestartOrPrevious && authority.canNavigateMediaItems)
? _restartOrPlayPrevious
: null;
}
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
@@ -274,6 +264,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
hasFirstFrame: _hasFirstFrame,
controls: (context) => PlexVideoControls(
player: player!,
volumeController: _volumeController!,
metadata: _currentMetadata,
onNext: onNext,
onPrevious: onPrevious,
@@ -310,7 +301,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
canControl: canControl,
canControl: authority.canControlPlayback,
canNavigateMediaItems: authority.canNavigateMediaItems,
hasFirstFrame: _hasFirstFrame,
playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null,
chromeController: _chromeController,
@@ -14,39 +14,17 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
receiver.onPreviousTrack = () {
if (mounted) unawaited(_restartOrPlayPrevious());
};
receiver.onSeekForward = () async {
final settings = await SettingsService.getInstance();
await _seekRelative(Duration(seconds: settings.read(SettingsService.seekTimeSmall)));
receiver.onSeekForward = () => _dispatchCompanionSeek(1);
receiver.onSeekBackward = () => _dispatchCompanionSeek(-1);
receiver.onVolumeUp = () => _dispatchCompanionVolume(10);
receiver.onVolumeDown = () => _dispatchCompanionVolume(-10);
receiver.onVolumeMute = _dispatchCompanionMute;
receiver.onSubtitles = () {
if (_canControlPlayback()) _cycleSubtitleTrack();
};
receiver.onSeekBackward = () async {
final settings = await SettingsService.getInstance();
await _seekRelative(Duration(seconds: -settings.read(SettingsService.seekTimeSmall)));
receiver.onAudioTracks = () {
if (_canControlPlayback()) _cycleAudioTrack();
};
receiver.onVolumeUp = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol);
unawaited(player!.setVolume(newVolume));
unawaited(settings.write(SettingsService.volume, newVolume));
};
receiver.onVolumeDown = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol);
unawaited(player!.setVolume(newVolume));
unawaited(settings.write(SettingsService.volume, newVolume));
};
receiver.onVolumeMute = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final transition = settings.resolveMuteToggle(player!.state.volume);
unawaited(player!.setVolume(transition.playerVolume));
unawaited(settings.write(SettingsService.volume, transition.persistedVolume));
};
receiver.onSubtitles = _cycleSubtitleTrack;
receiver.onAudioTracks = _cycleAudioTrack;
receiver.onFullscreen = _toggleFullscreen;
// Override home to exit the player first. Replacements inherit the base
@@ -65,6 +43,38 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
}
}
void _dispatchCompanionSeek(int direction) {
final currentPlayer = player;
if (!mounted || currentPlayer == null || !_canControlPlayback()) return;
final settings = SettingsService.instance;
final seconds = settings.read(SettingsService.seekTimeSmall) * direction;
// _seekRelative captures the current player synchronously before its first
// await, binding this command to the exact screen/player owner at receipt.
unawaited(
_seekRelative(Duration(seconds: seconds)).catchError((Object error, StackTrace stackTrace) {
appLogger.w('Companion seek failed', error: error, stackTrace: stackTrace);
}),
);
}
void _dispatchCompanionVolume(double delta) {
final currentPlayer = player;
final controller = _volumeController;
if (!mounted || currentPlayer == null || controller == null || !controller.ownsPlayer(currentPlayer)) {
return;
}
controller.adjust(delta);
}
void _dispatchCompanionMute() {
final currentPlayer = player;
final controller = _volumeController;
if (!mounted || currentPlayer == null || controller == null || !controller.ownsPlayer(currentPlayer)) {
return;
}
controller.toggleMute();
}
void _cleanupCompanionRemoteCallbacks() {
final receiver = CompanionRemoteReceiver.instance;
if (!identical(receiver.playerOwner, this)) {
@@ -22,6 +22,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
}
Future<void> _playNext() async {
if (!_canNavigateMediaItems()) return;
if (!mounted) return;
if (_nextEpisode == null || _isLoadingNext) return;
@@ -40,6 +41,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
}
Future<void> _playPrevious() async {
if (!_canNavigateMediaItems()) return;
if (_previousEpisode == null || _isLoadingPrevious) return;
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
@@ -52,6 +54,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
}
Future<void> _restartOrPlayPrevious() async {
if (!_canNavigateMediaItems()) return;
final currentPlayer = player;
if (!mounted || currentPlayer == null || _isLoadingPrevious) return;
@@ -401,7 +404,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// rollback state is the eagerly-set identity (shown by the loading UI)
// and the first-frame flag.
final previousMetadata = _currentMetadata;
final previousMediaIndex = _effectiveSelectedMediaIndex;
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
final previousPartId = _currentMediaInfo?.partId;
final previousHasFirstFrame = _hasFirstFrame.value;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
@@ -456,6 +459,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
return _MediaReloadOutcome.failed;
}
final shouldAutoStart = shouldAutoStartReloadedMedia(
wasPlayingBeforeReload: wasPlayingBeforeReload,
watchTogetherOwnsStart: wtOwnsStart,
startPaused: startPaused,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex;
@@ -463,13 +472,20 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final targetAudioStreamId = useCurrentAudioStreamSelection
? selectedAudioStreamId ?? _selectedAudioStreamId
: selectedAudioStreamId;
final targetLaunchIdentity = VideoPlayerLaunchIdentity(
metadata: metadata,
mediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: targetQualityPreset,
isOffline: _offlineLibraryMode,
routeKind: VideoPlayerRouteKind.vod,
);
try {
// Eager identity-only: the loading UI shows the new title immediately,
// while the selection/source state flips with the session commit at
// the open boundary. Keep these writes inside the rollback boundary.
_currentMetadata = metadata;
VideoPlayerScreenState._activeId = metadata.id;
VideoPlayerScreenState._activeMediaIndex = targetMediaIndex;
VideoPlayerScreenState._activeRouteGuard.update(this, targetLaunchIdentity);
_unfocusPlayNextPrompt();
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
@@ -587,6 +603,14 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Generation invalidation prevents follow-on selection calls, but a
// native audio/subtitle/rate mutation may already have been
// dispatched. Drain exactly that captured operation before reusing
// the player for replacement media, otherwise its late completion can
// mutate the replacement item's tracks.
await attempt.trackMutationDrain;
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
@@ -604,11 +628,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
play:
!frameRatePlan.holdPlaybackStart &&
!wtOwnsStart &&
!startPaused &&
externalSubtitlePlan.canStartBeforeTrackSetup,
play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
onOpened: () {
@@ -669,10 +689,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh &&
effectiveExternalSubtitlePlan.requiresPostOpenAdd &&
!wtOwnsStart &&
!startPaused;
shouldAutoStart && frameRatePlan.needsStartupRefresh && effectiveExternalSubtitlePlan.requiresPostOpenAdd;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
@@ -681,12 +698,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// start) own the resume instead. Post-open external-subtitle paths
// resume once here so the startup refresh gate can observe a frame.
shouldResumeAfterSubtitleLoad: () =>
shouldAutoStart &&
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
!wtOwnsStart &&
!startPaused &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart,
applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
@@ -694,13 +710,15 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// startPaused rides the Watch Together yield path: the gate release
// arms track selection but leaves the player paused for the caller.
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
// Paused reloads use the same no-resume branch as an externally
// coordinated start: track selection is armed without manufacturing
// a new play intent.
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart || startPaused,
shouldResume: shouldAutoStart,
watchTogetherOwnsStart: wtOwnsStart,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
@@ -737,8 +755,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// Nothing was opened: the previous session is still committed, so
// only the eagerly-set identity needs restoring before resuming.
_currentMetadata = previousMetadata;
VideoPlayerScreenState._activeId = previousMetadata.id;
VideoPlayerScreenState._activeMediaIndex = previousMediaIndex;
if (previousLaunchIdentity != null) {
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
}
_hasFirstFrame.value = previousHasFirstFrame;
// If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends).
+17 -12
View File
@@ -28,8 +28,9 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
}
Future<void> _sendLiveTimeline(String state) async {
final session = _live.session;
if (session == null) return;
final requestSession = _live.session;
if (requestSession == null) return;
final requestGeneration = _live.timelineGeneration;
// For live TV, player position/duration are unreliable (often 0). Use
// elapsed wall-clock as the position and the program duration from tune
// metadata; the per-backend session owns the wire mapping.
@@ -38,19 +39,23 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
: 0;
try {
final updatedBuffer = await session.reportTimeline(
await runLiveTimelineReport(
requestSession: requestSession,
requestGeneration: requestGeneration,
state: state,
positionMs: playbackTime,
durationMs: session.program.durationMs ?? 0,
currentSession: () => _live.session,
currentGeneration: () => _live.timelineGeneration,
isMounted: () => mounted,
commit: (updatedBuffer) {
_setPlayerState(() {
_live.captureBuffer = updatedBuffer;
_live.atLiveEdge =
(_currentPositionEpoch >=
updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
});
},
);
if (updatedBuffer != null && mounted) {
_setPlayerState(() {
_live.captureBuffer = updatedBuffer;
_live.atLiveEdge =
(_currentPositionEpoch >=
updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
});
}
} catch (e) {
appLogger.d('Live timeline update failed', error: e);
}
@@ -32,20 +32,23 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
if (!mounted || manager == null || currentPlayer == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive;
final canSeek = !widget.isLive && currentPlayer.state.seekable;
final hasNavigableItems = _currentMetadata.isEpisode || playbackState.isPlaylistActive;
final contentCanSeek = !widget.isLive && currentPlayer.state.seekable;
final canControlPlayback = _canControlPlayback();
final canNavigateMediaItems = _canNavigateMediaItems();
if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return;
await manager.setControlsEnabled(
canGoNext: canNavigateEpisodes,
canGoPrevious: canNavigateEpisodes,
canSeek: canSeek,
canPlayPause: canControlPlayback,
canGoNext: hasNavigableItems && canNavigateMediaItems,
canGoPrevious: hasNavigableItems && canNavigateMediaItems,
canSeek: contentCanSeek && canControlPlayback,
canStop: true,
// In-track skips work on live TV too through the capture buffer.
canSkip: true,
canSkip: canControlPlayback,
// Rate changes don't apply to a live stream.
canSetSpeed: !widget.isLive,
canSetSpeed: !widget.isLive && canControlPlayback,
);
}
@@ -58,7 +61,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
Future<void> _restoreMediaControlsAfterResume() async {
if (!_isPlayerInitialized || !mounted) return;
unawaited(_setWakelock(player?.state.isActive ?? false));
unawaited(_wakelockController.setEnabled(player?.state.isActive ?? false));
final manager = _mediaControlsManager;
final currentPlayer = player;
@@ -357,32 +357,35 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
}
}
/// Gate-release resume that yields to Watch Together when a session owns
/// the playback start: track selection is still armed, but instead of
/// playing, the sync readiness hold (if any) is released — the
/// coordinated group start unpauses later. Shared by the start and reload
/// flows.
Future<void> _resumeAfterStartupGateOrYieldToWatchTogether({
/// Resolves the post-gate playback decision without inventing a play
/// intent. Track selection is still armed when playback must remain paused;
/// a Watch Together owner also receives its readiness release.
Future<void> _finishPlaybackAfterStartupGate({
required Player currentPlayer,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
required bool wtOwnsStart,
required bool shouldResume,
required bool watchTogetherOwnsStart,
Completer<void>? wtStartupHold,
}) async {
if (!wtOwnsStart) {
if (shouldResume) {
return _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
);
}
appLogger.d('Frame rate matching: yielding post-gate resume to Watch Together ($reason)');
appLogger.d(
watchTogetherOwnsStart
? 'Frame rate matching: yielding post-gate resume to Watch Together ($reason)'
: 'Frame rate matching: preserving paused playback after $reason',
);
final trackManager = _trackManager;
if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
if (wtStartupHold != null && !wtStartupHold.isCompleted) {
if (watchTogetherOwnsStart && wtStartupHold != null && !wtStartupHold.isCompleted) {
wtStartupHold.complete();
}
}
@@ -12,7 +12,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
// never fires false. Normalize all playback-dependent state.
unawaited(_setWakelock(false));
unawaited(_wakelockController.setEnabled(false));
final duration = player?.state.duration;
unawaited(
duration != null && duration.inMilliseconds > 0
@@ -34,6 +34,10 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
sleepTimerService.notifyVideoCompleted();
return;
}
if (!_canNavigateMediaItems()) {
if (!_completionLatch.triggered) _completionLatch.latch();
return;
}
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionLatch.triggered) {
_completionLatch.latch();
@@ -80,6 +84,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
}
void _startAutoPlayTimer() {
if (!_canNavigateMediaItems()) return;
_autoPlayTimer?.cancel();
_autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
@@ -185,6 +185,129 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
});
}
Future<void> _tearDownFailedPlayerAttempt(Player attemptPlayer) async {
final activePlayer = player;
if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return;
final cancellationFutures = <Future<void>>[
if (_playingSubscription != null) _playingSubscription!.cancel(),
if (_completedSubscription != null) _completedSubscription!.cancel(),
if (_errorSubscription != null) _errorSubscription!.cancel(),
if (_logSubscription != null) _logSubscription!.cancel(),
if (_backendSwitchedSubscription != null) _backendSwitchedSubscription!.cancel(),
if (_bufferingSubscription != null) _bufferingSubscription!.cancel(),
if (_serverStatusSubscription != null) _serverStatusSubscription!.cancel(),
if (_playbackRestartSubscription != null) _playbackRestartSubscription!.cancel(),
if (_positionSubscription != null) _positionSubscription!.cancel(),
if (_mediaControlSubscription != null) _mediaControlSubscription!.cancel(),
if (_mediaControlsPlayingSubscription != null) _mediaControlsPlayingSubscription!.cancel(),
if (_mediaControlsPositionSubscription != null) _mediaControlsPositionSubscription!.cancel(),
if (_mediaControlsRateSubscription != null) _mediaControlsRateSubscription!.cancel(),
if (_mediaControlsSeekableSubscription != null) _mediaControlsSeekableSubscription!.cancel(),
];
_playingSubscription = null;
_completedSubscription = null;
_errorSubscription = null;
_logSubscription = null;
_backendSwitchedSubscription = null;
_bufferingSubscription = null;
_serverStatusSubscription = null;
_playbackRestartSubscription = null;
_positionSubscription = null;
_mediaControlSubscription = null;
_mediaControlsPlayingSubscription = null;
_mediaControlsPositionSubscription = null;
_mediaControlsRateSubscription = null;
_mediaControlsSeekableSubscription = null;
try {
await Future.wait(cancellationFutures);
} catch (e, st) {
appLogger.w('Failed to cancel player subscriptions during initialization rollback', error: e, stackTrace: st);
}
final progressTracker = _progressTracker;
_progressTracker = null;
progressTracker?.stopTracking();
progressTracker?.dispose();
final trackManager = _trackManager;
_trackManager = null;
trackManager?.dispose();
final mediaControlsManager = _mediaControlsManager;
_mediaControlsManager = null;
if (mediaControlsManager != null) {
try {
await mediaControlsManager.clear();
} catch (e, st) {
appLogger.w('Failed to clear media controls during initialization rollback', error: e, stackTrace: st);
}
mediaControlsManager.dispose();
}
_stopLiveTimelineUpdates();
_detachPipStateListener();
_clearAutoPipEnteringCallback();
final videoPipManager = _videoPIPManager;
_videoPIPManager = null;
if (videoPipManager != null) {
videoPipManager.onBeforeEnterPip = null;
try {
await videoPipManager.disableAutoPip();
} catch (e, st) {
appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: st);
}
}
final ambientLightingService = _ambientLightingService;
_ambientLightingService = null;
if (ambientLightingService != null) {
try {
await ambientLightingService.disable();
} catch (e, st) {
appLogger.w('Failed to disable ambient lighting during initialization rollback', error: e, stackTrace: st);
}
}
_shaderService?.ambientLightingService = null;
_shaderService = null;
_videoFilterManager?.ambientLightingService = null;
_videoFilterManager?.dispose();
_videoFilterManager = null;
_pipFiltersPrepared = false;
final scrubPreviewSource = _scrubPreviewSource;
_scrubPreviewSource = null;
scrubPreviewSource?.dispose();
if (identical(_lastVideoLayoutPlayer, attemptPlayer)) {
_lastVideoLayoutPlayer = null;
_lastVideoLayoutSize = null;
_pendingVideoLayoutSize = null;
}
_audioFocusFuture = null;
_playbackDataFuture = null;
_playbackSession = null;
_mediaControlsSuspendedForTvBackground = false;
if (progressTracker != null) {
try {
await Future.wait<void>([
DiscordRPCService.instance.stopPlayback(),
TraktScrobbleService.instance.stopPlayback(),
TrackerCoordinator.instance.stopPlayback(),
]);
} catch (e, st) {
appLogger.w('Failed to stop scrobblers during initialization rollback', error: e, stackTrace: st);
}
}
await _wakelockController.setEnabled(false);
if (mounted) {
_isBuffering.value = false;
_hasFirstFrame.value = false;
}
}
/// Wire the per-item playback services that need to (re)bind whenever
/// the active media item changes: [PlaybackProgressTracker],
/// [MediaControlsManager.updateMetadata], and the
@@ -317,9 +440,60 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
final mediaControlsManager = MediaControlsManager();
_mediaControlsManager = mediaControlsManager;
final mediaControlRouter = VideoPlayerMediaControlRouter(
canControlPlayback: _canControlPlayback,
canNavigateMediaItems: _canNavigateMediaItems,
onPlay: () {
final currentPlayer = player;
if (currentPlayer == null) return;
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
_updateMediaControlsPlaybackState();
},
onPause: () {
final currentPlayer = player;
if (currentPlayer == null) return;
if (_frameRate.suppressesMediaPause) {
appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)');
return;
}
unawaited(_pauseWithPlaybackIntent(currentPlayer));
_updateMediaControlsPlaybackState();
},
onTogglePlayPause: () {
final currentPlayer = player;
if (currentPlayer == null) return;
if (currentPlayer.state.isActive) {
unawaited(_pauseWithPlaybackIntent(currentPlayer));
} else {
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
}
_updateMediaControlsPlaybackState();
},
onSeek: (position) {
final currentPlayer = player;
if (currentPlayer != null) {
unawaited(_seekPlayback(clampSeekPosition(currentPlayer, position)));
}
},
onNext: () {
if (_nextEpisode != null) unawaited(_playNext());
},
onPrevious: () => unawaited(_restartOrPlayPrevious()),
onStop: () => unawaited(_handleBackButton()),
onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultMediaControlSkip)),
onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultMediaControlSkip))),
onSetSpeed: (speed) {
final currentPlayer = player;
if (currentPlayer != null) unawaited(currentPlayer.setRate(speed));
},
);
// Set up media control event handling
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
final activePlayer = player;
if (_mediaControlsSuspendedForTvBackground) {
appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
return;
@@ -335,59 +509,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return;
}
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
if (event is PlayEvent) {
final currentPlayer = activePlayer!;
appLogger.d('Media control: Play event received');
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
_updateMediaControlsPlaybackState();
} else if (event is PauseEvent) {
if (_frameRate.suppressesMediaPause) {
appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)');
return;
}
appLogger.d('Media control: Pause event received');
unawaited(_pauseWithPlaybackIntent(activePlayer!));
_updateMediaControlsPlaybackState();
} else if (event is TogglePlayPauseEvent) {
final currentPlayer = activePlayer!;
appLogger.d('Media control: Toggle play/pause event received');
if (currentPlayer.state.isActive) {
unawaited(_pauseWithPlaybackIntent(currentPlayer));
} else {
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
}
_updateMediaControlsPlaybackState();
} else if (event is SeekEvent) {
appLogger.d('Media control: Seek event received to ${event.position}');
unawaited(_seekPlayback(clampSeekPosition(activePlayer!, event.position)));
} else if (event is NextTrackEvent) {
appLogger.d('Media control: Next track event received');
if (_nextEpisode != null) _playNext();
} else if (event is PreviousTrackEvent) {
appLogger.d('Media control: Previous track event received');
unawaited(_restartOrPlayPrevious());
} else if (event is StopEvent) {
// Same semantics as the companion remote's stop: exit the player.
appLogger.d('Media control: Stop event received');
unawaited(_handleBackButton());
} else if (event is SkipForwardEvent) {
appLogger.d('Media control: Skip forward event received (${event.interval})');
unawaited(_seekRelative(event.interval ?? _defaultMediaControlSkip));
} else if (event is SkipBackwardEvent) {
appLogger.d('Media control: Skip backward event received (${event.interval})');
unawaited(_seekRelative(-(event.interval ?? _defaultMediaControlSkip)));
} else if (event is SetSpeedEvent) {
// UI, Discord, and the media-session state all follow reactively
// via streams.rate — same unguarded path as keyboard shortcuts.
appLogger.d('Media control: Set speed event received (${event.speed}x)');
unawaited(activePlayer!.setRate(event.speed));
}
mediaControlRouter.route(event);
});
// Wire progress tracker, media-controls metadata, and the
@@ -452,11 +574,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (currentPlayer != null) {
unawaited(_pauseWithPlaybackIntent(currentPlayer));
}
unawaited(_setWakelock(false));
unawaited(_wakelockController.setEnabled(false));
return;
}
unawaited(_setWakelock(isPlaying));
unawaited(_wakelockController.setEnabled(isPlaying));
if (isPlaying) {
// Force a texture refresh on resume to unstick stale frames
@@ -625,7 +747,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
/// stream (play/seek route to [_retrySpuriousEofRecovery] while parked).
void _parkAfterFailedRecovery() {
_spuriousEofRecoveryParked = true;
unawaited(_setWakelock(false));
unawaited(_wakelockController.setEnabled(false));
showGlobalErrorSnackBar(t.messages.streamInterrupted);
}
@@ -358,11 +358,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart,
shouldResume: !wtOwnsStart,
watchTogetherOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
@@ -69,13 +69,12 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
}
}
/// Check if episode navigation controls should be enabled
/// Returns true if not in Watch Together session, or if user is the host
bool _canNavigateEpisodes() {
if (_watchTogetherProvider == null) return true;
if (!_watchTogetherProvider!.isInSession) return true;
return _watchTogetherProvider!.isHost;
}
/// Playback intent is guest-controllable only when the active room permits
/// it. Outside a room, the local screen remains authoritative.
bool _canControlPlayback() => _activeWatchTogetherSession()?.canControl() ?? true;
/// Choosing another queue item or episode is host-only in every room mode.
bool _canNavigateMediaItems() => _activeWatchTogetherSession()?.isHost ?? true;
/// Notify watch together session of current media change (host only)
/// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation)
@@ -0,0 +1,48 @@
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../utils/app_logger.dart';
typedef WakelockPlatformToggle = Future<void> Function(bool enabled);
/// Serializes fire-and-forget wakelock requests around the latest desired state.
class WakelockController {
WakelockController({WakelockPlatformToggle? platformToggle})
: _platformToggle = platformToggle ?? _togglePlatformWakelock;
final WakelockPlatformToggle _platformToggle;
Future<void> _tail = Future<void>.value();
bool? _effectiveEnabled;
bool _desiredEnabled = false;
/// Requests a wakelock state and completes after this queued reconciliation.
///
/// Platform failures are logged and absorbed so detached UI callers cannot
/// produce unhandled errors. A failed state is not recorded as effective;
/// the same value can therefore be retried by a later explicit request.
Future<void> setEnabled(bool enabled) {
_desiredEnabled = enabled;
final operation = _tail.then((_) => _reconcile());
_tail = operation;
return operation;
}
Future<void> _reconcile() async {
while (_effectiveEnabled != _desiredEnabled) {
final target = _desiredEnabled;
try {
await _platformToggle(target);
_effectiveEnabled = target;
} catch (error, stackTrace) {
appLogger.w('Wakelock ${target ? 'enable' : 'disable'} failed', error: error, stackTrace: stackTrace);
// Do not spin on a persistent failure. If an opposing request arrived
// during the await, it still gets one attempt before this operation
// settles; an identical state retries only through another setEnabled.
if (_desiredEnabled == target) return;
}
}
}
}
Future<void> _togglePlatformWakelock(bool enabled) => WakelockPlus.toggle(enable: enabled);
+185 -73
View File
@@ -10,7 +10,6 @@ import 'package:flutter/services.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../mpv/mpv.dart';
import '../mpv/player/platform/player_android.dart';
@@ -65,6 +64,7 @@ import '../services/track_selection_service.dart';
import '../services/ambient_lighting_service.dart';
import '../services/video_filter_manager.dart';
import '../services/video_pip_manager.dart';
import '../services/video_volume_controller.dart';
import '../services/pip_service.dart';
import '../models/shader_preset.dart';
import '../services/shader_service.dart';
@@ -84,6 +84,9 @@ import '../utils/video_player_navigation.dart';
import 'video_player/completion_latch.dart';
import 'video_player/frame_rate_matcher.dart';
import 'video_player/live_stream_retry.dart';
import 'video_player/live_timeline_report.dart';
import 'video_player/media_control_router.dart';
import 'video_player/wakelock_controller.dart';
import 'video_player/live_tv_session_args.dart';
import 'video_player/live_tv_session_state.dart';
import 'video_player/tv_background_suspend_policy.dart';
@@ -117,22 +120,7 @@ part 'video_player/parts/seeking.dart';
part 'video_player/parts/build.dart';
part 'video_player/parts/watch_together.dart';
bool? _wakelockEnabled;
Future<void> _setWakelock(bool enabled) async {
if (_wakelockEnabled == enabled) return;
_wakelockEnabled = enabled;
try {
if (enabled) {
await WakelockPlus.enable();
} else {
await WakelockPlus.disable();
}
} catch (e) {
_wakelockEnabled = null;
appLogger.w('Wakelock ${enabled ? 'enable' : 'disable'} failed: $e');
}
}
final WakelockController _wakelockController = WakelockController();
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
@@ -181,11 +169,12 @@ enum _MediaReloadOutcome {
/// Async continuations check [isCurrent] after every await while the screen
/// is mounted, the captured player is active, and no newer attempt exists.
class _PlaybackAttempt {
_PlaybackAttempt._(this._owner, this.generation, this.player);
_PlaybackAttempt._(this._owner, this.generation, this.player, this.trackMutationDrain);
final VideoPlayerScreenState _owner;
final int generation;
final Player player;
final Future<void> trackMutationDrain;
bool get isCurrent => _owner._isCurrentPlaybackGeneration(generation, player);
}
@@ -281,16 +270,20 @@ class VideoPlayerScreen extends StatefulWidget {
class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindingObserver, MountedSetStateMixin {
static const int _liveEdgeThresholdSeconds = 5;
// Track the currently active video to guard against duplicate navigation
static String? _activeId;
static int? _activeMediaIndex;
// Track the currently active route target to guard duplicate navigation and
// project the server-qualified media key to housekeeping consumers.
static final VideoPlayerActiveRouteGuard _activeRouteGuard = VideoPlayerActiveRouteGuard();
static String? get activeId => _activeId;
static int? get activeMediaIndex => _activeMediaIndex;
static String? get activeGlobalKey => _activeRouteGuard.activeGlobalKey;
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
Player? player;
VideoVolumeController? _volumeController;
bool _isPlayerInitialized = false;
String? _playerInitializationError;
Future<void>? _playerInitializationOperation;
int _playerInitializationGeneration = 0;
late MediaItem _currentMetadata;
MediaItem? _nextEpisode;
MediaItem? _previousEpisode;
@@ -328,7 +321,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
SubtitleTrack? _preferredSubtitleTrack;
SubtitleTrack? _preferredSecondarySubtitleTrack;
bool _serverSupportsTranscoding = false;
// Kicked off early in `_initializePlayer` for online non-live playback so
// Kicked off early in the player initialization attempt for online non-live playback so
// the metadata fetch (and transcode-decision HTTP, if non-original preset)
// overlaps with MPV property configuration. Awaited inside `_startPlayback`
// immediately before `player.open()` needs the video URL.
@@ -472,6 +465,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
MediaControlsManager? _mediaControlsManager;
({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority;
PlaybackProgressTracker? _progressTracker;
VideoFilterManager? _videoFilterManager;
VideoPIPManager? _videoPIPManager;
@@ -494,7 +488,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
VoidCallback? _savedOnHome;
/// Backend-neutral lookup. Returns whichever client (Plex or Jellyfin)
/// owns this item. Used by the playback-init path in [_initializePlayer].
/// owns this item. Used by the player initialization path.
MediaServerClient? _getMediaServerClient(BuildContext context) {
final id = _currentMetadata.serverId;
if (id == null) return null;
@@ -617,11 +611,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Start a new playback attempt: bumps the generation and captures the
/// owning player so async continuations can check [_PlaybackAttempt.isCurrent]
/// uniformly instead of threading (generation, player) pairs around.
/// Start a new playback attempt: invalidates automatic track selection,
/// bumps the generation, and captures the owning player so async
/// continuations can check [_PlaybackAttempt.isCurrent] uniformly instead of
/// threading (generation, player) pairs around. Reloads await the captured,
/// bounded mutation drain at their replacement-open boundary.
_PlaybackAttempt _beginPlaybackAttempt(Player currentPlayer, {bool isMediaReload = false}) {
return _PlaybackAttempt._(this, _beginPlaybackGeneration(isMediaReload: isMediaReload), currentPlayer);
final trackMutationDrain = _trackManager?.invalidatePendingSelection() ?? Future<void>.value();
return _PlaybackAttempt._(
this,
_beginPlaybackGeneration(isMediaReload: isMediaReload),
currentPlayer,
trackMutationDrain,
);
}
bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) {
@@ -691,8 +693,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
);
_currentMetadata = widget.metadata;
_activeId = widget.metadata.id;
_activeMediaIndex = widget.selectedMediaIndex;
_activeRouteGuard.activate(
this,
VideoPlayerLaunchIdentity(
metadata: widget.metadata,
mediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedQualityPreset: widget.selectedQualityPreset,
isOffline: widget.isOffline,
routeKind: widget.isLive ? VideoPlayerRouteKind.liveTv : VideoPlayerRouteKind.vod,
),
);
_effectiveSelectedMediaIndex = widget.selectedMediaIndex;
_requestedMediaSourceId = widget.selectedMediaSourceId;
@@ -763,7 +774,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (mounted) _showStillWatchingDialog();
});
_initializePlayer();
unawaited(_startPlayerInitialization(replaceCurrent: false));
}
@override
@@ -807,7 +818,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} else {
unawaited(_mediaControlsManager?.clear());
}
unawaited(_setWakelock(false));
unawaited(_wakelockController.setEnabled(false));
_recordLifecycleState('paused', action: 'backgrounded');
break;
case AppLifecycleState.resumed:
@@ -824,15 +835,95 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
Future<void> _initializePlayer() async {
var initPhase = 'starting';
try {
if (mounted) {
setState(() => _playerInitializationError = null);
Future<void> _startPlayerInitialization({required bool replaceCurrent}) {
final activeOperation = _playerInitializationOperation;
if (activeOperation != null) return activeOperation;
final generation = ++_playerInitializationGeneration;
final operationCompleter = Completer<void>();
final operation = operationCompleter.future;
_playerInitializationOperation = operation;
unawaited(() async {
try {
await _runPlayerInitializationAttempt(generation, replaceCurrent: replaceCurrent);
} catch (e, st) {
appLogger.e('Unexpected player initialization lifecycle failure', error: e, stackTrace: st);
} finally {
if (identical(_playerInitializationOperation, operation)) {
_playerInitializationOperation = null;
}
operationCompleter.complete();
}
}());
return operation;
}
void _retryPlayerInitialization() {
unawaited(_startPlayerInitialization(replaceCurrent: true));
}
bool _isPlayerInitializationCurrent(int generation) {
return mounted && generation == _playerInitializationGeneration;
}
bool _ownsPlayerInitializationAttempt(int generation, Player currentPlayer) {
return _isPlayerInitializationCurrent(generation) && identical(player, currentPlayer);
}
void _disposeVolumeControllerForPlayer(Player currentPlayer) {
final controller = _volumeController;
if (controller == null || !controller.ownsPlayer(currentPlayer)) return;
_volumeController = null;
controller.dispose();
}
Future<void> _disposePlayerInitializationAttempt(Player attemptPlayer) async {
_playbackGeneration++;
_disposeVolumeControllerForPlayer(attemptPlayer);
if (identical(player, attemptPlayer)) {
player = null;
}
try {
await _tearDownFailedPlayerAttempt(attemptPlayer);
} catch (e, st) {
appLogger.w('Failed to tear down player collaborators during initialization rollback', error: e, stackTrace: st);
}
try {
await attemptPlayer.abandonAudioFocus();
} catch (e, st) {
appLogger.w('Failed to abandon audio focus during player rollback', error: e, stackTrace: st);
}
try {
await attemptPlayer.dispose(preserveDisplayMode: false);
} catch (e, st) {
appLogger.w('Failed to dispose player during initialization rollback', error: e, stackTrace: st);
}
}
Future<void> _runPlayerInitializationAttempt(int generation, {required bool replaceCurrent}) async {
var initPhase = 'starting';
Player? attemptPlayer;
var committed = false;
String? failureMessage;
try {
if (!_isPlayerInitializationCurrent(generation)) return;
setState(() {
_playerInitializationError = null;
_isPlayerInitialized = false;
});
if (replaceCurrent) {
final previousPlayer = player;
if (previousPlayer != null) {
await _disposePlayerInitializationAttempt(previousPlayer);
}
if (!_isPlayerInitializationCurrent(generation)) return;
}
initPhase = 'loading settings';
final settingsService = await SettingsService.getInstance();
if (!mounted) return;
if (!_isPlayerInitializationCurrent(generation)) return;
_videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled);
_autoPipEnabled = settingsService.read(SettingsService.autoPip);
_exitFullscreenOnPlayerClose = settingsService.read(SettingsService.exitFullscreenOnPlayerClose);
@@ -846,7 +937,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
initPhase = 'syncing display mode';
_displayModeService = DisplayModeService(settingsService, FullscreenStateManager());
await _displayModeService!.syncWithNative();
if (!mounted) return;
if (!_isPlayerInitializationCurrent(generation)) return;
if (!_fullscreenListenerAttached) {
FullscreenStateManager().addListener(_onFullscreenChanged);
_fullscreenListenerAttached = true;
@@ -858,15 +949,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// video core (see PlaybackCoordinator).
initPhase = 'claiming playback session';
await PlaybackCoordinator.instance.claimVideo();
if (!mounted) return;
if (!mounted || generation != _playerInitializationGeneration) return;
initPhase = 'creating player';
final currentPlayer = Player(useExoPlayer: useExoPlayer);
player = currentPlayer;
_playerBackendLabel = currentPlayer.playerType;
attemptPlayer = currentPlayer;
if (!mounted || generation != _playerInitializationGeneration) return;
if (Platform.isAndroid && useExoPlayer) {
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
if (!mounted || player != currentPlayer) return;
if (!mounted || generation != _playerInitializationGeneration) return;
}
// Kick off getPlaybackData() in parallel with the rest of MPV setup.
@@ -875,7 +966,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// no async gaps invalidate it before the calls below read it.
// Skipped for live TV (has its own tune path) and offline (its own
// branch in _startPlayback).
if (!widget.isLive && !_offlineLibraryMode && mounted) {
if (!widget.isLive && !_offlineLibraryMode) {
// Backend-neutral lookup so Jellyfin items also flow through here.
// Plex-specific transcoder caching is gated on capabilities below;
// Jellyfin's `streamHeaders` is empty because it embeds api_key in
@@ -917,7 +1008,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playbackDataFuture!.ignore();
}
if (!mounted || player != currentPlayer) return;
if (!_isPlayerInitializationCurrent(generation)) return;
initPhase = 'configuring player';
await currentPlayer.configureSubtitleFonts();
await currentPlayer.setProperty('sub-ass', 'yes'); // Enable libass
@@ -944,7 +1035,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// buffering, which combined with decoded frames and GPU textures
// exhausts the process address space on memory-constrained devices.
final heapMB = await PlayerAndroid.getHeapSize();
if (!mounted || player != currentPlayer) return;
if (!_isPlayerInitializationCurrent(generation)) return;
if (heapMB > 0) {
int autoBackMB;
if (heapMB <= 256) {
@@ -1082,8 +1173,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
await currentPlayer.setVolume(savedVolume);
if (!_isPlayerInitializationCurrent(generation)) return;
_volumeController = VideoVolumeController(
player: currentPlayer,
settings: settingsService,
initialVolume: savedVolume,
);
if (!mounted || player != currentPlayer) return;
player = currentPlayer;
_playerBackendLabel = currentPlayer.playerType;
initPhase = 'wiring player streams';
await _wirePlayerStreams(
@@ -1091,7 +1189,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
settingsService: settingsService,
useExoPlayer: useExoPlayer,
);
if (!mounted || player != currentPlayer) return;
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
if (mounted) {
setState(() {
@@ -1102,13 +1200,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
SleepTimerService().restartIfNeeded(() => unawaited(_pauseWithPlaybackIntent(currentPlayer)));
// Enable wakelock to prevent screen from turning off during playback
unawaited(_setWakelock(true));
unawaited(_wakelockController.setEnabled(true));
appLogger.d('Wakelock enabled for video playback');
}
initPhase = 'starting playback';
await _startPlayback();
if (!mounted || player != currentPlayer) return;
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
// Set fullscreen mode and orientation based on rotation lock setting
initPhase = 'applying orientation';
@@ -1131,7 +1229,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
if (!mounted || player != currentPlayer) return;
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
// Player streams are wired before open so broadcast first-frame events
// cannot be dropped. Service init follows immediately after open.
// `_loadAdjacentEpisodes` depends on the play queue being in state
@@ -1146,15 +1244,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
);
initPhase = 'initializing playback services';
await _initializeServices();
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
committed = true;
} catch (e, st) {
failureMessage = _safePlaybackErrorMessage(e);
appLogger.e('Failed to initialize player during $initPhase', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isPlayerInitialized = false;
_playerInitializationError = _safePlaybackErrorMessage(e);
});
} finally {
final failedAttempt = attemptPlayer;
if (!committed && failedAttempt != null) {
await _disposePlayerInitializationAttempt(failedAttempt);
}
}
if (failureMessage != null && _isPlayerInitializationCurrent(generation)) {
setState(() {
_isPlayerInitialized = false;
_playerInitializationError = failureMessage;
});
}
}
/// Windows display mode matching service.
@@ -1302,6 +1409,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
@override
void dispose() {
_playerInitializationGeneration++;
_frameRate.dispose();
WidgetsBinding.instance.removeObserver(this);
final transitionCompleter = _playbackTransitionIdleCompleter;
@@ -1401,7 +1510,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_displayModeService != null &&
_displayModeService!.anyChangeApplied) {
if (_displayModeService!.hdrStateChanged && player != null) {
player!.setProperty('target-colorspace-hint', 'no');
final currentPlayer = player!;
unawaited(() async {
try {
await currentPlayer.setProperty('target-colorspace-hint', 'no');
} catch (error, stackTrace) {
appLogger.w(
'Failed to clear the Windows HDR colorspace hint during teardown',
error: error,
stackTrace: stackTrace,
);
}
}());
}
_displayModeService!.restoreAll();
}
@@ -1417,7 +1537,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
player!.abandonAudioFocus();
}
unawaited(_setWakelock(false));
unawaited(_wakelockController.setEnabled(false));
appLogger.d('Wakelock disabled');
if (!isReplacingWithVideo) {
@@ -1425,6 +1545,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
Sentry.addBreadcrumb(Breadcrumb(message: 'Player dispose', category: 'player'));
final volumeController = _volumeController;
_volumeController = null;
volumeController?.dispose();
final playerToDispose = player;
player = null;
if (playerToDispose != null) {
@@ -1432,10 +1555,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// player→player handoff; the replacement screen primes its own.
unawaited(playerToDispose.dispose(preserveDisplayMode: isReplacingWithVideo));
}
if (_activeId == _currentMetadata.id) {
_activeId = null;
_activeMediaIndex = null;
}
_activeRouteGuard.clear(this);
super.dispose();
}
@@ -1499,7 +1619,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return;
}
if (!_canControlPlaybackFromRemote()) {
if (!_canControlPlayback()) {
appLogger.d('$source play/pause ignored: playback control unavailable');
return;
}
@@ -1522,15 +1642,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
key == LogicalKeyboardKey.mediaPlay ||
key == LogicalKeyboardKey.mediaPause;
bool _canControlPlaybackFromRemote() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
return !watchTogether.isInSession || watchTogether.canControl();
} catch (e) {
return true;
}
}
String? _lastLogError;
bool _sawServer500 = false;
@@ -1540,6 +1651,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Navigate to a specific queue item (called from QueueSheet)
Future<void> navigateToQueueItem(MediaItem metadata) async {
if (!_canNavigateMediaItems()) return;
_notifyWatchTogetherMediaChange(metadata: metadata);
await _navigateToEpisode(metadata);
}
+9 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
@@ -85,7 +87,13 @@ class AmbientLightingService {
/// The shader adapts automatically via dynamic `target_size` uniform.
void updateOutputAspect(double outputAspect) {
if (!_enabled) return;
_player.setProperty('video-aspect-override', outputAspect.toString());
unawaited(() async {
try {
await _player.setProperty('video-aspect-override', outputAspect.toString());
} catch (error, stackTrace) {
appLogger.w('AmbientLightingService: Failed to update output aspect', error: error, stackTrace: stackTrace);
}
}());
}
/// Generate a static multi-pass GLSL shader.

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