fix(runtime): harden application service boundaries
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+174
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+902
-123
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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": "Гледай на живо",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ライブで視聴",
|
||||
|
||||
@@ -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": "실시간 시청",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Смотреть в прямом эфире",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => 'ТВ сериали',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’app — 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'テレビ番組',
|
||||
|
||||
@@ -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 프로그램',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Сериалы',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '电视剧',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user