Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d82a5c5cd7 | ||
|
|
fdd4c661fe | ||
|
|
439ae1d733 | ||
|
|
a759e8b3c6 | ||
|
|
74d3af3ae1 | ||
|
|
8624c37041 | ||
|
|
7a19f4149e | ||
|
|
860ce1e11a | ||
|
|
2b4875d389 | ||
|
|
e7aa1e4782 | ||
|
|
bbaf5f0f9e | ||
|
|
0f5e5c8b6e | ||
|
|
a9f0532f5f | ||
|
|
fb0613e3db | ||
|
|
9c08c78f6d | ||
|
|
95b013e155 | ||
|
|
d83d0790ba | ||
|
|
bbed260169 | ||
|
|
35061f9f68 | ||
|
|
1d9ffb7427 | ||
|
|
bac2a0d201 | ||
|
|
2a7e5f4f9c | ||
|
|
bc0d14a749 | ||
|
|
f78f65faf5 | ||
|
|
957711a650 | ||
|
|
2cb2c3eb95 |
@@ -16,13 +16,20 @@ tags:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
# The TV player opens with its chrome down so the OSD and timebar never sit
|
||||
# over the opening seconds of the picture (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
# A give-up lands on the transport assertions below, which is where it belongs.
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
notVisible: "Overview"
|
||||
timeout: 30000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
|
||||
@@ -7,9 +7,23 @@ tags:
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
|
||||
# tap here flips InputModeTracker into pointer mode, which collapses the rail
|
||||
# to icons and leaves no "Search" label to hit — and a percentage coordinate
|
||||
# does not survive the jump from a phone in forced TV mode to a 4K television.
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Search"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
@@ -24,13 +38,24 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
# The TV player opens with its chrome down (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
notVisible: "(?s)^Play S1E1$"
|
||||
timeout: 30000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
|
||||
# "Next Episode" is the credits skip button at this point in the episode, so
|
||||
# waiting on it would let Back fire before the prompt ever opened.
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
visible: "(?s)^Cancel$"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
@@ -42,7 +67,10 @@ tags:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
# Dismissal is animated, so give it a bounded wait rather than one instant look.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Cancel$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Pins the side-loaded subtitle identity contract against the real media3 the
|
||||
* app links, on a real device.
|
||||
*
|
||||
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
|
||||
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
|
||||
* selector reports. media3 does not hand that id back verbatim:
|
||||
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
|
||||
* source, and `MergingMediaPeriod` prefixes every child format id with its
|
||||
* period index. Matching the raw id therefore classifies every sidecar as an
|
||||
* embedded track and drops its URI, which is what broke Plex sidecar
|
||||
* subtitles (#1713).
|
||||
*
|
||||
* A JVM test can only assert the parsing rule. This asserts that the rule
|
||||
* still matches what media3 actually emits.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalSubtitleIdentityTest {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ExternalSubtitleIdentityTest"
|
||||
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
|
||||
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
|
||||
writeText(SRT)
|
||||
}
|
||||
|
||||
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val settled = CountDownLatch(1)
|
||||
val playerRef = AtomicReference<ExoPlayer>()
|
||||
val errorRef = AtomicReference<Throwable>()
|
||||
val textFormatId = AtomicReference<String>()
|
||||
val textGroupCount = AtomicReference(0)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
|
||||
playerRef.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
if (text.isEmpty()) return
|
||||
textGroupCount.set(text.size)
|
||||
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
|
||||
settled.countDown()
|
||||
}
|
||||
})
|
||||
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
|
||||
// the subtitle configuration exactly as ExoPlayerCore.open does.
|
||||
player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setUri(Uri.fromFile(media))
|
||||
.setSubtitleConfigurations(
|
||||
listOf(
|
||||
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
|
||||
.setId(ExternalSubtitleIds.idFor(0))
|
||||
.setLabel("Plezy sidecar")
|
||||
.setLanguage("en")
|
||||
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
player.prepare()
|
||||
} catch (error: Throwable) {
|
||||
errorRef.set(error)
|
||||
settled.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = settled.await(30, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerRef.get()?.release()
|
||||
thread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
thread.join(5_000)
|
||||
media.delete()
|
||||
subtitle.delete()
|
||||
|
||||
assertTrue("Player teardown timed out", teardownFinished)
|
||||
assertNull("Playback failed", errorRef.get())
|
||||
assertTrue("Timed out before any text track group was reported", finished)
|
||||
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
|
||||
|
||||
val reportedId = textFormatId.get()
|
||||
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
|
||||
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
|
||||
|
||||
// The contract ExoPlayerCore depends on.
|
||||
assertTrue(
|
||||
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
|
||||
ExternalSubtitleIds.isExternal(reportedId)
|
||||
)
|
||||
assertEquals(
|
||||
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
|
||||
0,
|
||||
ExternalSubtitleIds.indexOf(reportedId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
|
||||
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(asset).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -1877,14 +1877,15 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
subtitleTrackGroupMap[trackId] = trackGroup
|
||||
val isSelected = group.isSelected
|
||||
|
||||
// Detect external (side-loaded) subtitle by the ID prefix set in open()
|
||||
val isExternal = format.id?.startsWith("external_") == true
|
||||
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
|
||||
// Detect external (side-loaded) subtitle by the ID set in open(). media3
|
||||
// rewrites merged child ids, so the tag is not the whole id.
|
||||
val isExternal = ExternalSubtitleIds.isExternal(format.id)
|
||||
val externalIndex = ExternalSubtitleIds.indexOf(format.id)
|
||||
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
|
||||
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
|
||||
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
|
||||
|
||||
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
|
||||
Log.d(TAG, "Subtitle track $groupIndex: formatId=${format.id}, codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
|
||||
|
||||
val track = mutableMapOf<String, Any?>(
|
||||
"type" to "sub",
|
||||
@@ -3222,7 +3223,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
|
||||
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
|
||||
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
|
||||
.setId("external_$index")
|
||||
.setId(ExternalSubtitleIds.idFor(index))
|
||||
.setLabel(title ?: "External")
|
||||
.setLanguage(language)
|
||||
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
|
||||
@@ -3585,7 +3586,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
val existingIndex = externalSubtitleUris.indexOf(uri)
|
||||
val isNew = existingIndex < 0
|
||||
val index = if (isNew) externalSubtitles.size else existingIndex
|
||||
val formatId = "external_$index"
|
||||
val formatId = ExternalSubtitleIds.idFor(index)
|
||||
|
||||
if (isNew) {
|
||||
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
|
||||
@@ -3632,9 +3633,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
} else {
|
||||
// Already attached — select the existing track via override.
|
||||
// Already attached — select the existing track via override. The
|
||||
// reported id carries media3's merge prefixes, so compare the tag.
|
||||
val trackId = subtitleTrackGroupMap.entries
|
||||
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
|
||||
.firstOrNull { (_, group) -> ExternalSubtitleIds.indexOf(group.getFormat(0).id) == index }
|
||||
?.key
|
||||
if (trackId != null) {
|
||||
selectSubtitleTrack(trackId)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Identity of a side-loaded subtitle across media3's media-source merging.
|
||||
*
|
||||
* Each `MediaItem.SubtitleConfiguration` is tagged with [idFor], and the tag is
|
||||
* read back off the `Format` the track selector reports. The tag does not
|
||||
* survive verbatim: `DefaultMediaSourceFactory.createMediaSource` always wraps
|
||||
* the primary source plus one source per subtitle configuration in a
|
||||
* `MergingMediaSource`, and since media3 1.3.0 `MergingMediaPeriod.onPrepared`
|
||||
* rewrites every child format id to `"<periodIndex>:<originalId>"`. A second
|
||||
* merge — the one this player builds for container sidecars — prefixes it
|
||||
* again. So the same configuration can surface as `external_0`,
|
||||
* `1:external_0`, or `0:1:external_0`.
|
||||
*
|
||||
* Only the final `:`-separated segment carries the tag, which is why matching
|
||||
* on the whole id silently classifies every sidecar as an embedded track.
|
||||
*/
|
||||
internal object ExternalSubtitleIds {
|
||||
private const val PREFIX = "external_"
|
||||
|
||||
/** Tag written onto the [index]th side-loaded subtitle configuration. */
|
||||
fun idFor(index: Int): String = "$PREFIX$index"
|
||||
|
||||
/** Whether [formatId] identifies a side-loaded subtitle. */
|
||||
fun isExternal(formatId: String?): Boolean = tagOf(formatId) != null
|
||||
|
||||
/**
|
||||
* Index passed to [idFor], or null when [formatId] is not a side-loaded
|
||||
* subtitle or carries a tag this build did not write.
|
||||
*/
|
||||
fun indexOf(formatId: String?): Int? = tagOf(formatId)?.toIntOrNull()
|
||||
|
||||
private fun tagOf(formatId: String?): String? {
|
||||
// substringAfterLast returns the whole string when no ':' is present, which
|
||||
// is the unmerged case.
|
||||
val segment = formatId?.substringAfterLast(':') ?: return null
|
||||
return if (segment.startsWith(PREFIX)) segment.removePrefix(PREFIX) else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalSubtitleIdsTest {
|
||||
|
||||
@Test
|
||||
fun unmergedTagResolves() {
|
||||
assertTrue(ExternalSubtitleIds.isExternal("external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("external_0"))
|
||||
assertEquals(7, ExternalSubtitleIds.indexOf("external_7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaSourceFactoryMergePrefixResolves() {
|
||||
// DefaultMediaSourceFactory always merges side-loaded subtitles with the
|
||||
// primary source, so MergingMediaPeriod prefixes the period index.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("1:external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("1:external_0"))
|
||||
assertEquals(2, ExternalSubtitleIds.indexOf("3:external_2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containerSidecarOuterMergePrefixResolves() {
|
||||
// The container-sidecar path wraps that merge in a second MergingMediaSource.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("0:1:external_0"))
|
||||
assertEquals(0, ExternalSubtitleIds.indexOf("0:1:external_0"))
|
||||
assertEquals(4, ExternalSubtitleIds.indexOf("0:2:external_4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun embeddedAndUnknownIdsAreNotExternal() {
|
||||
assertFalse(ExternalSubtitleIds.isExternal(null))
|
||||
assertFalse(ExternalSubtitleIds.isExternal("1:2"))
|
||||
assertFalse(ExternalSubtitleIds.isExternal("0:"))
|
||||
// A container child whose own id merely ends in the tag's text.
|
||||
assertFalse(ExternalSubtitleIds.isExternal("1:not_external_0"))
|
||||
assertNull(ExternalSubtitleIds.indexOf("1:2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedTagIsExternalWithoutAnIndex() {
|
||||
// Still a side-loaded track, but no usable URI lookup.
|
||||
assertTrue(ExternalSubtitleIds.isExternal("1:external_x"))
|
||||
assertNull(ExternalSubtitleIds.indexOf("1:external_x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writtenIdRoundTrips() {
|
||||
for (index in 0..3) {
|
||||
val id = ExternalSubtitleIds.idFor(index)
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf(id))
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf("1:$id"))
|
||||
assertEquals(index, ExternalSubtitleIds.indexOf("0:1:$id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import '../utils/url_utils.dart';
|
||||
|
||||
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
|
||||
@@ -232,6 +233,13 @@ class JellyfinConnection extends Connection {
|
||||
/// match/unmatch, edit metadata) without an extra round-trip.
|
||||
final bool isAdministrator;
|
||||
|
||||
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
|
||||
/// profile picture. Jellyfin omits the key entirely in that case, and the
|
||||
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
|
||||
/// which makes the derived avatar URL self-invalidating. Captured at auth
|
||||
/// time and refreshed by [JellyfinClient.checkHealth].
|
||||
final String? primaryImageTag;
|
||||
|
||||
JellyfinConnection({
|
||||
required this.id,
|
||||
required String baseUrl,
|
||||
@@ -243,6 +251,7 @@ class JellyfinConnection extends Connection {
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.isAdministrator = false,
|
||||
this.primaryImageTag,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
@@ -298,6 +307,12 @@ class JellyfinConnection extends Connection {
|
||||
String? accessToken,
|
||||
String? deviceId,
|
||||
bool? isAdministrator,
|
||||
String? primaryImageTag,
|
||||
|
||||
/// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the
|
||||
/// user DTO, so a refresh must be able to null the cached value — a bare
|
||||
/// `primaryImageTag: null` is indistinguishable from "unchanged".
|
||||
bool clearPrimaryImageTag = false,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
@@ -314,6 +329,7 @@ class JellyfinConnection extends Connection {
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
@@ -332,6 +348,7 @@ class JellyfinConnection extends Connection {
|
||||
'accessToken': accessToken,
|
||||
'deviceId': deviceId,
|
||||
'isAdministrator': isAdministrator,
|
||||
'primaryImageTag': primaryImageTag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,9 +376,23 @@ class JellyfinConnection extends Connection {
|
||||
accessToken: json['accessToken'] as String? ?? '',
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
isAdministrator: json['isAdministrator'] as bool? ?? false,
|
||||
primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']),
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`.
|
||||
///
|
||||
/// The tag is decorative — a fork or a drifted scalar type must never brick
|
||||
/// sign-in — so this coerces through [readStringField] rather than casting,
|
||||
/// and collapses absent/blank to `null` ("no picture").
|
||||
static String? readPrimaryImageTag(Map<String, Object?> userDto) =>
|
||||
normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag'));
|
||||
|
||||
static String? normalizePrimaryImageTag(Object? raw) {
|
||||
final tag = raw?.toString().trim();
|
||||
return tag == null || tag.isEmpty ? null : tag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,22 @@ class ConnectionRegistry {
|
||||
|
||||
/// Insert or replace [connection]. If this is the first stored connection
|
||||
/// it is automatically marked default; re-upserting an existing row keeps
|
||||
/// the row's current `isDefault` (so token/metadata refreshes don't clear
|
||||
/// the default flag).
|
||||
/// the row's current `isDefault` and `createdAt` (so token/metadata
|
||||
/// refreshes don't clear the default flag or restamp creation order).
|
||||
///
|
||||
/// Creation order is behaviour, not bookkeeping: it decides which
|
||||
/// connection lends a profile its picture, and `remove` promotes the oldest
|
||||
/// remaining row to default. Re-authenticating rebuilds the model with
|
||||
/// `DateTime.now()` and reuses the same stable id, so without this the
|
||||
/// originally-first connection would jump to last on every re-sign-in.
|
||||
Future<void> upsert(Connection connection) async {
|
||||
await _db.runIdentityMutation(() async {
|
||||
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
|
||||
final bool isDefault;
|
||||
final int createdAt;
|
||||
if (existing != null) {
|
||||
isDefault = existing.isDefault;
|
||||
createdAt = existing.createdAt;
|
||||
} else {
|
||||
final any =
|
||||
await (_db.selectOnly(_db.connections)
|
||||
@@ -57,6 +65,7 @@ class ConnectionRegistry {
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
isDefault = any == null;
|
||||
createdAt = connection.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
final protectedConfig = await CredentialVault.protectConnectionConfig(
|
||||
connection.kind.id,
|
||||
@@ -68,7 +77,7 @@ class ConnectionRegistry {
|
||||
displayName: Value(connection.displayName),
|
||||
configJson: Value(jsonEncode(protectedConfig)),
|
||||
isDefault: Value(isDefault),
|
||||
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
|
||||
createdAt: Value(createdAt),
|
||||
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
|
||||
);
|
||||
await _db.into(_db.connections).insertOnConflictUpdate(row);
|
||||
|
||||
@@ -123,6 +123,23 @@ KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Whether the primary focus currently belongs to an active text editor.
|
||||
///
|
||||
/// Ancestor key handlers use this to stay off keys a focused field owns.
|
||||
/// Flutter dispatches a key event from the focused node upwards, and the
|
||||
/// editing shortcuts that turn Backspace into a deletion live in
|
||||
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
|
||||
/// screen. An ancestor that claims Backspace as "back" therefore both steals
|
||||
/// the navigation and stops the character from ever being deleted (#1741).
|
||||
///
|
||||
/// [EditableText] builds its [Focus] internally, so the focused node's context
|
||||
/// resolves to the owning [EditableTextState].
|
||||
bool isTextEditingFocused() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) return false;
|
||||
return context.findAncestorStateOfType<EditableTextState>() != null;
|
||||
}
|
||||
|
||||
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
|
||||
///
|
||||
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Yüklənir...",
|
||||
"fullscreen": "Tam ekran",
|
||||
"exitFullscreen": "Tam ekrandan çıx",
|
||||
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın"
|
||||
"pressBackAgainToExit": "Çıxmaq üçün geri düyməsinə bir daha basın",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lisenziyalar",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Fasilə",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Oynatma idarəetmələrini göstər",
|
||||
"hidePlaybackControls": "Oynatma idarəetmələrini gizlət",
|
||||
"seekBackwardButton": "${seconds} saniyə geri sar",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Зареждане...",
|
||||
"fullscreen": "На цял екран",
|
||||
"exitFullscreen": "Изход от цял екран",
|
||||
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете"
|
||||
"pressBackAgainToExit": "Натиснете Назад отново, за да излезете",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Лицензи",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Пауза",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Покажи контролите за възпроизвеждане",
|
||||
"hidePlaybackControls": "Скрий контролите за възпроизвеждане",
|
||||
"seekBackwardButton": "Превърти назад ${seconds} секунди",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Indlæser...",
|
||||
"fullscreen": "Fuldskærm",
|
||||
"exitFullscreen": "Forlad fuldskærm",
|
||||
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte"
|
||||
"pressBackAgainToExit": "Tryk på tilbage igen for at afslutte",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Vis afspilningsknapper",
|
||||
"hidePlaybackControls": "Skjul afspilningsknapper",
|
||||
"seekBackwardButton": "Spol ${seconds} sekunder tilbage",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Wird geladen …",
|
||||
"fullscreen": "Vollbild",
|
||||
"exitFullscreen": "Vollbild verlassen",
|
||||
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken"
|
||||
"pressBackAgainToExit": "Zum Beenden erneut Zurück drücken",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lizenzen",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Wiedergabesteuerung anzeigen",
|
||||
"hidePlaybackControls": "Wiedergabesteuerung ausblenden",
|
||||
"seekBackwardButton": "${seconds} Sekunden zurück",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+29
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Loading...",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"pressBackAgainToExit": "Press back again to exit"
|
||||
"pressBackAgainToExit": "Press back again to exit",
|
||||
"ratingSource": {
|
||||
"critic": "Critics",
|
||||
"audience": "Audience",
|
||||
"imdb": "IMDb",
|
||||
"tmdb": "TMDB",
|
||||
"rottenTomatoes": "Rotten Tomatoes",
|
||||
"simkl": "Simkl",
|
||||
"mal": "MyAnimeList",
|
||||
"anilist": "AniList",
|
||||
"trakt": "Trakt",
|
||||
"rottenTomatoesCritic": "Rotten Tomatoes critics",
|
||||
"rottenTomatoesAudience": "Rotten Tomatoes audience"
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "Paused",
|
||||
"playbackResumed": "Playing",
|
||||
"loadingVideo": "Loading video",
|
||||
"showPlaybackControls": "Show playback controls",
|
||||
"hidePlaybackControls": "Hide playback controls",
|
||||
"seekBackwardButton": "Seek backward ${seconds} seconds",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "Plezy could not start",
|
||||
"failedBody": "Something went wrong during startup. The details below identify what failed.",
|
||||
"failedBodyRepairable": "Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.",
|
||||
"phaseLabel": "Step",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
@@ -984,7 +999,9 @@
|
||||
"repairTitle": "Repair stored data?",
|
||||
"repairBodyCommon": "Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.",
|
||||
"repairBodyOneCredential": "One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.",
|
||||
"repairBodySessionsAtRisk": "Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.",
|
||||
"repairBodySignInsKept": "Your servers and profiles should stay signed in.",
|
||||
"repairBodySignInsLost": "The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.",
|
||||
"repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.",
|
||||
"repairConfirm": "Repair",
|
||||
"repairSucceeded": "Storage repaired",
|
||||
"repairNeedsRestart": "Storage repaired — restart required",
|
||||
@@ -1134,18 +1151,16 @@
|
||||
"creator": "Creator",
|
||||
"composer": "Composer"
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "Critics",
|
||||
"audience": "Audience",
|
||||
"imdb": "IMDb",
|
||||
"tmdb": "TMDB",
|
||||
"rottenTomatoes": "Rotten Tomatoes",
|
||||
"simkl": "Simkl",
|
||||
"mal": "MyAnimeList",
|
||||
"anilist": "AniList",
|
||||
"trakt": "Trakt",
|
||||
"rottenTomatoesCritic": "Rotten Tomatoes critics",
|
||||
"rottenTomatoesAudience": "Rotten Tomatoes audience"
|
||||
"relation": {
|
||||
"prequel": "Prequel",
|
||||
"sequel": "Sequel",
|
||||
"sideStory": "Side story",
|
||||
"spinOff": "Spin-off",
|
||||
"alternativeVersion": "Alternative version",
|
||||
"summary": "Summary",
|
||||
"parentStory": "Parent story",
|
||||
"adaptation": "Adaptation",
|
||||
"other": "Related"
|
||||
},
|
||||
"broadcast": "Airs ${day} at ${time}",
|
||||
"broadcastWithZone": "Airs ${day} at ${time} ${timezone}",
|
||||
@@ -1186,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "${n} result",
|
||||
"other": "${n} results"
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "Prequel",
|
||||
"sequel": "Sequel",
|
||||
"sideStory": "Side story",
|
||||
"spinOff": "Spin-off",
|
||||
"alternativeVersion": "Alternative version",
|
||||
"summary": "Summary",
|
||||
"parentStory": "Parent story",
|
||||
"adaptation": "Adaptation",
|
||||
"other": "Related"
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Cargando...",
|
||||
"fullscreen": "Pantalla completa",
|
||||
"exitFullscreen": "Salir de pantalla completa",
|
||||
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir"
|
||||
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencias",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pausar",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Mostrar controles de reproducción",
|
||||
"hidePlaybackControls": "Ocultar controles de reproducción",
|
||||
"seekBackwardButton": "Retroceder ${seconds} segundos",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Chargement...",
|
||||
"fullscreen": "Plein écran",
|
||||
"exitFullscreen": "Quitter le plein écran",
|
||||
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter"
|
||||
"pressBackAgainToExit": "Appuyez à nouveau sur retour pour quitter",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licences",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Afficher les commandes de lecture",
|
||||
"hidePlaybackControls": "Masquer les commandes de lecture",
|
||||
"seekBackwardButton": "Reculer de ${seconds} secondes",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Betöltés...",
|
||||
"fullscreen": "Teljes képernyő",
|
||||
"exitFullscreen": "Kilépés a teljes képernyőből",
|
||||
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot"
|
||||
"pressBackAgainToExit": "A kilépéshez nyomd meg újra a Vissza gombot",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencek",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Szünet",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Lejátszásvezérlők megjelenítése",
|
||||
"hidePlaybackControls": "Lejátszásvezérlők elrejtése",
|
||||
"seekBackwardButton": "Tekerés hátra ${seconds} másodperccel",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Caricamento...",
|
||||
"fullscreen": "Schermo intero",
|
||||
"exitFullscreen": "Esci dalla modalità a schermo intero",
|
||||
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire"
|
||||
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pausa",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Mostra i controlli di riproduzione",
|
||||
"hidePlaybackControls": "Nascondi i controlli di riproduzione",
|
||||
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "読み込み中…",
|
||||
"fullscreen": "フルスクリーン",
|
||||
"exitFullscreen": "フルスクリーンを終了",
|
||||
"pressBackAgainToExit": "もう一度押すと終了します"
|
||||
"pressBackAgainToExit": "もう一度押すと終了します",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "ライセンス",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "一時停止",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "再生コントロールを表示",
|
||||
"hidePlaybackControls": "再生コントロールを非表示",
|
||||
"seekBackwardButton": "${seconds}秒戻る",
|
||||
@@ -973,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -983,10 +998,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1129,18 +1148,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1179,17 +1196,6 @@
|
||||
},
|
||||
"totalResults": {
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Жүктелуде...",
|
||||
"fullscreen": "Толық экран",
|
||||
"exitFullscreen": "Толық экраннан шығу",
|
||||
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз"
|
||||
"pressBackAgainToExit": "Шығу үшін артқа түймесін қайтадан басыңыз",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Лицензиялар",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Кідірту",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Басқару элементтерін көрсету",
|
||||
"hidePlaybackControls": "Басқару элементтерін жасыру",
|
||||
"seekBackwardButton": "${seconds} секунд артқа айналдыру",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "로딩 중...",
|
||||
"fullscreen": "전체 화면",
|
||||
"exitFullscreen": "전체 화면 종료",
|
||||
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다"
|
||||
"pressBackAgainToExit": "한 번 더 누르면 종료됩니다",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "라이선스",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "일시정지",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "재생 컨트롤 표시",
|
||||
"hidePlaybackControls": "재생 컨트롤 숨기기",
|
||||
"seekBackwardButton": "${seconds}초 뒤로 이동",
|
||||
@@ -973,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -983,10 +998,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1129,18 +1148,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1179,17 +1196,6 @@
|
||||
},
|
||||
"totalResults": {
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Laster...",
|
||||
"fullscreen": "Fullskjerm",
|
||||
"exitFullscreen": "Avslutt fullskjerm",
|
||||
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte"
|
||||
"pressBackAgainToExit": "Trykk på Tilbake en gang til for å avslutte",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lisenser",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Vis avspillingskontroller",
|
||||
"hidePlaybackControls": "Skjul avspillingskontroller",
|
||||
"seekBackwardButton": "Spol tilbake ${seconds} sekunder",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Laden...",
|
||||
"fullscreen": "Volledig scherm",
|
||||
"exitFullscreen": "Volledig scherm verlaten",
|
||||
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten"
|
||||
"pressBackAgainToExit": "Druk nogmaals op terug om af te sluiten",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenties",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pauzeren",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Afspeelbediening tonen",
|
||||
"hidePlaybackControls": "Afspeelbediening verbergen",
|
||||
"seekBackwardButton": "${seconds} seconden terugspoelen",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Ładowanie...",
|
||||
"fullscreen": "Pełny ekran",
|
||||
"exitFullscreen": "Wyjdź z pełnego ekranu",
|
||||
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść"
|
||||
"pressBackAgainToExit": "Naciśnij ponownie przycisk Wstecz, aby wyjść",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencje",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pauza",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Pokaż elementy sterujące odtwarzaniem",
|
||||
"hidePlaybackControls": "Ukryj elementy sterujące odtwarzaniem",
|
||||
"seekBackwardButton": "Przewiń do tyłu o ${seconds} sekund",
|
||||
@@ -976,6 +990,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -986,10 +1001,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1138,18 +1157,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1194,17 +1211,6 @@
|
||||
"few": "",
|
||||
"many": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Carregando...",
|
||||
"fullscreen": "Tela cheia",
|
||||
"exitFullscreen": "Sair da tela cheia",
|
||||
"pressBackAgainToExit": "Pressione voltar novamente para sair"
|
||||
"pressBackAgainToExit": "Pressione voltar novamente para sair",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenças",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pausar",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Mostrar controles de reprodução",
|
||||
"hidePlaybackControls": "Ocultar controles de reprodução",
|
||||
"seekBackwardButton": "Retroceder ${seconds} segundos",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Загрузка...",
|
||||
"fullscreen": "Полноэкранный режим",
|
||||
"exitFullscreen": "Выйти из полноэкранного режима",
|
||||
"pressBackAgainToExit": "Нажмите ещё раз для выхода"
|
||||
"pressBackAgainToExit": "Нажмите ещё раз для выхода",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Лицензии",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Пауза",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Показать элементы управления воспроизведением",
|
||||
"hidePlaybackControls": "Скрыть элементы управления воспроизведением",
|
||||
"seekBackwardButton": "Перемотать назад на ${seconds} с",
|
||||
@@ -976,6 +990,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -986,10 +1001,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1138,18 +1157,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1194,17 +1211,6 @@
|
||||
"few": "",
|
||||
"many": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32942 (1497 per locale)
|
||||
/// Strings: 32946 (1497 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
+111
-95
@@ -300,6 +300,8 @@ class Translations$common$en {
|
||||
|
||||
/// en: 'Press back again to exit'
|
||||
String get pressBackAgainToExit => 'Press back again to exit';
|
||||
|
||||
late final Translations$common$ratingSource$en ratingSource = Translations$common$ratingSource$en.internal(_root);
|
||||
}
|
||||
|
||||
// Path: screens
|
||||
@@ -1818,6 +1820,9 @@ class Translations$videoControls$en {
|
||||
/// en: 'Playing'
|
||||
String get playbackResumed => 'Playing';
|
||||
|
||||
/// en: 'Loading video'
|
||||
String get loadingVideo => 'Loading video';
|
||||
|
||||
/// en: 'Show playback controls'
|
||||
String get showPlaybackControls => 'Show playback controls';
|
||||
|
||||
@@ -2851,6 +2856,9 @@ class Translations$startup$en {
|
||||
/// en: 'Something went wrong during startup. The details below identify what failed.'
|
||||
String get failedBody => 'Something went wrong during startup. The details below identify what failed.';
|
||||
|
||||
/// en: 'Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.'
|
||||
String get failedBodyRepairable => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.';
|
||||
|
||||
/// en: 'Step'
|
||||
String get phaseLabel => 'Step';
|
||||
|
||||
@@ -2881,8 +2889,14 @@ class Translations$startup$en {
|
||||
/// en: 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.'
|
||||
String get repairBodyOneCredential => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.';
|
||||
|
||||
/// en: 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.'
|
||||
String get repairBodySessionsAtRisk => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.';
|
||||
/// en: 'Your servers and profiles should stay signed in.'
|
||||
String get repairBodySignInsKept => 'Your servers and profiles should stay signed in.';
|
||||
|
||||
/// en: 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.'
|
||||
String get repairBodySignInsLost => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.';
|
||||
|
||||
/// en: 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.'
|
||||
String get repairBodySessionsUncertain => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.';
|
||||
|
||||
/// en: 'Repair'
|
||||
String get repairConfirm => 'Repair';
|
||||
@@ -3040,7 +3054,7 @@ class Translations$explore$en {
|
||||
late final Translations$explore$format$en format = Translations$explore$format$en.internal(_root);
|
||||
late final Translations$explore$sourceMaterial$en sourceMaterial = Translations$explore$sourceMaterial$en.internal(_root);
|
||||
late final Translations$explore$creditRole$en creditRole = Translations$explore$creditRole$en.internal(_root);
|
||||
late final Translations$explore$ratingSource$en ratingSource = Translations$explore$ratingSource$en.internal(_root);
|
||||
late final Translations$explore$relation$en relation = Translations$explore$relation$en.internal(_root);
|
||||
|
||||
/// en: 'Airs ${day} at ${time}'
|
||||
String broadcast({required Object day, required Object time}) => 'Airs ${day} at ${time}';
|
||||
@@ -3055,8 +3069,6 @@ class Translations$explore$en {
|
||||
one: '${n} result',
|
||||
other: '${n} results',
|
||||
);
|
||||
|
||||
late final Translations$explore$relation$en relation = Translations$explore$relation$en.internal(_root);
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
@@ -4820,6 +4832,48 @@ class Translations$addServer$en {
|
||||
String get borrowFromAnotherProfileSubtitle => 'Reuse another profile\'s connection. PIN-protected profiles require a PIN.';
|
||||
}
|
||||
|
||||
// Path: common.ratingSource
|
||||
class Translations$common$ratingSource$en {
|
||||
Translations$common$ratingSource$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Critics'
|
||||
String get critic => 'Critics';
|
||||
|
||||
/// en: 'Audience'
|
||||
String get audience => 'Audience';
|
||||
|
||||
/// en: 'IMDb'
|
||||
String get imdb => 'IMDb';
|
||||
|
||||
/// en: 'TMDB'
|
||||
String get tmdb => 'TMDB';
|
||||
|
||||
/// en: 'Rotten Tomatoes'
|
||||
String get rottenTomatoes => 'Rotten Tomatoes';
|
||||
|
||||
/// en: 'Simkl'
|
||||
String get simkl => 'Simkl';
|
||||
|
||||
/// en: 'MyAnimeList'
|
||||
String get mal => 'MyAnimeList';
|
||||
|
||||
/// en: 'AniList'
|
||||
String get anilist => 'AniList';
|
||||
|
||||
/// en: 'Trakt'
|
||||
String get trakt => 'Trakt';
|
||||
|
||||
/// en: 'Rotten Tomatoes critics'
|
||||
String get rottenTomatoesCritic => 'Rotten Tomatoes critics';
|
||||
|
||||
/// en: 'Rotten Tomatoes audience'
|
||||
String get rottenTomatoesAudience => 'Rotten Tomatoes audience';
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
class Translations$hotkeys$actions$en {
|
||||
Translations$hotkeys$actions$en.internal(this._root);
|
||||
@@ -5414,46 +5468,40 @@ class Translations$explore$creditRole$en {
|
||||
String get composer => 'Composer';
|
||||
}
|
||||
|
||||
// Path: explore.ratingSource
|
||||
class Translations$explore$ratingSource$en {
|
||||
Translations$explore$ratingSource$en.internal(this._root);
|
||||
// Path: explore.relation
|
||||
class Translations$explore$relation$en {
|
||||
Translations$explore$relation$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Critics'
|
||||
String get critic => 'Critics';
|
||||
/// en: 'Prequel'
|
||||
String get prequel => 'Prequel';
|
||||
|
||||
/// en: 'Audience'
|
||||
String get audience => 'Audience';
|
||||
/// en: 'Sequel'
|
||||
String get sequel => 'Sequel';
|
||||
|
||||
/// en: 'IMDb'
|
||||
String get imdb => 'IMDb';
|
||||
/// en: 'Side story'
|
||||
String get sideStory => 'Side story';
|
||||
|
||||
/// en: 'TMDB'
|
||||
String get tmdb => 'TMDB';
|
||||
/// en: 'Spin-off'
|
||||
String get spinOff => 'Spin-off';
|
||||
|
||||
/// en: 'Rotten Tomatoes'
|
||||
String get rottenTomatoes => 'Rotten Tomatoes';
|
||||
/// en: 'Alternative version'
|
||||
String get alternativeVersion => 'Alternative version';
|
||||
|
||||
/// en: 'Simkl'
|
||||
String get simkl => 'Simkl';
|
||||
/// en: 'Summary'
|
||||
String get summary => 'Summary';
|
||||
|
||||
/// en: 'MyAnimeList'
|
||||
String get mal => 'MyAnimeList';
|
||||
/// en: 'Parent story'
|
||||
String get parentStory => 'Parent story';
|
||||
|
||||
/// en: 'AniList'
|
||||
String get anilist => 'AniList';
|
||||
/// en: 'Adaptation'
|
||||
String get adaptation => 'Adaptation';
|
||||
|
||||
/// en: 'Trakt'
|
||||
String get trakt => 'Trakt';
|
||||
|
||||
/// en: 'Rotten Tomatoes critics'
|
||||
String get rottenTomatoesCritic => 'Rotten Tomatoes critics';
|
||||
|
||||
/// en: 'Rotten Tomatoes audience'
|
||||
String get rottenTomatoesAudience => 'Rotten Tomatoes audience';
|
||||
/// en: 'Related'
|
||||
String get other => 'Related';
|
||||
}
|
||||
|
||||
// Path: explore.detail
|
||||
@@ -5555,42 +5603,6 @@ class Translations$explore$detail$en {
|
||||
String get background => 'Background';
|
||||
}
|
||||
|
||||
// Path: explore.relation
|
||||
class Translations$explore$relation$en {
|
||||
Translations$explore$relation$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Prequel'
|
||||
String get prequel => 'Prequel';
|
||||
|
||||
/// en: 'Sequel'
|
||||
String get sequel => 'Sequel';
|
||||
|
||||
/// en: 'Side story'
|
||||
String get sideStory => 'Side story';
|
||||
|
||||
/// en: 'Spin-off'
|
||||
String get spinOff => 'Spin-off';
|
||||
|
||||
/// en: 'Alternative version'
|
||||
String get alternativeVersion => 'Alternative version';
|
||||
|
||||
/// en: 'Summary'
|
||||
String get summary => 'Summary';
|
||||
|
||||
/// en: 'Parent story'
|
||||
String get parentStory => 'Parent story';
|
||||
|
||||
/// en: 'Adaptation'
|
||||
String get adaptation => 'Adaptation';
|
||||
|
||||
/// en: 'Related'
|
||||
String get other => 'Related';
|
||||
}
|
||||
|
||||
// Path: downloads.backgroundWarning
|
||||
class Translations$downloads$backgroundWarning$en {
|
||||
Translations$downloads$backgroundWarning$en.internal(this._root);
|
||||
@@ -6083,6 +6095,17 @@ extension on Translations {
|
||||
'common.fullscreen' => 'Fullscreen',
|
||||
'common.exitFullscreen' => 'Exit fullscreen',
|
||||
'common.pressBackAgainToExit' => 'Press back again to exit',
|
||||
'common.ratingSource.critic' => 'Critics',
|
||||
'common.ratingSource.audience' => 'Audience',
|
||||
'common.ratingSource.imdb' => 'IMDb',
|
||||
'common.ratingSource.tmdb' => 'TMDB',
|
||||
'common.ratingSource.rottenTomatoes' => 'Rotten Tomatoes',
|
||||
'common.ratingSource.simkl' => 'Simkl',
|
||||
'common.ratingSource.mal' => 'MyAnimeList',
|
||||
'common.ratingSource.anilist' => 'AniList',
|
||||
'common.ratingSource.trakt' => 'Trakt',
|
||||
'common.ratingSource.rottenTomatoesCritic' => 'Rotten Tomatoes critics',
|
||||
'common.ratingSource.rottenTomatoesAudience' => 'Rotten Tomatoes audience',
|
||||
'screens.licenses' => 'Licenses',
|
||||
'screens.switchProfile' => 'Switch Profile',
|
||||
'screens.subtitleStyling' => 'Subtitle Styling',
|
||||
@@ -6523,6 +6546,8 @@ extension on Translations {
|
||||
'rateSheet.notAvailable' => 'No match found',
|
||||
'rateSheet.noConnectedServices' => 'Connect a service in Settings to rate there.',
|
||||
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show',
|
||||
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
|
||||
'accessibility.mediaCardSeason' => ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}',
|
||||
@@ -6534,8 +6559,6 @@ extension on Translations {
|
||||
'accessibility.increase' => 'Increase',
|
||||
'accessibility.decreaseValue' => ({required Object label}) => 'Decrease ${label}',
|
||||
'accessibility.increaseValue' => ({required Object label}) => 'Increase ${label}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'accessibility.hue' => 'Hue',
|
||||
'accessibility.saturation' => 'Saturation',
|
||||
'accessibility.brightness' => 'Brightness',
|
||||
@@ -6579,6 +6602,7 @@ extension on Translations {
|
||||
'videoControls.pauseButton' => 'Pause',
|
||||
'videoControls.playbackPaused' => 'Paused',
|
||||
'videoControls.playbackResumed' => 'Playing',
|
||||
'videoControls.loadingVideo' => 'Loading video',
|
||||
'videoControls.showPlaybackControls' => 'Show playback controls',
|
||||
'videoControls.hidePlaybackControls' => 'Hide playback controls',
|
||||
'videoControls.seekBackwardButton' => ({required Object seconds}) => 'Seek backward ${seconds} seconds',
|
||||
@@ -6926,6 +6950,7 @@ extension on Translations {
|
||||
'logs.uploadLogs' => 'Upload Logs',
|
||||
'startup.failedTitle' => 'Plezy could not start',
|
||||
'startup.failedBody' => 'Something went wrong during startup. The details below identify what failed.',
|
||||
'startup.failedBodyRepairable' => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.',
|
||||
'startup.phaseLabel' => 'Step',
|
||||
'startup.showDetails' => 'Show details',
|
||||
'startup.hideDetails' => 'Hide details',
|
||||
@@ -6936,7 +6961,9 @@ extension on Translations {
|
||||
'startup.repairTitle' => 'Repair stored data?',
|
||||
'startup.repairBodyCommon' => 'Plezy\'s settings file is damaged and cannot be read. Repairing resets every setting to its default.',
|
||||
'startup.repairBodyOneCredential' => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.',
|
||||
'startup.repairBodySessionsAtRisk' => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.',
|
||||
'startup.repairBodySignInsKept' => 'Your servers and profiles should stay signed in.',
|
||||
'startup.repairBodySignInsLost' => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.',
|
||||
'startup.repairBodySessionsUncertain' => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.',
|
||||
'startup.repairConfirm' => 'Repair',
|
||||
'startup.repairSucceeded' => 'Storage repaired',
|
||||
'startup.repairNeedsRestart' => 'Storage repaired — restart required',
|
||||
@@ -7033,6 +7060,8 @@ extension on Translations {
|
||||
'explore.stats.dropped' => ({required Object n}) => '${n} dropped',
|
||||
'explore.season.winter' => 'Winter',
|
||||
'explore.season.spring' => 'Spring',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.season.summer' => 'Summer',
|
||||
'explore.season.fall' => 'Fall',
|
||||
'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}',
|
||||
@@ -7048,8 +7077,6 @@ extension on Translations {
|
||||
'explore.sourceMaterial.manga' => 'Manga',
|
||||
'explore.sourceMaterial.lightNovel' => 'Light novel',
|
||||
'explore.sourceMaterial.novel' => 'Novel',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.sourceMaterial.visualNovel' => 'Visual novel',
|
||||
'explore.sourceMaterial.game' => 'Game',
|
||||
'explore.sourceMaterial.webComic' => 'Web comic',
|
||||
@@ -7060,17 +7087,15 @@ extension on Translations {
|
||||
'explore.creditRole.producer' => 'Producer',
|
||||
'explore.creditRole.creator' => 'Creator',
|
||||
'explore.creditRole.composer' => 'Composer',
|
||||
'explore.ratingSource.critic' => 'Critics',
|
||||
'explore.ratingSource.audience' => 'Audience',
|
||||
'explore.ratingSource.imdb' => 'IMDb',
|
||||
'explore.ratingSource.tmdb' => 'TMDB',
|
||||
'explore.ratingSource.rottenTomatoes' => 'Rotten Tomatoes',
|
||||
'explore.ratingSource.simkl' => 'Simkl',
|
||||
'explore.ratingSource.mal' => 'MyAnimeList',
|
||||
'explore.ratingSource.anilist' => 'AniList',
|
||||
'explore.ratingSource.trakt' => 'Trakt',
|
||||
'explore.ratingSource.rottenTomatoesCritic' => 'Rotten Tomatoes critics',
|
||||
'explore.ratingSource.rottenTomatoesAudience' => 'Rotten Tomatoes audience',
|
||||
'explore.relation.prequel' => 'Prequel',
|
||||
'explore.relation.sequel' => 'Sequel',
|
||||
'explore.relation.sideStory' => 'Side story',
|
||||
'explore.relation.spinOff' => 'Spin-off',
|
||||
'explore.relation.alternativeVersion' => 'Alternative version',
|
||||
'explore.relation.summary' => 'Summary',
|
||||
'explore.relation.parentStory' => 'Parent story',
|
||||
'explore.relation.adaptation' => 'Adaptation',
|
||||
'explore.relation.other' => 'Related',
|
||||
'explore.broadcast' => ({required Object day, required Object time}) => 'Airs ${day} at ${time}',
|
||||
'explore.broadcastWithZone' => ({required Object day, required Object time, required Object timezone}) => 'Airs ${day} at ${time} ${timezone}',
|
||||
'explore.detail.originalTitle' => 'Original title',
|
||||
@@ -7103,15 +7128,6 @@ extension on Translations {
|
||||
'explore.detail.relatedTitles' => 'Related titles',
|
||||
'explore.detail.background' => 'Background',
|
||||
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
|
||||
'explore.relation.prequel' => 'Prequel',
|
||||
'explore.relation.sequel' => 'Sequel',
|
||||
'explore.relation.sideStory' => 'Side story',
|
||||
'explore.relation.spinOff' => 'Spin-off',
|
||||
'explore.relation.alternativeVersion' => 'Alternative version',
|
||||
'explore.relation.summary' => 'Summary',
|
||||
'explore.relation.parentStory' => 'Parent story',
|
||||
'explore.relation.adaptation' => 'Adaptation',
|
||||
'explore.relation.other' => 'Related',
|
||||
'liveTv.title' => 'Live TV',
|
||||
'liveTv.guide' => 'Guide',
|
||||
'liveTv.noChannels' => 'No channels available',
|
||||
@@ -7558,12 +7574,12 @@ extension on Translations {
|
||||
'externalPlayer.playerUrlScheme' => 'URL Scheme',
|
||||
'externalPlayer.off' => 'Off',
|
||||
'externalPlayer.launchFailed' => 'Failed to open external player',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed',
|
||||
'externalPlayer.playInExternalPlayer' => 'Play in External Player',
|
||||
'metadataEdit.editMetadata' => 'Edit...',
|
||||
'metadataEdit.screenTitle' => 'Edit Metadata',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.basicInfo' => 'Basic Info',
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Laddar...",
|
||||
"fullscreen": "Helskärm",
|
||||
"exitFullscreen": "Avsluta helskärm",
|
||||
"pressBackAgainToExit": "Tryck bakåt igen för att avsluta"
|
||||
"pressBackAgainToExit": "Tryck bakåt igen för att avsluta",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pausa",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Visa uppspelningskontroller",
|
||||
"hidePlaybackControls": "Dölj uppspelningskontroller",
|
||||
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Yükleniyor...",
|
||||
"fullscreen": "Tam Ekran",
|
||||
"exitFullscreen": "Tam Ekrandan Çık",
|
||||
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın"
|
||||
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lisanslar",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Duraklat",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Oynatma kontrollerini göster",
|
||||
"hidePlaybackControls": "Oynatma kontrollerini gizle",
|
||||
"seekBackwardButton": "${seconds} saniye geri sar",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "Yuklanmoqda...",
|
||||
"fullscreen": "Toʻliq ekran",
|
||||
"exitFullscreen": "Toʻliq ekrandan chiqish",
|
||||
"pressBackAgainToExit": "Chiqish uchun orqaga tugmasini yana bir bor bosing"
|
||||
"pressBackAgainToExit": "Chiqish uchun orqaga tugmasini yana bir bor bosing",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Litsenziyalar",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "Pauza",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Boshqaruv tugmalarini koʻrsatish",
|
||||
"hidePlaybackControls": "Boshqaruv tugmalarini yashirish",
|
||||
"seekBackwardButton": "${seconds} soniya orqaga oʻtkazish",
|
||||
@@ -974,6 +988,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -984,10 +999,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1132,18 +1151,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1184,17 +1201,6 @@
|
||||
"totalResults": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "載入中…",
|
||||
"fullscreen": "全螢幕",
|
||||
"exitFullscreen": "退出全螢幕",
|
||||
"pressBackAgainToExit": "再按一次返回以退出"
|
||||
"pressBackAgainToExit": "再按一次返回以退出",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "授權條款",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "暫停",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "顯示播放控制項",
|
||||
"hidePlaybackControls": "隱藏播放控制項",
|
||||
"seekBackwardButton": "後退 ${seconds} 秒",
|
||||
@@ -973,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -983,10 +998,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1129,18 +1148,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1179,17 +1196,6 @@
|
||||
},
|
||||
"totalResults": {
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+31
-25
@@ -64,7 +64,20 @@
|
||||
"loading": "加载中…",
|
||||
"fullscreen": "全屏",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"pressBackAgainToExit": "再按一次返回键退出"
|
||||
"pressBackAgainToExit": "再按一次返回键退出",
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "许可证",
|
||||
@@ -585,6 +598,7 @@
|
||||
"pauseButton": "暂停",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "显示播放控制项",
|
||||
"hidePlaybackControls": "隐藏播放控制项",
|
||||
"seekBackwardButton": "快退 ${seconds} 秒",
|
||||
@@ -973,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -983,10 +998,14 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"restartRequiredBody": "",
|
||||
"quitPlezy": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
@@ -1129,18 +1148,16 @@
|
||||
"creator": "",
|
||||
"composer": ""
|
||||
},
|
||||
"ratingSource": {
|
||||
"critic": "",
|
||||
"audience": "",
|
||||
"imdb": "",
|
||||
"tmdb": "",
|
||||
"rottenTomatoes": "",
|
||||
"simkl": "",
|
||||
"mal": "",
|
||||
"anilist": "",
|
||||
"trakt": "",
|
||||
"rottenTomatoesCritic": "",
|
||||
"rottenTomatoesAudience": ""
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
},
|
||||
"broadcast": "",
|
||||
"broadcastWithZone": "",
|
||||
@@ -1179,17 +1196,6 @@
|
||||
},
|
||||
"totalResults": {
|
||||
"other": ""
|
||||
},
|
||||
"relation": {
|
||||
"prequel": "",
|
||||
"sequel": "",
|
||||
"sideStory": "",
|
||||
"spinOff": "",
|
||||
"alternativeVersion": "",
|
||||
"summary": "",
|
||||
"parentStory": "",
|
||||
"adaptation": "",
|
||||
"other": ""
|
||||
}
|
||||
},
|
||||
"liveTv": {
|
||||
|
||||
+51
-12
@@ -85,6 +85,7 @@ import 'utils/android_exit_diagnostics.dart';
|
||||
import 'utils/storage_failure.dart';
|
||||
import 'services/base_shared_preferences_service.dart';
|
||||
import 'services/prefs_recovery.dart';
|
||||
import 'services/sensitive_prefs.dart';
|
||||
import 'services/startup_diagnostics.dart';
|
||||
import 'utils/dialogs.dart';
|
||||
import 'widgets/dialog_action_button.dart';
|
||||
@@ -432,16 +433,21 @@ Future<StartupRepairResult> repairStartupStorage(
|
||||
final unreadableKey = cause is UnreadableSensitivePreferenceException ? cause.key : null;
|
||||
final reopenSafe = cause is! CorruptPreferenceStoreException || cause.reopenSafe;
|
||||
|
||||
// Name the cost before touching anything. A salvaged vault key keeps servers
|
||||
// and profiles signed in because those tokens are ciphertext in the
|
||||
// database; tracker and Seerr sessions are plaintext preference entries and
|
||||
// can still be lost, so neither branch promises more than it can deliver.
|
||||
// Name the real cost before touching anything, not the usual one. Servers
|
||||
// and profiles survive a repair only because their tokens are ciphertext in
|
||||
// the database and the key that decrypts them lives in the store — so a
|
||||
// store the key cannot be read out of signs the user out of everything. An
|
||||
// all-zero file is exactly that case (#1732), and telling them their
|
||||
// sign-ins are safe would be a promise the outcome dialog then contradicts.
|
||||
final signInsSurvive = unreadableKey != null
|
||||
? unreadableKey != credentialVaultKeyPref
|
||||
: (await PrefsRecovery.previewSalvage()).vaultKey != null;
|
||||
if (!context.mounted) return StartupRepairResult.none;
|
||||
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.startup.repairTitle,
|
||||
message: unreadableKey != null
|
||||
? '${t.startup.repairBodyOneCredential}\n\n${t.startup.repairBodySessionsAtRisk}'
|
||||
: '${t.startup.repairBodyCommon}\n\n${t.startup.repairBodySessionsAtRisk}',
|
||||
message: repairConsentMessage(oneCredential: unreadableKey != null, signInsSurvive: signInsSurvive),
|
||||
confirmText: t.startup.repairConfirm,
|
||||
isDestructive: true,
|
||||
);
|
||||
@@ -457,11 +463,41 @@ Future<StartupRepairResult> repairStartupStorage(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reports a completed repair, including the credential-bearing backup.
|
||||
/// The consent dialog's body: what is being repaired, then what it costs.
|
||||
///
|
||||
/// Separated from [repairStartupStorage] because the two halves fail
|
||||
/// differently. Deciding whether the sign-ins survive is I/O against a
|
||||
/// damaged file; deciding what to *tell* the user about it is a promise, and
|
||||
/// #1732 is what happens when the promise is written once, optimistically,
|
||||
/// for a case that does not always hold.
|
||||
///
|
||||
/// [signInsSurvive] covers servers and profiles only. Their tokens are
|
||||
/// ciphertext in the database, so they live or die with the vault key —
|
||||
/// which is exactly why that one value decides this sentence.
|
||||
///
|
||||
/// Tracker and Seerr sessions are plaintext preference entries, salvaged and
|
||||
/// reseeded one by one and untouched entirely by the single-credential
|
||||
/// repair. They therefore do not follow the vault key in either direction,
|
||||
/// and get one cautious sentence in both branches rather than a promise
|
||||
/// borrowed from a value that does not govern them.
|
||||
@visibleForTesting
|
||||
String repairConsentMessage({required bool oneCredential, required bool signInsSurvive}) => [
|
||||
oneCredential ? t.startup.repairBodyOneCredential : t.startup.repairBodyCommon,
|
||||
signInsSurvive ? t.startup.repairBodySignInsKept : t.startup.repairBodySignInsLost,
|
||||
t.startup.repairBodySessionsUncertain,
|
||||
].join('\n\n');
|
||||
|
||||
/// Reports a completed repair, including the retained copy of the damaged
|
||||
/// store.
|
||||
///
|
||||
/// The backup path is shown so the user can find and delete it, never so it
|
||||
/// can be attached to a report: it holds the credential-vault key, tracker
|
||||
/// refresh tokens and Seerr cookies in plaintext.
|
||||
/// can be attached to a report: it can hold the credential-vault key, tracker
|
||||
/// refresh tokens and Seerr cookies in plaintext. The warning is suppressed
|
||||
/// only when the quarantined *bytes* prove there is nothing in them — an
|
||||
/// all-zero file (#1732) — never on the strength of what the salvage managed
|
||||
/// to recover, which says nothing about what is still in the file. A warning
|
||||
/// on a file of zeros is the one that teaches users to ignore the warning
|
||||
/// that matters.
|
||||
@visibleForTesting
|
||||
Future<void> showRepairOutcomeDialog(
|
||||
BuildContext context,
|
||||
@@ -492,8 +528,10 @@ Future<void> showRepairOutcomeDialog(
|
||||
Text(t.startup.backupTitle, style: Theme.of(dialogContext).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(backupPath, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
|
||||
if (outcome.backupHoldsCredentials) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
DialogActionButton(
|
||||
label: t.startup.deleteBackup,
|
||||
@@ -1434,6 +1472,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
registry: context.read<ProfileRegistry>(),
|
||||
plexHome: context.read<PlexHomeService>(),
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
storage: context.read<StorageService>(),
|
||||
);
|
||||
unawaited(provider.initialize());
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'media_backend.dart';
|
||||
import 'media_kind.dart';
|
||||
import 'media_role.dart';
|
||||
import 'media_version.dart';
|
||||
import 'media_rating.dart';
|
||||
|
||||
part 'media_item.freezed.dart';
|
||||
part 'media_item.g.dart';
|
||||
@@ -66,6 +67,7 @@ sealed class MediaItem with _$MediaItem {
|
||||
int? addedAt,
|
||||
int? updatedAt,
|
||||
double? rating,
|
||||
List<MediaRatingSource>? ratings,
|
||||
double? userRating,
|
||||
bool? isFavorite,
|
||||
List<String>? genres,
|
||||
@@ -128,6 +130,7 @@ sealed class MediaItem with _$MediaItem {
|
||||
addedAt: addedAt,
|
||||
updatedAt: updatedAt,
|
||||
rating: rating,
|
||||
ratings: ratings,
|
||||
userRating: userRating,
|
||||
isFavorite: isFavorite,
|
||||
genres: genres,
|
||||
@@ -189,6 +192,7 @@ sealed class MediaItem with _$MediaItem {
|
||||
addedAt: addedAt,
|
||||
updatedAt: updatedAt,
|
||||
rating: rating,
|
||||
ratings: ratings,
|
||||
userRating: userRating,
|
||||
isFavorite: isFavorite,
|
||||
genres: genres,
|
||||
@@ -257,11 +261,14 @@ sealed class MediaItem with _$MediaItem {
|
||||
@JsonKey(fromJson: flexibleInt) int? addedAt,
|
||||
@JsonKey(fromJson: flexibleInt) int? updatedAt,
|
||||
@JsonKey(fromJson: flexibleDouble) double? rating,
|
||||
@JsonKey(fromJson: flexibleDouble) double? audienceRating,
|
||||
@JsonKey(fromJson: flexibleDouble) double? userRating,
|
||||
|
||||
/// Every attributed score the response carried, headline first. Plex
|
||||
/// listings yield one or two (the `rating`/`audienceRating` pair with
|
||||
/// their source images); `/library/metadata/{id}` adds the `Rating[]`
|
||||
/// array, so IMDb and TMDB join Rotten Tomatoes on detail screens.
|
||||
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
|
||||
bool? isFavorite,
|
||||
String? ratingImage,
|
||||
String? audienceRatingImage,
|
||||
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
|
||||
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
|
||||
@JsonKey(fromJson: _mediaItemStringList) List<String>? writers,
|
||||
@@ -335,6 +342,10 @@ sealed class MediaItem with _$MediaItem {
|
||||
@JsonKey(fromJson: flexibleInt) int? updatedAt,
|
||||
@JsonKey(fromJson: flexibleDouble) double? rating,
|
||||
@JsonKey(fromJson: flexibleDouble) double? userRating,
|
||||
|
||||
/// `CommunityRating` and the `CriticRating` Tomatometer, in that order.
|
||||
/// Jellyfin exposes no per-source array, so this is at most two entries.
|
||||
@JsonKey(fromJson: _mediaItemRatingsFromJson) List<MediaRatingSource>? ratings,
|
||||
bool? isFavorite,
|
||||
@JsonKey(fromJson: _mediaItemStringList) List<String>? genres,
|
||||
@JsonKey(fromJson: _mediaItemStringList) List<String>? directors,
|
||||
@@ -749,4 +760,13 @@ List<MediaVersion>? _mediaItemVersionsFromJson(Object? raw) {
|
||||
: null;
|
||||
}
|
||||
|
||||
List<MediaRatingSource>? _mediaItemRatingsFromJson(Object? raw) {
|
||||
return raw is List
|
||||
? [
|
||||
for (final rating in raw)
|
||||
if (rating is Map<String, Object?>) ?MediaRatingSource.fromJson(rating),
|
||||
]
|
||||
: null;
|
||||
}
|
||||
|
||||
Map<String, Object?>? _mediaItemRawFromJson(Object? raw) => raw is Map ? Map<String, Object?>.from(raw) : null;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,11 +51,9 @@ PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
rating: flexibleDouble(json['rating']),
|
||||
audienceRating: flexibleDouble(json['audienceRating']),
|
||||
userRating: flexibleDouble(json['userRating']),
|
||||
ratings: _mediaItemRatingsFromJson(json['ratings']),
|
||||
isFavorite: json['isFavorite'] as bool?,
|
||||
ratingImage: json['ratingImage'] as String?,
|
||||
audienceRatingImage: json['audienceRatingImage'] as String?,
|
||||
genres: _mediaItemStringList(json['genres']),
|
||||
directors: _mediaItemStringList(json['directors']),
|
||||
writers: _mediaItemStringList(json['writers']),
|
||||
@@ -123,11 +121,9 @@ Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
|
||||
'addedAt': ?instance.addedAt,
|
||||
'updatedAt': ?instance.updatedAt,
|
||||
'rating': ?instance.rating,
|
||||
'audienceRating': ?instance.audienceRating,
|
||||
'userRating': ?instance.userRating,
|
||||
'ratings': ?instance.ratings?.map((e) => e.toJson()).toList(),
|
||||
'isFavorite': ?instance.isFavorite,
|
||||
'ratingImage': ?instance.ratingImage,
|
||||
'audienceRatingImage': ?instance.audienceRatingImage,
|
||||
'genres': ?instance.genres,
|
||||
'directors': ?instance.directors,
|
||||
'writers': ?instance.writers,
|
||||
@@ -200,6 +196,7 @@ JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
rating: flexibleDouble(json['rating']),
|
||||
userRating: flexibleDouble(json['userRating']),
|
||||
ratings: _mediaItemRatingsFromJson(json['ratings']),
|
||||
isFavorite: json['isFavorite'] as bool?,
|
||||
genres: _mediaItemStringList(json['genres']),
|
||||
directors: _mediaItemStringList(json['directors']),
|
||||
@@ -262,6 +259,7 @@ Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
|
||||
'updatedAt': ?instance.updatedAt,
|
||||
'rating': ?instance.rating,
|
||||
'userRating': ?instance.userRating,
|
||||
'ratings': ?instance.ratings?.map((e) => e.toJson()).toList(),
|
||||
'isFavorite': ?instance.isFavorite,
|
||||
'genres': ?instance.genres,
|
||||
'directors': ?instance.directors,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'ids.dart';
|
||||
import 'media_item.dart';
|
||||
import 'media_version.dart';
|
||||
|
||||
/// Merge freshly fetched metadata with identity and library context already
|
||||
/// known by the caller. The fetched item owns descriptive fields, while
|
||||
@@ -12,3 +13,63 @@ MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId f
|
||||
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tallest version height an item exposes, or 0 when the backend reported
|
||||
/// none.
|
||||
int _bestResolutionHeight(MediaItem item) {
|
||||
var best = 0;
|
||||
for (final version in item.mediaVersions ?? const <MediaVersion>[]) {
|
||||
final height = version.resolutionHeight;
|
||||
if (height != null && height > best) best = height;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Order the library copies of one title best-first: highest resolution, then
|
||||
/// library title, then server name, then global key.
|
||||
///
|
||||
/// Total and derived purely from the items, so a chooser can re-sort after
|
||||
/// merging a later resolution pass without its rows jumping around.
|
||||
int compareLibraryCopies(MediaItem a, MediaItem b) {
|
||||
final byResolution = _bestResolutionHeight(b).compareTo(_bestResolutionHeight(a));
|
||||
if (byResolution != 0) return byResolution;
|
||||
final byLibrary = (a.libraryTitle ?? '').compareTo(b.libraryTitle ?? '');
|
||||
if (byLibrary != 0) return byLibrary;
|
||||
final byServer = (a.serverName ?? '').compareTo(b.serverName ?? '');
|
||||
if (byServer != 0) return byServer;
|
||||
return a.globalKey.compareTo(b.globalKey);
|
||||
}
|
||||
|
||||
/// Fold a re-resolved copy into the one already on screen.
|
||||
///
|
||||
/// The addition is fresher, but a degraded pass must not erase context. The
|
||||
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
|
||||
/// hands back an unstamped item when it fails, and a copy that lost its
|
||||
/// library title is indistinguishable from its sibling in the same server's
|
||||
/// other library — exactly the ambiguity the chooser exists to resolve. The
|
||||
/// version list behind the resolution hint is treated the same way.
|
||||
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
|
||||
final versions = addition.mediaVersions;
|
||||
return addition.copyWith(
|
||||
libraryId: addition.libraryId ?? existing.libraryId,
|
||||
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
|
||||
serverName: addition.serverName ?? existing.serverName,
|
||||
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
|
||||
///
|
||||
/// Never removes a copy, and never downgrades one. The cross-server fan-out
|
||||
/// behind these lists logs and skips per-server failures, so a later pass can
|
||||
/// legitimately come back short a server, or short the best-effort library
|
||||
/// stamp of a copy it did return.
|
||||
List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaItem> additions) {
|
||||
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
|
||||
for (final item in additions) {
|
||||
final existing = byKey[item.globalKey];
|
||||
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
|
||||
}
|
||||
return byKey.values.toList()..sort(compareLibraryCopies);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ enum MediaKind {
|
||||
|
||||
bool get isPlayable => isVideo || this == track;
|
||||
|
||||
/// Whether this kind maps to one or more files on disk, i.e. the server can
|
||||
/// answer `MediaServerClient.getFileInfo` for it.
|
||||
///
|
||||
/// Deliberately not folded into [isPlayable] even though the member sets
|
||||
/// coincide today: container kinds (show, season, artist, album, collection,
|
||||
/// playlist, folder) aggregate leaves and carry no `Media`/`MediaSources` of
|
||||
/// their own, while a non-playable kind can still be file-backed.
|
||||
bool get hasFileInfo => this == movie || this == episode || this == track || this == clip;
|
||||
|
||||
/// Whether this container kind derives watched state from aggregate leaves.
|
||||
bool get usesLeafWatchCounts => switch (this) {
|
||||
show || season || artist || album || collection || playlist || folder => true,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/// One attributed score for a media item.
|
||||
///
|
||||
/// Backends routinely return several: Plex's `/library/metadata/{id}` carries
|
||||
/// an IMDb score next to both Rotten Tomatoes panels and TMDB, Simkl carries
|
||||
/// Simkl/IMDb/MAL side by side, Jellyfin carries a community score next to a
|
||||
/// Tomatometer. `MediaItem.rating` keeps the backend's headline number; this
|
||||
/// list keeps every score with attribution so the UI can badge and label them.
|
||||
///
|
||||
/// Serialization is hand-written rather than `json_serializable` because
|
||||
/// catalog objects round-trip through `MediaItem.raw` maps rather than a
|
||||
/// generated codec.
|
||||
library;
|
||||
|
||||
class MediaRatingSource {
|
||||
/// Stable, lowercase source key: `imdb`, `tmdb`, `rottenTomatoesCritic`,
|
||||
/// `rottenTomatoesAudience`, `simkl`, `mal`, `anilist`, `trakt`, `critic`,
|
||||
/// `audience`. Rendered through a label map, never raw.
|
||||
final String source;
|
||||
|
||||
/// Normalized to 0-10 by the mapper, matching `MediaItem.rating`. Percentage
|
||||
/// sources (Rotten Tomatoes, TMDB) are rendered back as `value * 10`%.
|
||||
final double value;
|
||||
|
||||
/// How many users the score is based on, when the backend says.
|
||||
final int? votes;
|
||||
|
||||
const MediaRatingSource({required this.source, required this.value, this.votes});
|
||||
|
||||
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
|
||||
|
||||
static MediaRatingSource? fromJson(Map<String, Object?> json) {
|
||||
final source = json['source'] as String?;
|
||||
final value = (json['value'] as num?)?.toDouble();
|
||||
if (source == null || value == null) return null;
|
||||
return MediaRatingSource(source: source, value: value, votes: json['votes'] as int?);
|
||||
}
|
||||
}
|
||||
@@ -167,13 +167,24 @@ abstract class MediaServerClient {
|
||||
Future<MediaItem?> fetchItem(String id);
|
||||
|
||||
/// Fetch a single item *and* its on-deck episode (the next unwatched /
|
||||
/// in-progress episode) in one round-trip when the backend supports it.
|
||||
/// The item follows [fetchItem]'s error contract: an online HTTP 404 returns
|
||||
/// both nullable fields as `null`, while every other HTTP status throws.
|
||||
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
|
||||
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
|
||||
/// leaving callers to fetch on-deck separately if they need it.
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id);
|
||||
/// in-progress episode). The item follows [fetchItem]'s error contract: an
|
||||
/// online HTTP 404 returns both nullable fields as `null`, while every other
|
||||
/// HTTP status throws.
|
||||
///
|
||||
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`. Jellyfin
|
||||
/// has no equivalent endpoint and needs a second request for on-deck, so it
|
||||
/// would otherwise hold the item behind a round trip the detail screen does
|
||||
/// not need in order to paint.
|
||||
///
|
||||
/// [onItemReady] exists for exactly that case: implementations invoke it as
|
||||
/// soon as the item is known, *if* that is strictly before the on-deck
|
||||
/// lookup finishes. Backends that return both together never invoke it, and
|
||||
/// neither does a null item. Callers must therefore treat it as an optional
|
||||
/// early paint and still handle the returned record.
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
|
||||
String id, {
|
||||
void Function(MediaItem item)? onItemReady,
|
||||
});
|
||||
|
||||
/// Direct children of [parentId] — episodes of a season, seasons of a
|
||||
/// show, tracks of an album, items of a collection.
|
||||
@@ -272,7 +283,18 @@ abstract class MediaServerClient {
|
||||
/// candidate budget; a backend may supplement omitted media categories and
|
||||
/// return more candidates for cross-server ranking. [abort] cancels every
|
||||
/// backend request owned by this search pass.
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
|
||||
///
|
||||
/// [excludedLibraryIds] names server-local libraries the user has hidden. A
|
||||
/// backend whose search rows carry a library id may ignore it, because the
|
||||
/// caller drops those rows by id. A backend whose rows cannot be attributed
|
||||
/// to a library MUST scope the request server-side instead — the caller has
|
||||
/// nothing to filter on.
|
||||
Future<List<MediaItem>> searchItems(
|
||||
String query, {
|
||||
int limit = 100,
|
||||
AbortController? abort,
|
||||
Set<String> excludedLibraryIds = const {},
|
||||
});
|
||||
|
||||
/// Items the user has started but not finished. Plex calls this "On Deck"
|
||||
/// internally; the neutral name matches the Continue Watching UI surface.
|
||||
@@ -504,7 +526,7 @@ abstract class MediaServerClient {
|
||||
/// has no external mapping for the item.
|
||||
Future<ExternalIds> fetchExternalIds(String itemId);
|
||||
|
||||
/// Reverse lookup: find a library movie/show matching any of [ids].
|
||||
/// Reverse lookup: find every library movie/show matching any of [ids].
|
||||
///
|
||||
/// Neither backend can filter by external id — Plex's `guid=` matches only
|
||||
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
|
||||
@@ -512,7 +534,16 @@ abstract class MediaServerClient {
|
||||
/// title and verify candidates against their exact external ids. Title
|
||||
/// alone never produces a match.
|
||||
///
|
||||
/// [titles] are tried in order until one yields an id-verified candidate;
|
||||
/// One title can own several library items: a server with a 4K section and
|
||||
/// an HD section holds two rating keys for the same movie, and a library
|
||||
/// still on a legacy agent carries a different primary guid than its modern
|
||||
/// sibling. Implementations MUST return every id-verified copy rather than
|
||||
/// the first, and MUST NOT truncate — external-id verification is the only
|
||||
/// bound, so a long list means the user genuinely owns that many copies and
|
||||
/// the caller (the Explore "In these libraries" chooser) exists to show
|
||||
/// them. Ordering is the implementation's, and callers re-sort.
|
||||
///
|
||||
/// [titles] are tried in order until one yields id-verified candidates;
|
||||
/// pass the entry's own title first and broader forms after (see
|
||||
/// `titleMatchCandidates`). A sequel entry's own title never matches its
|
||||
/// parent show, which is why more than one is needed. [year] applies a ±1
|
||||
@@ -520,22 +551,21 @@ abstract class MediaServerClient {
|
||||
/// season's, not the show's.
|
||||
///
|
||||
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
|
||||
/// already holds, which the local server *can* filter on exactly, skipping
|
||||
/// the title search. It is never resolved over the network — only Plex
|
||||
/// Discover catalog items carry one, in their own rating key. Other backends
|
||||
/// ignore it.
|
||||
/// already holds, which the local server *can* filter on exactly. It is
|
||||
/// never resolved over the network — only Plex Discover catalog items carry
|
||||
/// one, in their own rating key. Other backends ignore it.
|
||||
///
|
||||
/// [season] gates the result: when the entry maps to season 2+ of a longer
|
||||
/// series, a match is only returned if the server actually has that season.
|
||||
/// [season] gates the results: when the entry maps to season 2+ of a longer
|
||||
/// series, a candidate is only kept if the server actually has that season.
|
||||
/// Implementations MUST gate only on [ExternalSeasonRef.agreedSeason] — the
|
||||
/// provider a library numbers its seasons by is a server-side setting no
|
||||
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
|
||||
/// on a guess.
|
||||
///
|
||||
/// Returns null when this server has no match or [kind] is not movie/show.
|
||||
/// Used to match external catalog items (Explore tab) back to the user's
|
||||
/// libraries.
|
||||
Future<MediaItem?> findByExternalIds(
|
||||
/// Returns an empty list when this server has no match or [kind] is not
|
||||
/// movie/show. Used to match external catalog items (Explore tab) back to
|
||||
/// the user's libraries.
|
||||
Future<List<MediaItem>> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> titles = const [],
|
||||
@@ -716,20 +746,41 @@ extension MediaServerClientScope on MediaServerClient {
|
||||
_ => serverId,
|
||||
};
|
||||
|
||||
/// Mark [item] watched because it crossed [watchedThreshold] during playback,
|
||||
/// when a playback-stopped report is/was also sent for the same playback.
|
||||
/// Mark [item] watched because playback crossed [watchedThreshold], for paths
|
||||
/// where the backend cannot mark it from the playback reports themselves:
|
||||
/// queued offline replay, external players, Plex same-file siblings, and
|
||||
/// in-player sessions whose crossing the backend never observed.
|
||||
///
|
||||
/// Backends that mark played from the stop report
|
||||
/// ([marksWatchedOnPlaybackStopped]) skip the server call — issuing
|
||||
/// [markWatched] too would double-scrobble via the Jellyfin Trakt plugin
|
||||
/// (#1287). The single local event emitted here keeps the UI and Plezy's
|
||||
/// own Trakt sync (which key on `watched` events, not progress) in sync;
|
||||
/// the stop report syncs the server.
|
||||
///
|
||||
/// In-player sessions that *did* give the backend an observable crossing use
|
||||
/// [notifyWatchedFromPlaybackSession] instead.
|
||||
Future<void> markWatchedFromPlaybackStop(MediaItem item) async {
|
||||
if (!marksWatchedOnPlaybackStopped) {
|
||||
await markWatched(item);
|
||||
}
|
||||
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
|
||||
}
|
||||
|
||||
/// Emit the local watched event for [item] without touching the server.
|
||||
///
|
||||
/// Used when a live playback-reporting session has already given the backend
|
||||
/// everything it needs to mark the item itself — a report below
|
||||
/// [watchedThreshold] followed by one at or above it. Both backends act on
|
||||
/// that crossing: Jellyfin through `/Sessions/Playing/Stopped`
|
||||
/// (`MaxResumePct`), Plex through `/:/timeline` past
|
||||
/// `LibraryVideoPlayedThreshold`. Adding an explicit [markWatched] on top
|
||||
/// records the same watch twice — a second Trakt-plugin scrobble on Jellyfin
|
||||
/// (#1287), a second Play History row and an inflated `viewCount` on Plex
|
||||
/// (#1740).
|
||||
void notifyWatchedFromPlaybackSession(MediaItem item) {
|
||||
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaServerClientLiveTv on MediaServerClient {
|
||||
@@ -752,6 +803,27 @@ abstract interface class SeasonEpisodePagingClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Optional capability for clients whose server can answer "may the signed-in
|
||||
/// user delete *this* item?" per item.
|
||||
///
|
||||
/// Jellyfin-only by nature: `BaseItemDto.CanDelete` folds the global
|
||||
/// `EnableContentDeletion` grant, the per-library
|
||||
/// `EnableContentDeletionFromFolders` grant, and item state (virtual/missing
|
||||
/// files, in-progress recordings) into one server-computed boolean — none of
|
||||
/// which a client can reproduce. Plex exposes no per-item delete permission,
|
||||
/// so it deliberately does not implement this and callers keep using their
|
||||
/// account-level owner/admin gate for it.
|
||||
abstract interface class MediaDeletionPermissionClient {
|
||||
/// `true`/`false` as reported by the server for [item], or `null` when the
|
||||
/// server did not answer (item not visible to this user, unexpected shape).
|
||||
///
|
||||
/// Never served from cache: the answer changes server-side with no
|
||||
/// client-visible event, and a stale `true` puts a destructive action back in
|
||||
/// front of a user who lost the grant. Callers must fail closed on `null`
|
||||
/// and on throw.
|
||||
Future<bool?> fetchDeletePermission(MediaItem item);
|
||||
}
|
||||
|
||||
/// Cache-aware fetch helpers shared by both backends so the offline-first /
|
||||
/// network-then-cache pattern lives in one place.
|
||||
///
|
||||
|
||||
@@ -68,6 +68,25 @@ class MediaVersion {
|
||||
/// them when metadata is fetched with `checkFiles=1`.
|
||||
bool get isPlayable => parts.isEmpty || parts.any((part) => part.isPlayable);
|
||||
|
||||
/// Approximate vertical resolution, for ordering versions best-first.
|
||||
///
|
||||
/// Plex reports either a numeric height (`"1080"`) or a named tier
|
||||
/// (`"sd"`, `"4k"`) and usually both; Jellyfin reports [height] directly.
|
||||
/// Null when the backend gave neither.
|
||||
int? get resolutionHeight {
|
||||
final reported = height;
|
||||
if (reported != null && reported > 0) return reported;
|
||||
final named = (videoResolution ?? '').trim().toLowerCase();
|
||||
return switch (named) {
|
||||
'' => null,
|
||||
'sd' => 480,
|
||||
'hd' => 720,
|
||||
'4k' => 2160,
|
||||
'8k' => 4320,
|
||||
_ => int.tryParse(named),
|
||||
};
|
||||
}
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)".
|
||||
/// When [name] is set, it prefixes the technical label so a user can tell
|
||||
/// "Director's Cut · 1080p H.264 MKV" apart from "Theatrical Cut · 1080p
|
||||
|
||||
@@ -4,6 +4,16 @@ mixin Refreshable {
|
||||
|
||||
mixin FullRefreshable {
|
||||
void fullRefresh();
|
||||
|
||||
/// Online-entry variant, used by `main_screen._primeOnlineServices` on cold
|
||||
/// start and on reconnect-from-offline. Its job is to guarantee the tab
|
||||
/// loads once servers are up — not to force a refetch.
|
||||
///
|
||||
/// Screens that already kick off their own load in `initState` override this
|
||||
/// to skip while that pass is still in flight; otherwise the prime queues an
|
||||
/// identical trailing pass and the tab fetches everything twice (#1784).
|
||||
/// Profile switches go through [fullRefresh] instead, which always refetches.
|
||||
void primeRefresh() => fullRefresh();
|
||||
}
|
||||
|
||||
mixin FocusableTab {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../../media/media_backend.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_rating.dart';
|
||||
import '../../utils/external_ids.dart';
|
||||
import 'catalog_cast_member.dart';
|
||||
import 'catalog_metadata.dart';
|
||||
@@ -213,7 +214,7 @@ class CatalogItem {
|
||||
final String? accentColor;
|
||||
|
||||
/// Scores beyond [rating], each with its own source label.
|
||||
final List<CatalogRatingSource>? ratings;
|
||||
final List<MediaRatingSource>? ratings;
|
||||
|
||||
/// Leaderboard positions. A list because providers return several windows
|
||||
/// at once (all-time and seasonal).
|
||||
@@ -493,6 +494,7 @@ class CatalogItem {
|
||||
contentRating: certification,
|
||||
durationMs: runtimeMinutes == null ? null : Duration(minutes: runtimeMinutes!).inMilliseconds,
|
||||
rating: rating,
|
||||
ratings: ratings,
|
||||
genres: genres,
|
||||
thumbPath: posterUrl,
|
||||
artPath: backdropUrl,
|
||||
@@ -594,7 +596,7 @@ class CatalogItem {
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
bannerUrl: json['bannerUrl'] as String?,
|
||||
accentColor: json['accentColor'] as String?,
|
||||
ratings: decodeCatalogList(json['ratings'], CatalogRatingSource.fromJson),
|
||||
ratings: decodeCatalogList(json['ratings'], MediaRatingSource.fromJson),
|
||||
ranks: decodeCatalogList(json['ranks'], CatalogRank.fromJson),
|
||||
audience: _decodeObject(json['audience'], CatalogAudience.fromJson),
|
||||
broadcast: _decodeObject(json['broadcast'], CatalogBroadcast.fromJson),
|
||||
|
||||
@@ -8,35 +8,6 @@
|
||||
/// round-trip through `MediaItem.raw` inside a single session.
|
||||
library;
|
||||
|
||||
/// One provider score with its own source label.
|
||||
///
|
||||
/// Providers routinely return several: Simkl carries Simkl/IMDb/MAL side by
|
||||
/// side, Plex carries a critic score next to an audience score. The neutral
|
||||
/// [CatalogItem.rating] keeps the provider's headline number; this list keeps
|
||||
/// the rest with attribution so the UI can label them.
|
||||
class CatalogRatingSource {
|
||||
/// Stable, lowercase source key: `simkl`, `imdb`, `mal`, `anilist`, `tmdb`,
|
||||
/// `trakt`, `critic`, `audience`. Rendered through a label map, never raw.
|
||||
final String source;
|
||||
|
||||
/// Normalized to 0-10 by the mapper, matching [CatalogItem.rating].
|
||||
final double value;
|
||||
|
||||
/// How many users the score is based on, when the provider says.
|
||||
final int? votes;
|
||||
|
||||
const CatalogRatingSource({required this.source, required this.value, this.votes});
|
||||
|
||||
Map<String, Object?> toJson() => {'source': source, 'value': value, if (votes != null) 'votes': votes};
|
||||
|
||||
static CatalogRatingSource? fromJson(Map<String, Object?> json) {
|
||||
final source = json['source'] as String?;
|
||||
final value = (json['value'] as num?)?.toDouble();
|
||||
if (source == null || value == null) return null;
|
||||
return CatalogRatingSource(source: source, value: value, votes: json['votes'] as int?);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a leaderboard position is a position *in*.
|
||||
///
|
||||
/// MAL returns one rank per ranking endpoint, AniList returns a `rankings`
|
||||
|
||||
@@ -10,8 +10,6 @@ class PlexVideoPlaybackData {
|
||||
|
||||
final List<MediaVersion> availableVersions;
|
||||
|
||||
final List<MediaMarker> markers;
|
||||
|
||||
final int selectedMediaIndex;
|
||||
|
||||
final int selectedPartIndex;
|
||||
@@ -20,7 +18,6 @@ class PlexVideoPlaybackData {
|
||||
required this.videoUrl,
|
||||
required this.mediaInfo,
|
||||
required this.availableVersions,
|
||||
this.markers = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedPartIndex = 0,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,13 @@ class PlayerAndroid extends PlayerBase {
|
||||
|
||||
bool get usingMpvFallback => _usingMpvFallback;
|
||||
|
||||
/// Subtitles are hidden through the player's visibility toggle. Sticky
|
||||
/// across media opens, like mpv's global `sub-visibility`, so an episode
|
||||
/// change cannot put them back on screen.
|
||||
bool _subtitlesHidden = false;
|
||||
|
||||
/// Track that un-hiding restores: whatever was selected when subtitles were
|
||||
/// hidden, then whatever was selected for the current media while hidden.
|
||||
String? _hiddenSubtitleTrackId;
|
||||
|
||||
@override
|
||||
@@ -248,14 +255,36 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('selectAudioTrack', {'trackId': track.id});
|
||||
}
|
||||
|
||||
/// ExoPlayer has no renderer-level subtitle visibility switch, so hiding is
|
||||
/// implemented as deselection (see [setProperty]'s `sub-visibility` case).
|
||||
/// A selection arriving while subtitles are hidden — the automatic pass
|
||||
/// after an episode change, or a manual pick — becomes what un-hiding
|
||||
/// restores instead of putting subtitles back on screen, matching how mpv's
|
||||
/// global `sub-visibility` keeps hiding across files (#1779).
|
||||
@override
|
||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||
if (_subtitlesHidden) {
|
||||
_hiddenSubtitleTrackId = track.id == SubtitleTrack.off.id ? null : track.id;
|
||||
return _selectSubtitleTrackNatively(SubtitleTrack.off);
|
||||
}
|
||||
return _selectSubtitleTrackNatively(track);
|
||||
}
|
||||
|
||||
Future<void> _selectSubtitleTrackNatively(SubtitleTrack track) async {
|
||||
await invoke('selectSubtitleTrack', {'trackId': track.id});
|
||||
}
|
||||
|
||||
/// A sidecar flagged default must not draw itself onto a hidden renderer
|
||||
/// either; the selection pass that follows the add records it the same way
|
||||
/// [selectSubtitleTrack] does.
|
||||
@override
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
|
||||
await invoke('addSubtitleTrack', {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'language': language,
|
||||
'select': select && !_subtitlesHidden,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -307,19 +336,21 @@ class PlayerAndroid extends PlayerBase {
|
||||
break;
|
||||
case 'sub-visibility':
|
||||
if (value == 'no') {
|
||||
if (_subtitlesHidden) break;
|
||||
_subtitlesHidden = true;
|
||||
final current = state.track.subtitle;
|
||||
if (current != null && current.id != 'no') {
|
||||
_hiddenSubtitleTrackId = current.id;
|
||||
await selectSubtitleTrack(SubtitleTrack.off);
|
||||
_hiddenSubtitleTrackId = current != null && current.id != SubtitleTrack.off.id ? current.id : null;
|
||||
if (_hiddenSubtitleTrackId != null) {
|
||||
await _selectSubtitleTrackNatively(SubtitleTrack.off);
|
||||
}
|
||||
} else {
|
||||
if (!_subtitlesHidden) break;
|
||||
_subtitlesHidden = false;
|
||||
final storedId = _hiddenSubtitleTrackId;
|
||||
if (storedId != null) {
|
||||
_hiddenSubtitleTrackId = null;
|
||||
final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
|
||||
if (track != null) {
|
||||
await selectSubtitleTrack(track);
|
||||
}
|
||||
_hiddenSubtitleTrackId = null;
|
||||
final track = storedId == null ? null : state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
|
||||
if (track != null) {
|
||||
await _selectSubtitleTrackNatively(track);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -11,6 +11,9 @@ import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'plex_home_service.dart';
|
||||
import 'profile.dart';
|
||||
import 'profile_avatar_source.dart';
|
||||
import 'profile_connection.dart';
|
||||
import 'profile_connection_registry.dart';
|
||||
import 'profile_merge.dart';
|
||||
import 'profile_registry.dart';
|
||||
|
||||
@@ -27,6 +30,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
required this._registry,
|
||||
required this._plexHome,
|
||||
required this._connections,
|
||||
required this._profileConnections,
|
||||
this._storage,
|
||||
this._activeProfileIdWriter,
|
||||
});
|
||||
@@ -34,6 +38,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
final ProfileRegistry _registry;
|
||||
final PlexHomeService _plexHome;
|
||||
final ConnectionRegistry _connections;
|
||||
final ProfileConnectionRegistry _profileConnections;
|
||||
StorageService? _storage;
|
||||
final Future<void> Function(String profileId)? _activeProfileIdWriter;
|
||||
|
||||
@@ -42,9 +47,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
List<Profile> _localProfiles = const [];
|
||||
Map<String, List<PlexHomeUser>> _plexHomeUsers = const {};
|
||||
Map<String, Connection> _connectionsById = const {};
|
||||
Map<String, List<ProfileConnection>> _connectionsByProfile = const {};
|
||||
Map<String, String?> _avatarUrls = const {};
|
||||
|
||||
StreamSubscription<List<Profile>>? _localSub;
|
||||
StreamSubscription<List<Connection>>? _connSub;
|
||||
StreamSubscription<List<ProfileConnection>>? _pcSub;
|
||||
StreamSubscription<Map<String, List<PlexHomeUser>>>? _plexHomeSub;
|
||||
Future<void>? _initializeFuture;
|
||||
bool _initialized = false;
|
||||
@@ -61,6 +69,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
Profile? get active => _active;
|
||||
String? get activeId => _active?.id;
|
||||
List<Profile> get profiles => _profiles;
|
||||
|
||||
/// Derived picture URL for [profileId], or null when initials should render.
|
||||
String? avatarUrlFor(String profileId) => _avatarUrls[profileId];
|
||||
|
||||
bool get hasMultipleProfiles => _profiles.length > 1;
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
@@ -170,6 +182,14 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
_resolveActive();
|
||||
safeNotifyListeners();
|
||||
});
|
||||
_pcSub = _profileConnections.watchAll().listen((list) {
|
||||
final byProfile = groupConnectionsByProfile(list);
|
||||
if (_sameProfileConnections(byProfile, _connectionsByProfile)) return;
|
||||
_connectionsByProfile = byProfile;
|
||||
_recomputeProfiles();
|
||||
_resolveActive();
|
||||
safeNotifyListeners();
|
||||
});
|
||||
_plexHomeSub = _plexHome.stream.listen((cache) {
|
||||
if (_samePlexHomeUsers(cache, _plexHomeUsers)) return;
|
||||
_plexHomeUsers = cache;
|
||||
@@ -191,24 +211,56 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
_localProfiles = await _registry.list();
|
||||
final initialConns = await _connections.list();
|
||||
_connectionsById = {for (final c in initialConns) c.id: c};
|
||||
_connectionsByProfile = groupConnectionsByProfile(await _profileConnections.listAll());
|
||||
_plexHomeUsers = _plexHome.current;
|
||||
_recomputeProfiles();
|
||||
_resolveActive();
|
||||
}
|
||||
|
||||
/// [Connection] has no value equality; its persisted config is the
|
||||
/// cheapest faithful comparison key for the handful of rows involved.
|
||||
/// [Connection] has no value equality; its persisted config is the cheapest
|
||||
/// faithful comparison key for the handful of rows involved.
|
||||
///
|
||||
/// `createdAt` is compared separately because it is a real column rather
|
||||
/// than part of `toConfigJson`, and avatar selection reads it: a profile
|
||||
/// shows the picture of its oldest linked connection. `ConnectionRegistry`
|
||||
/// pins creation order across re-authentication, so this costs no extra
|
||||
/// notifications — it only stops a genuine correction (a restore, a
|
||||
/// backfill) from being swallowed until the next launch.
|
||||
static bool _sameConnections(Map<String, Connection> a, Map<String, Connection> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final entry in a.entries) {
|
||||
final other = b[entry.key];
|
||||
if (other == null) return false;
|
||||
if (identical(entry.value, other)) continue;
|
||||
if (entry.value.createdAt != other.createdAt) return false;
|
||||
if (jsonEncode(entry.value.toConfigJson()) != jsonEncode(other.toConfigJson())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool _sameProfileConnections(Map<String, List<ProfileConnection>> a, Map<String, List<ProfileConnection>> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final entry in a.entries) {
|
||||
final other = b[entry.key];
|
||||
if (other == null || entry.value.length != other.length) return false;
|
||||
for (final row in entry.value) {
|
||||
var hasSameFingerprint = false;
|
||||
for (final candidate in other) {
|
||||
if (row.profileId == candidate.profileId &&
|
||||
row.connectionId == candidate.connectionId &&
|
||||
row.userIdentifier == candidate.userIdentifier) {
|
||||
hasSameFingerprint = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Only these identity fields affect avatar selection. Deliberately
|
||||
// ignore tokens, default status, and binder-maintained timestamps.
|
||||
if (!hasSameFingerprint) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool _samePlexHomeUsers(Map<String, List<PlexHomeUser>> a, Map<String, List<PlexHomeUser>> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final entry in a.entries) {
|
||||
@@ -225,6 +277,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
connectionsById: _connectionsById,
|
||||
storage: _storage,
|
||||
);
|
||||
_avatarUrls = resolveProfileAvatarUrls(
|
||||
profiles: _profiles,
|
||||
connectionsByProfile: _connectionsByProfile,
|
||||
connectionsById: _connectionsById,
|
||||
plexHomeByConnectionId: _plexHomeUsers,
|
||||
);
|
||||
}
|
||||
|
||||
void _resolveActive() {
|
||||
@@ -487,13 +545,17 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
await _localSub?.cancel();
|
||||
await _connSub?.cancel();
|
||||
await _plexHomeSub?.cancel();
|
||||
await _pcSub?.cancel();
|
||||
_localSub = null;
|
||||
_connSub = null;
|
||||
_plexHomeSub = null;
|
||||
_pcSub = null;
|
||||
_profiles = const [];
|
||||
_localProfiles = const [];
|
||||
_plexHomeUsers = const {};
|
||||
_connectionsById = const {};
|
||||
_connectionsByProfile = const {};
|
||||
_avatarUrls = const {};
|
||||
_active = null;
|
||||
_initializeFuture = null;
|
||||
_initialized = false;
|
||||
@@ -525,6 +587,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
_localSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
_plexHomeSub?.cancel();
|
||||
_pcSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,19 @@ class ProfileAvatar extends StatelessWidget {
|
||||
final double size;
|
||||
final bool showLockBadge;
|
||||
|
||||
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true});
|
||||
/// The derived picture for this profile (see [resolveProfileAvatarUrls]).
|
||||
///
|
||||
/// Falls back to [Profile.avatarThumbUrl] when null.
|
||||
final String? avatarUrl;
|
||||
|
||||
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true, this.avatarUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final p = profile;
|
||||
final lockBadgeSize = size * 0.34;
|
||||
final memCacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
@@ -27,7 +33,7 @@ class ProfileAvatar extends StatelessWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ClipOval(
|
||||
child: SizedBox(width: size, height: size, child: _buildContent(theme, p)),
|
||||
child: SizedBox(width: size, height: size, child: _buildContent(theme, p, memCacheSize)),
|
||||
),
|
||||
if (showLockBadge && p != null && p.isPinProtected)
|
||||
Positioned(
|
||||
@@ -55,16 +61,21 @@ class ProfileAvatar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ThemeData theme, Profile? p) {
|
||||
Widget _buildContent(ThemeData theme, Profile? p, int memCacheSize) {
|
||||
if (p == null) {
|
||||
return Container(color: theme.colorScheme.surfaceContainerHighest);
|
||||
}
|
||||
final thumb = p.avatarThumbUrl;
|
||||
// An empty override means "nothing derived", not "suppress the profile's
|
||||
// own picture" — same absent-or-blank rule the thumb itself uses below.
|
||||
final override = avatarUrl;
|
||||
final thumb = override != null && override.isNotEmpty ? override : p.avatarThumbUrl;
|
||||
if (thumb != null && thumb.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: thumb,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: memCacheSize,
|
||||
memCacheHeight: memCacheSize,
|
||||
placeholder: (_, _) => _initialFallback(theme, p),
|
||||
errorBuilder: (_, _, _) => _initialFallback(theme, p),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import '../connection/connection.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/jellyfin_mappers.dart';
|
||||
import 'profile.dart';
|
||||
import 'profile_connection.dart';
|
||||
|
||||
/// Picture URL per profile id, `null` meaning "render initials".
|
||||
///
|
||||
/// A [PlexHomeProfile] owns its picture: Plex serves a per-home-user avatar
|
||||
/// and [Profile.virtualPlexHome] already carries it, so those pass through
|
||||
/// untouched.
|
||||
///
|
||||
/// A [LocalProfile] has no picture of its own. It shows the user picture of
|
||||
/// the connection it was linked to **first** — the oldest by
|
||||
/// [Connection.createdAt], ties broken by connection id so the choice is
|
||||
/// stable. If that connection has no picture the profile falls back to
|
||||
/// initials; we deliberately do *not* skip ahead to a later connection, so the
|
||||
/// avatar stays a property of "the first connection" rather than of whichever
|
||||
/// backend happens to have an image today.
|
||||
///
|
||||
/// The join row is not a usable ordering key: `profile_connections` has no
|
||||
/// creation timestamp, and `tokenAcquiredAt` is rewritten every time the
|
||||
/// binder re-mints a Plex Home token.
|
||||
Map<String, String?> resolveProfileAvatarUrls({
|
||||
required List<Profile> profiles,
|
||||
required Map<String, List<ProfileConnection>> connectionsByProfile,
|
||||
required Map<String, Connection> connectionsById,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
|
||||
}) {
|
||||
return {
|
||||
for (final profile in profiles)
|
||||
profile.id: _avatarForProfile(
|
||||
profile: profile,
|
||||
links: connectionsByProfile[profile.id] ?? const [],
|
||||
connectionsById: connectionsById,
|
||||
plexHomeByConnectionId: plexHomeByConnectionId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String? _avatarForProfile({
|
||||
required Profile profile,
|
||||
required List<ProfileConnection> links,
|
||||
required Map<String, Connection> connectionsById,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
|
||||
}) {
|
||||
final own = profile.avatarThumbUrl;
|
||||
if (own != null && own.isNotEmpty) return own;
|
||||
// A Plex Home user with no avatar set keeps its initials. Plex owns that
|
||||
// profile's identity end to end, so it must never borrow the picture of a
|
||||
// connection it happens to have been lent.
|
||||
if (profile.isPlexHome) return null;
|
||||
|
||||
ProfileConnection? firstLink;
|
||||
Connection? first;
|
||||
for (final link in links) {
|
||||
// A link whose connection was removed can't contribute a picture, and it
|
||||
// must not win the ordering either.
|
||||
final connection = connectionsById[link.connectionId];
|
||||
if (connection == null) continue;
|
||||
if (first == null || _isEarlier(connection, first)) {
|
||||
first = connection;
|
||||
firstLink = link;
|
||||
}
|
||||
}
|
||||
if (first == null || firstLink == null) return null;
|
||||
|
||||
return connectionAvatarUrl(connection: first, link: firstLink, plexHomeByConnectionId: plexHomeByConnectionId);
|
||||
}
|
||||
|
||||
bool _isEarlier(Connection candidate, Connection incumbent) {
|
||||
final byCreatedAt = candidate.createdAt.compareTo(incumbent.createdAt);
|
||||
if (byCreatedAt != 0) return byCreatedAt < 0;
|
||||
return candidate.id.compareTo(incumbent.id) < 0;
|
||||
}
|
||||
|
||||
/// Picture a single connection contributes to [link]'s profile, or `null`.
|
||||
///
|
||||
/// Plex resolves through the join row rather than the account owner: a profile
|
||||
/// borrows a *specific* Home user (`userIdentifier`), and that user's live
|
||||
/// [PlexHomeUser.thumb] is the picture Plex shows for it. Reading the live
|
||||
/// cache also means the avatar tracks Plex's hourly refresh for free.
|
||||
String? connectionAvatarUrl({
|
||||
required Connection connection,
|
||||
required ProfileConnection link,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeByConnectionId,
|
||||
}) {
|
||||
return switch (connection) {
|
||||
JellyfinConnection(:final baseUrl, :final userId, :final primaryImageTag) => jellyfinUserImageUrl(
|
||||
baseUrl: baseUrl,
|
||||
userId: userId,
|
||||
tag: primaryImageTag,
|
||||
),
|
||||
PlexAccountConnection() => _plexHomeUserThumb(plexHomeByConnectionId[connection.id], link.userIdentifier),
|
||||
};
|
||||
}
|
||||
|
||||
String? _plexHomeUserThumb(List<PlexHomeUser>? homeUsers, String homeUserUuid) {
|
||||
if (homeUsers == null || homeUserUuid.isEmpty) return null;
|
||||
for (final user in homeUsers) {
|
||||
if (user.uuid != homeUserUuid) continue;
|
||||
return user.thumb.isEmpty ? null : user.thumb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import '../connection/connection.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import 'profile.dart';
|
||||
import 'profile_connection.dart';
|
||||
|
||||
/// Groups profile-connection rows by profile id, preserving input order.
|
||||
Map<String, List<ProfileConnection>> groupConnectionsByProfile(List<ProfileConnection> pcs) {
|
||||
final out = <String, List<ProfileConnection>>{};
|
||||
for (final pc in pcs) {
|
||||
out.putIfAbsent(pc.profileId, () => []).add(pc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Merge local profiles with virtual Plex Home profiles. Each Plex Home
|
||||
/// user becomes a virtual profile attached to its `connectionId`. Home
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/plex/plex_home_user.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import 'plex_home_service.dart';
|
||||
import 'profile.dart';
|
||||
import 'profile_avatar_source.dart';
|
||||
import 'profile_connection.dart';
|
||||
import 'profile_connection_registry.dart';
|
||||
import 'profile_merge.dart';
|
||||
@@ -25,9 +26,22 @@ class ProfilesView {
|
||||
|
||||
final Map<String, Connection> connectionsById;
|
||||
|
||||
const ProfilesView({required this.profiles, required this.connectionsByProfile, required this.connectionsById});
|
||||
/// Picture URL per profile id; null means render initials. See [resolveProfileAvatarUrls].
|
||||
final Map<String, String?> avatarUrlByProfile;
|
||||
|
||||
static const empty = ProfilesView(profiles: [], connectionsByProfile: {}, connectionsById: {});
|
||||
const ProfilesView({
|
||||
required this.profiles,
|
||||
required this.connectionsByProfile,
|
||||
required this.connectionsById,
|
||||
required this.avatarUrlByProfile,
|
||||
});
|
||||
|
||||
static const empty = ProfilesView(
|
||||
profiles: [],
|
||||
connectionsByProfile: {},
|
||||
connectionsById: {},
|
||||
avatarUrlByProfile: {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Join-table rows that should be shown as explicit, user-manageable
|
||||
@@ -77,21 +91,24 @@ ProfilesView _build({
|
||||
required StorageService? storage,
|
||||
}) {
|
||||
final connectionsById = {for (final c in conns) c.id: c};
|
||||
final connectionsByProfile = groupConnectionsByProfile(pcs);
|
||||
final all = mergeLocalWithPlexHome(
|
||||
locals: locals,
|
||||
plexHomeByConnectionId: homes,
|
||||
connectionsById: connectionsById,
|
||||
storage: storage,
|
||||
);
|
||||
return ProfilesView(profiles: all, connectionsByProfile: _groupByProfile(pcs), connectionsById: connectionsById);
|
||||
}
|
||||
|
||||
Map<String, List<ProfileConnection>> _groupByProfile(List<ProfileConnection> pcs) {
|
||||
final out = <String, List<ProfileConnection>>{};
|
||||
for (final pc in pcs) {
|
||||
out.putIfAbsent(pc.profileId, () => []).add(pc);
|
||||
}
|
||||
return out;
|
||||
return ProfilesView(
|
||||
profiles: all,
|
||||
connectionsByProfile: connectionsByProfile,
|
||||
connectionsById: connectionsById,
|
||||
avatarUrlByProfile: resolveProfileAvatarUrls(
|
||||
profiles: all,
|
||||
connectionsByProfile: connectionsByProfile,
|
||||
connectionsById: connectionsById,
|
||||
plexHomeByConnectionId: homes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lightweight `combineLatest4` — emits the combined value once each input
|
||||
|
||||
@@ -167,6 +167,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return _loadCoordinator.requestFull();
|
||||
}
|
||||
|
||||
/// Whether a [load] pass is already running. The startup online-entry hook
|
||||
/// uses this to skip a prime that would only duplicate the load the screen
|
||||
/// started in `initState`.
|
||||
bool get isLoadInFlight => _loadCoordinator.isBusy;
|
||||
|
||||
Future<void> _loadOnce() async {
|
||||
// Yield to the microtask queue before the first notify so a load()
|
||||
// kicked off during build (the screen's initState) doesn't mark
|
||||
|
||||
@@ -263,7 +263,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
|
||||
child: isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: .center,
|
||||
|
||||
@@ -19,6 +19,9 @@ import '../i18n/app_locale_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_merge.dart';
|
||||
import '../media/media_rating.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../models/catalog/catalog_cast_member.dart';
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
import '../models/catalog/catalog_metadata.dart';
|
||||
@@ -76,6 +79,12 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
List<CatalogLink> _streamingLinks = const [];
|
||||
List<CatalogLink> _otherLinks = const [];
|
||||
bool _showSpoilerTags = false;
|
||||
|
||||
/// Focus node per library copy, keyed by [MediaItem.globalKey] so a later
|
||||
/// resolution pass that adds a copy keeps the nodes — and therefore the
|
||||
/// focus — of the rows already on screen. [_libraryMatchFocusNodes] is the
|
||||
/// same nodes in display order, for index-based dpad traversal.
|
||||
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
|
||||
List<FocusNode> _libraryMatchFocusNodes = const [];
|
||||
CatalogSource? _watchlistSource;
|
||||
SeerrCatalogSource? _requestSource;
|
||||
@@ -134,7 +143,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
node.dispose();
|
||||
}
|
||||
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
|
||||
for (final node in _libraryMatchFocusNodes) {
|
||||
for (final node in _libraryMatchNodesByKey.values) {
|
||||
node.dispose();
|
||||
}
|
||||
for (final node in _relationFocusNodes) {
|
||||
@@ -149,37 +158,60 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Monotonic guard for match resolution: the bare row item and its
|
||||
/// detail-enriched form resolve concurrently, and only the latest-issued
|
||||
/// resolution may publish (a slow bare lookup must not overwrite the
|
||||
/// enriched verdict with its own).
|
||||
int _matchGeneration = 0;
|
||||
|
||||
Future<void> _resolveMatches(CatalogItem item) async {
|
||||
final generation = ++_matchGeneration;
|
||||
List<MediaItem> matches;
|
||||
try {
|
||||
matches = await context.read<CatalogLibraryMatcher>().match(item);
|
||||
} catch (e) {
|
||||
appLogger.w('Catalog library match failed for ${item.identityKey}', error: e);
|
||||
matches = const [];
|
||||
// A failed pass is no evidence about copies an earlier pass already
|
||||
// found; only claim "not in your library" when nothing has resolved.
|
||||
if (_matches == null) _mergeMatches(const []);
|
||||
return;
|
||||
}
|
||||
if (generation == _matchGeneration) _setMatches(matches);
|
||||
_mergeMatches(matches);
|
||||
}
|
||||
|
||||
void _setMatches(List<MediaItem> matches) {
|
||||
/// Fold a resolution pass into the visible copies.
|
||||
///
|
||||
/// Union, never replace. The bare row item and its detail-enriched form
|
||||
/// resolve concurrently, and `findByExternalIdsAcrossServers` logs and skips
|
||||
/// per-server failures — so a later pass can legitimately come back short a
|
||||
/// server that answered the first one. Replacing would drop rows for copies
|
||||
/// that are still there.
|
||||
void _mergeMatches(List<MediaItem> matches) {
|
||||
if (!mounted) return;
|
||||
for (final node in _libraryMatchFocusNodes) {
|
||||
node.dispose();
|
||||
final focused = _libraryMatchNodesByKey.values.firstWhereOrNull((node) => node.hasPrimaryFocus);
|
||||
final merged = mergeLibraryCopies(_matches ?? const [], matches);
|
||||
final keys = {for (final match in merged) match.globalKey};
|
||||
for (final key in _libraryMatchNodesByKey.keys.toList()) {
|
||||
if (!keys.contains(key)) _libraryMatchNodesByKey.remove(key)!.dispose();
|
||||
}
|
||||
_libraryMatchFocusNodes = [
|
||||
for (var index = 0; index < matches.length; index++)
|
||||
FocusNode(
|
||||
debugLabel: 'catalog_library_match_$index',
|
||||
onKeyEvent: (node, event) => _handleLibraryMatchKey(index, event),
|
||||
for (final match in merged)
|
||||
_libraryMatchNodesByKey.putIfAbsent(
|
||||
match.globalKey,
|
||||
() => FocusNode(
|
||||
debugLabel: 'catalog_library_match_${match.globalKey}',
|
||||
// Resolved live: merging a pass can reorder the rows, so a
|
||||
// captured index would steer the wrong copy.
|
||||
onKeyEvent: (node, event) {
|
||||
final index = _libraryMatchFocusNodes.indexOf(node);
|
||||
return index < 0 ? KeyEventResult.ignored : _handleLibraryMatchKey(index, event);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
setState(() => _matches = matches);
|
||||
setState(() => _matches = merged);
|
||||
// A merge re-sorts, and SettingsGroup shapes its cards by list index, so
|
||||
// the tile holding the focused node can be rebuilt from scratch and drop
|
||||
// it. Reclaim it rather than dumping a dpad user on another copy.
|
||||
if (focused != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || focused.hasPrimaryFocus || focused.context == null) return;
|
||||
focused.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
CatalogSource? get _ownSource =>
|
||||
@@ -231,11 +263,14 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
_relationEntries = relationEntries;
|
||||
});
|
||||
// The row form of a Plex Discover item carries only its rating key;
|
||||
// the detail body brings the external ids (#1715). When enrichment
|
||||
// added id forms and the bare lookup found nothing, ask again with
|
||||
// the full set.
|
||||
// the detail body brings the external ids (#1715). Ask again with the
|
||||
// full set whenever enrichment added id forms — not just when the bare
|
||||
// lookup came back empty: the exact `plex://` guid finds only copies in
|
||||
// libraries on the modern agent, while a legacy-agent sibling is
|
||||
// reachable solely through the imdb/tmdb forms (#1754). The result
|
||||
// merges, so a re-ask can only add copies.
|
||||
final gainedIds = !widget.item.ids.allKeys.toSet().containsAll(detail.item.ids.allKeys);
|
||||
if (gainedIds && (_matches?.isEmpty ?? true)) {
|
||||
if (gainedIds) {
|
||||
unawaited(_resolveMatches(detail.item));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -499,14 +534,30 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The technical label of a copy's best version, e.g. "4K HEVC MKV
|
||||
/// (45.0 Mbps)". Library names are user-chosen and need not mention
|
||||
/// resolution, which is exactly the question two copies of one movie pose
|
||||
/// (#1754), so the row states it outright. Omitted when the backend
|
||||
/// reported no resolution at all, rather than showing "Unknown".
|
||||
static String? _libraryMatchQuality(MediaItem match) {
|
||||
MediaVersion? best;
|
||||
for (final version in match.mediaVersions ?? const <MediaVersion>[]) {
|
||||
if (version.resolutionHeight == null) continue;
|
||||
if (best == null || version.resolutionHeight! > best.resolutionHeight!) best = version;
|
||||
}
|
||||
return best?.displayLabel;
|
||||
}
|
||||
|
||||
Widget _buildLibraryMatchTile(MediaItem match, int index) {
|
||||
// Plex matches carry their library title; Jellyfin's search-based lookup
|
||||
// only does when the ancestors call succeeded, so fall back to the server
|
||||
// name alone. The subtitle carries whatever else tells two copies apart.
|
||||
final details = [?_libraryMatchQuality(match), ?(match.libraryTitle == null ? null : match.serverName)];
|
||||
return FocusableListTile(
|
||||
focusNode: _libraryMatchFocusNodes[index],
|
||||
leading: BackendBadge(backend: match.backend, size: 24),
|
||||
// Plex matches carry their library title; Jellyfin's search-based
|
||||
// lookup doesn't, so fall back to the server name alone.
|
||||
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
|
||||
subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null,
|
||||
subtitle: details.isEmpty ? null : Text(details.join(' • ')),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
|
||||
);
|
||||
@@ -617,23 +668,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
CatalogCreditRole.composer => t.explore.creditRole.composer,
|
||||
};
|
||||
|
||||
static String? _ratingSourceLabel(String source) => switch (source) {
|
||||
'critic' => t.explore.ratingSource.critic,
|
||||
'audience' => t.explore.ratingSource.audience,
|
||||
'imdb' => t.explore.ratingSource.imdb,
|
||||
'tmdb' => t.explore.ratingSource.tmdb,
|
||||
'rottenTomatoes' => t.explore.ratingSource.rottenTomatoes,
|
||||
// Plex splits Rotten Tomatoes into its two panels, so both the provenance
|
||||
// and the critic/audience distinction survive.
|
||||
'rottenTomatoesCritic' => t.explore.ratingSource.rottenTomatoesCritic,
|
||||
'rottenTomatoesAudience' => t.explore.ratingSource.rottenTomatoesAudience,
|
||||
'simkl' => t.explore.ratingSource.simkl,
|
||||
'mal' => t.explore.ratingSource.mal,
|
||||
'anilist' => t.explore.ratingSource.anilist,
|
||||
'trakt' => t.explore.ratingSource.trakt,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static String _relationLabel(CatalogRelationType type) => switch (type) {
|
||||
CatalogRelationType.prequel => t.explore.relation.prequel,
|
||||
CatalogRelationType.sequel => t.explore.relation.sequel,
|
||||
@@ -795,9 +829,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
Widget? _buildRatingsSection(ThemeData theme) {
|
||||
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
|
||||
final chips = <Widget>[];
|
||||
for (final rating in _item.ratings ?? const <CatalogRatingSource>[]) {
|
||||
final source = _ratingSourceLabel(rating.source);
|
||||
final badge = catalogRatingInfo(rating.source, rating.value);
|
||||
for (final rating in _item.ratings ?? const <MediaRatingSource>[]) {
|
||||
final source = ratingSourceLabel(rating.source);
|
||||
final badge = ratingInfoForSource(rating.source, rating.value);
|
||||
if (source == null && badge == null) continue;
|
||||
var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue;
|
||||
if (rating.votes case final votes?) {
|
||||
|
||||
@@ -203,7 +203,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: .fromLTRB(16, 16, 16, 16 + MediaQuery.paddingOf(context).bottom),
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
showSelectedIcon: false,
|
||||
|
||||
@@ -600,6 +600,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
unawaited(_discover.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void primeRefresh() {
|
||||
// `initState` already fired `load()`. On cold start that pass is still
|
||||
// running when the online-entry hook primes the tab, and asking again only
|
||||
// queues an identical trailing pass — the whole home fan-out twice (#1784).
|
||||
// When nothing is in flight (reconnect-from-offline, or a first pass that
|
||||
// gave up because no server was online yet) a real refresh is still owed.
|
||||
if (_discover.isLoadInFlight) return;
|
||||
fullRefresh();
|
||||
}
|
||||
|
||||
/// Whether the loaded hubs span more than one connected server.
|
||||
bool _hubsSpanMultipleServers() {
|
||||
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
|
||||
@@ -652,12 +663,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
key: _userMenuKey,
|
||||
enabled: !_switchingProfile,
|
||||
icon: active != null
|
||||
? ProfileAvatar(profile: active, size: 32)
|
||||
? ProfileAvatar(profile: active, size: 32, avatarUrl: activeProvider.avatarUrlFor(active.id))
|
||||
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
|
||||
tooltip: t.profiles.sectionTitle,
|
||||
anchorAlignment: AppMenuAnchorAlignment.end,
|
||||
onSelected: (value) => unawaited(_handleUserMenuAction(context, value)),
|
||||
entriesBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles),
|
||||
entriesBuilder: (context) =>
|
||||
_userMenuItems(context, activeProfile: active, profiles: profiles, activeProvider: activeProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -666,6 +678,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
BuildContext context, {
|
||||
required Profile? activeProfile,
|
||||
required List<Profile> profiles,
|
||||
required ActiveProfileProvider activeProvider,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final switchable = profiles.where((p) => p.id != activeProfile?.id).toList();
|
||||
@@ -674,7 +687,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
for (final p in switchable)
|
||||
AppMenuItem<String>(
|
||||
value: 'profile:${p.id}',
|
||||
leading: ProfileAvatar(profile: p, size: 24),
|
||||
leading: ProfileAvatar(profile: p, size: 24, avatarUrl: activeProvider.avatarUrlFor(p.id)),
|
||||
label: p.displayName,
|
||||
trailing: p.isPinProtected
|
||||
? AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/media_card_sliver_layout.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/skeleton_media_card.dart';
|
||||
import '../widgets/system_bottom_inset.dart';
|
||||
|
||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||
@@ -86,7 +87,10 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
/// host that defers route back to [handleBackNavigation], plus a Scaffold
|
||||
/// with a CustomScrollView bound as the primary scroll view. Callers build
|
||||
/// the slivers themselves (typically
|
||||
/// `[appBar, ...header, ...buildStateSlivers(), grid]`).
|
||||
/// `[appBar, ...header, ...buildStateSlivers(), grid]`); a trailing
|
||||
/// [SliverSystemBottomInset] is appended so the last row clears the system
|
||||
/// navigation bar. Screens that add their own trailing spacer (the music
|
||||
/// detail screens reserve the floating mini-player) stack on top of it.
|
||||
Widget buildDetailScaffold({required List<Widget> slivers}) {
|
||||
return PrimaryScrollController(
|
||||
controller: scrollController,
|
||||
@@ -100,7 +104,9 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)),
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(primary: true, slivers: [...slivers, const SliverSystemBottomInset()]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../media/media_server_client.dart';
|
||||
import '../media/media_sort.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../widgets/system_bottom_inset.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/continuation_pagination_coordinator.dart';
|
||||
import '../utils/error_message_utils.dart';
|
||||
@@ -611,6 +612,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
||||
onBack: handleBackFromContent,
|
||||
),
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -520,7 +520,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_fullRefreshContentTabs();
|
||||
_primeContentTabs();
|
||||
}
|
||||
|
||||
/// Single-shot "resume queued downloads once any client is online" rule,
|
||||
@@ -1634,15 +1634,26 @@ class _MainScreenState extends State<MainScreen>
|
||||
if (_screenKeys[tab]?.currentState case final T state) fn(state);
|
||||
}
|
||||
|
||||
/// Full-refresh the primary content tabs. Shared by the online-entry hook
|
||||
/// ([_primeOnlineServices]) and the profile-switch invalidation
|
||||
/// ([_invalidateAllScreens]), which refresh the same set.
|
||||
/// Full-refresh the primary content tabs. Used by the profile-switch
|
||||
/// invalidation ([_invalidateAllScreens]), which must refetch everything for
|
||||
/// the new identity.
|
||||
void _fullRefreshContentTabs() {
|
||||
for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) {
|
||||
for (final tab in _contentTabs) {
|
||||
_onScreen<FullRefreshable>(tab, (screen) => screen.fullRefresh());
|
||||
}
|
||||
}
|
||||
|
||||
/// Online-entry variant used by [_primeOnlineServices] on cold start and on
|
||||
/// reconnect-from-offline. Screens that already started their own load skip
|
||||
/// it; see [FullRefreshable.primeRefresh].
|
||||
void _primeContentTabs() {
|
||||
for (final tab in _contentTabs) {
|
||||
_onScreen<FullRefreshable>(tab, (screen) => screen.primeRefresh());
|
||||
}
|
||||
}
|
||||
|
||||
static const _contentTabs = [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search];
|
||||
|
||||
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
|
||||
final tabs = _getBottomNavigationTabs(context);
|
||||
final selectedIndex = tabs.indexWhere((tab) => tab.id == _currentTab);
|
||||
|
||||
@@ -50,7 +50,6 @@ import '../services/plex_client.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/rating_utils.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
@@ -208,6 +207,12 @@ class _SeasonEpisodePager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the TV reveal gate's opacity wrapper. The detail tree builds
|
||||
/// other [AnimatedOpacity] widgets (the scroll-linked app-bar scrim is 0 at
|
||||
/// rest), so tests must target this one specifically.
|
||||
@visibleForTesting
|
||||
const tvDetailRevealGateKey = ValueKey<String>('tvDetailRevealGate');
|
||||
|
||||
class MediaDetailScreen extends StatefulWidget {
|
||||
final MediaItem metadata;
|
||||
final bool isOffline;
|
||||
@@ -838,6 +843,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
child: IgnorePointer(
|
||||
ignoring: !revealed,
|
||||
child: AnimatedOpacity(
|
||||
// Keyed so tests can assert this specific gate rather than
|
||||
// whichever AnimatedOpacity happens to be lowest on screen.
|
||||
key: tvDetailRevealGateKey,
|
||||
opacity: revealed ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -961,59 +969,28 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a rating chip that shows a source icon when available,
|
||||
/// falling back to a generic Material icon.
|
||||
Widget _buildRatingChip(String? imageUri, double value, IconData fallbackIcon) {
|
||||
/// Every attributed score in one pill, then the tappable user-rating chip.
|
||||
///
|
||||
/// The scores share a single pill rather than taking a chip each: this row
|
||||
/// is a height-clipped [Wrap], so a per-source chip would push the year,
|
||||
/// certification, and runtime out of the visible band on short heroes.
|
||||
List<Widget> _buildRatingChips(MediaItem metadata) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isTv = PlatformDetector.isTV();
|
||||
return MediaRatingBadge.chip(
|
||||
imageUri: imageUri,
|
||||
value: value,
|
||||
fallbackIcon: fallbackIcon,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
iconSize: isTv ? 20 : 16,
|
||||
spacing: isTv ? 6 : 4,
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
|
||||
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build all rating chips for the metadata.
|
||||
/// When both critic and audience ratings are from Rotten Tomatoes,
|
||||
/// they are combined into a single badge.
|
||||
List<Widget> _buildRatingChips(MediaItem metadata) {
|
||||
final chips = <Widget>[];
|
||||
// Plex-only fields (audienceRating / ratingImage / audienceRatingImage)
|
||||
// — Jellyfin lacks rating-source attribution. Pull them via a typed
|
||||
// narrow so the rest of the chip layout stays backend-neutral.
|
||||
final plex = metadata is PlexMediaItem ? metadata : null;
|
||||
final audienceRating = plex?.audienceRating;
|
||||
final ratingImage = plex?.ratingImage;
|
||||
final audienceRatingImage = plex?.audienceRatingImage;
|
||||
final bothRT =
|
||||
metadata.rating != null &&
|
||||
audienceRating != null &&
|
||||
isRottenTomatoes(ratingImage) &&
|
||||
isRottenTomatoes(audienceRatingImage);
|
||||
|
||||
if (bothRT) {
|
||||
chips.add(_buildCombinedRtChip(ratingImage, metadata.rating!, audienceRatingImage, audienceRating));
|
||||
} else {
|
||||
if (metadata.rating != null) {
|
||||
chips.add(_buildRatingChip(ratingImage, metadata.rating!, Symbols.star_rounded));
|
||||
}
|
||||
if (audienceRating != null) {
|
||||
chips.add(_buildRatingChip(audienceRatingImage, audienceRating, Symbols.people_rounded));
|
||||
}
|
||||
}
|
||||
|
||||
// User rating chip (tappable)
|
||||
if (!widget.isOffline) {
|
||||
chips.add(_buildUserRatingChip(metadata));
|
||||
}
|
||||
|
||||
return chips;
|
||||
return [
|
||||
if (mediaRatingsFor(metadata).isNotEmpty)
|
||||
MediaRatingBadgeGroup.chip(
|
||||
item: metadata,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
iconSize: isTv ? 20 : 16,
|
||||
spacing: isTv ? 6 : 4,
|
||||
entrySpacing: isTv ? 12 : 10,
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
|
||||
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
|
||||
),
|
||||
if (!widget.isOffline) _buildUserRatingChip(metadata),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildUserRatingChip(MediaItem metadata) {
|
||||
@@ -1111,49 +1088,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a combined RT chip showing critic + audience side by side.
|
||||
Widget _buildCombinedRtChip(
|
||||
String? criticImageUri,
|
||||
double criticValue,
|
||||
String? audienceImageUri,
|
||||
double audienceValue,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: .w500);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
MediaRatingBadge.inline(
|
||||
imageUri: criticImageUri,
|
||||
value: criticValue,
|
||||
fallbackIcon: Symbols.star_rounded,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
iconSize: 16,
|
||||
spacing: 4,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
MediaRatingBadge.inline(
|
||||
imageUri: audienceImageUri,
|
||||
value: audienceValue,
|
||||
fallbackIcon: Symbols.people_rounded,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
iconSize: 16,
|
||||
spacing: 4,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Backend-neutral counterpart of [getServerBoundPlexClient]. Returns a
|
||||
/// [MediaServerClient] for Jellyfin items too, so image URLs use the
|
||||
/// right server's transcoder.
|
||||
@@ -1355,35 +1289,59 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await client.fetchItemWithOnDeck(_metadata.id);
|
||||
// Normalises a freshly fetched item against the row we navigated from
|
||||
// (which owns serverId/library) and paints it. Called once from
|
||||
// [onItemReady] on backends that learn the item before on-deck, and once
|
||||
// from the settled result.
|
||||
//
|
||||
// [onDeckSettled] separates "on-deck not looked up yet" from "on-deck
|
||||
// looked up and there is none". Only the settled call may write it, so
|
||||
// the early paint leaves whatever is on screen alone — this method runs
|
||||
// again after playback, and clearing there would blank the play button
|
||||
// for the length of the on-deck round trip — while a reload that finds
|
||||
// the series finished still clears it.
|
||||
MediaItem publish(MediaItem source, {MediaItem? onDeckEpisode, bool onDeckSettled = false}) {
|
||||
final serverId = _metadata.serverId;
|
||||
final serverName = _metadata.serverName;
|
||||
final base = _withFallbackLibrary(
|
||||
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
|
||||
_metadata,
|
||||
);
|
||||
final onDeckWithServerId = onDeckEpisode == null
|
||||
? null
|
||||
: _withFallbackLibrary(
|
||||
onDeckEpisode.copyWith(
|
||||
serverId: serverId ?? onDeckEpisode.serverId,
|
||||
serverName: serverName ?? onDeckEpisode.serverName,
|
||||
),
|
||||
base,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_fullMetadata = base;
|
||||
if (onDeckSettled) _onDeckEpisode = onDeckWithServerId;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
// Jellyfin needs a second round trip for on-deck, which the screen does
|
||||
// not need in order to paint. Publishing the item as soon as it lands
|
||||
// takes that round trip off the critical path (#1784).
|
||||
//
|
||||
// Seasons/extras deliberately still start after the whole lookup
|
||||
// settles: starting them at the early paint measured *worse*, because
|
||||
// they contend with the on-deck request rather than overlapping it.
|
||||
final result = await client.fetchItemWithOnDeck(
|
||||
_metadata.id,
|
||||
onItemReady: (item) {
|
||||
if (mounted) publish(item);
|
||||
},
|
||||
);
|
||||
final metadata = result.item;
|
||||
final onDeckEpisode = result.onDeckEpisode;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Preserve serverId from original metadata
|
||||
final serverId = _metadata.serverId;
|
||||
final serverName = _metadata.serverName;
|
||||
final source = metadata ?? _metadata;
|
||||
final base = _withFallbackLibrary(
|
||||
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
|
||||
_metadata,
|
||||
);
|
||||
final onDeckWithServerId = onDeckEpisode == null
|
||||
? null
|
||||
: _withFallbackLibrary(
|
||||
onDeckEpisode.copyWith(
|
||||
serverId: serverId ?? onDeckEpisode.serverId,
|
||||
serverName: serverName ?? onDeckEpisode.serverName,
|
||||
),
|
||||
base,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_fullMetadata = base;
|
||||
_onDeckEpisode = onDeckWithServerId;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true);
|
||||
|
||||
if (base.isShow) {
|
||||
unawaited(_loadSeasons());
|
||||
@@ -3625,7 +3583,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final fields = _tvDetailMetadataParts<String>(
|
||||
metadata,
|
||||
text: (value) => value,
|
||||
rating: (item) => MediaRatingBadge.semanticLabelForMedia(item, fallbackItem: metadata),
|
||||
rating: (item) => MediaRatingBadgeGroup.semanticLabelForMedia(item, fallbackItem: metadata),
|
||||
);
|
||||
for (final field in fields) {
|
||||
add(field);
|
||||
@@ -3694,12 +3652,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final fields = _tvDetailMetadataParts<Widget>(
|
||||
metadata,
|
||||
text: (value) => Text(value, maxLines: 1, style: textStyle),
|
||||
rating: (item) => MediaRatingBadge.inlineForMedia(
|
||||
// Every score shares the one metadata slot, so the bullet separators
|
||||
// don't multiply with the number of sources.
|
||||
rating: (item) => MediaRatingBadgeGroup.inlineForMedia(
|
||||
item: item,
|
||||
fallbackItem: metadata,
|
||||
foregroundColor: textStyle.color,
|
||||
iconSize: textStyle.fontSize,
|
||||
spacing: 4 * scale,
|
||||
entrySpacing: 12 * scale,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import '../focusable_detail_screen_mixin.dart';
|
||||
import '../libraries/content_state_builder.dart';
|
||||
import '../../mixins/grid_focus_node_mixin.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
import '../../widgets/system_bottom_inset.dart';
|
||||
|
||||
/// Screen to display the contents of a playlist
|
||||
class PlaylistDetailScreen extends StatefulWidget {
|
||||
@@ -757,6 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
onBack: handleBackFromContent,
|
||||
),
|
||||
],
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -269,6 +270,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isLocal = _profile.isLocal;
|
||||
final avatarUrl = context.watch<ActiveProfileProvider>().avatarUrlFor(_profile.id);
|
||||
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(_profile.displayName),
|
||||
@@ -277,7 +279,9 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Center(child: ProfileAvatar(profile: _profile, size: 96)),
|
||||
Center(
|
||||
child: ProfileAvatar(profile: _profile, size: 96, avatarUrl: avatarUrl),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -266,6 +266,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
child: _ProfileTile(
|
||||
borderRadius: tileRadii,
|
||||
profile: profile,
|
||||
avatarUrl: view.avatarUrlByProfile[profile.id],
|
||||
isActive: isActive && !widget.requireSelection,
|
||||
chips: _chipsFor(profile, view),
|
||||
onTap: () => _switchTo(profile),
|
||||
@@ -363,6 +364,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
|
||||
class _ProfileTile extends StatelessWidget {
|
||||
final Profile profile;
|
||||
final String? avatarUrl;
|
||||
final bool isActive;
|
||||
final BorderRadius borderRadius;
|
||||
final List<_ChipData> chips;
|
||||
@@ -377,6 +379,7 @@ class _ProfileTile extends StatelessWidget {
|
||||
|
||||
const _ProfileTile({
|
||||
required this.profile,
|
||||
required this.avatarUrl,
|
||||
required this.isActive,
|
||||
required this.borderRadius,
|
||||
required this.chips,
|
||||
@@ -403,7 +406,7 @@ class _ProfileTile extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
ProfileAvatar(profile: profile, size: 44),
|
||||
ProfileAvatar(profile: profile, size: 44, avatarUrl: avatarUrl),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -10,6 +12,7 @@ import '../media/media_item.dart';
|
||||
import '../mixins/debounced_media_search.dart';
|
||||
import '../mixins/mounted_set_state_mixin.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -38,10 +41,45 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
AbortController? _activeSearchAbort;
|
||||
({String query, SearchAggregationResult result})? _pendingSearchOutcome;
|
||||
|
||||
HiddenLibrariesProvider? _hiddenLibraries;
|
||||
Set<String> _lastSeenHiddenKeys = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||
unawaited(_bindHiddenLibraries());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hiddenLibraries?.removeListener(_onHiddenLibrariesChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Re-run the visible query when a library is hidden or unhidden while
|
||||
/// results are on screen. The listener is attached only after the provider
|
||||
/// has hydrated, so its initial load notification cannot race the first
|
||||
/// query — which awaits that same hydration — into running twice.
|
||||
Future<void> _bindHiddenLibraries() async {
|
||||
final hiddenLibraries = context.read<HiddenLibrariesProvider>();
|
||||
await hiddenLibraries.ensureInitialized();
|
||||
if (!mounted) return;
|
||||
_lastSeenHiddenKeys = Set.of(hiddenLibraries.hiddenLibraryKeys);
|
||||
_hiddenLibraries = hiddenLibraries..addListener(_onHiddenLibrariesChanged);
|
||||
}
|
||||
|
||||
void _onHiddenLibrariesChanged() {
|
||||
final hiddenLibraries = _hiddenLibraries;
|
||||
if (hiddenLibraries == null || !mounted) return;
|
||||
final currentKeys = hiddenLibraries.hiddenLibraryKeys;
|
||||
if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) {
|
||||
return;
|
||||
}
|
||||
_lastSeenHiddenKeys = Set.of(currentKeys);
|
||||
final query = searchController.text.trim();
|
||||
if (query.isEmpty || !hasSearched) return;
|
||||
unawaited(runSearch(query));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -54,10 +92,21 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
throw const _SearchUnavailableException();
|
||||
}
|
||||
|
||||
// Hidden-library keys must be hydrated before the first query, or a cold
|
||||
// start would filter nothing. Read before any await: the profile subtree
|
||||
// can be torn down mid-search.
|
||||
final hiddenLibraries = context.read<HiddenLibrariesProvider>();
|
||||
await hiddenLibraries.ensureInitialized();
|
||||
if (!mounted) return const [];
|
||||
|
||||
final abort = AbortController();
|
||||
_activeSearchAbort = abort;
|
||||
try {
|
||||
final result = await multiServerProvider.aggregationService.searchAcrossServers(query, abort: abort);
|
||||
final result = await multiServerProvider.aggregationService.searchAcrossServers(
|
||||
query,
|
||||
hiddenLibraryKeys: hiddenLibraries.hiddenLibraryKeys,
|
||||
abort: abort,
|
||||
);
|
||||
abort.throwIfAborted();
|
||||
if (result.succeededServerIds.isEmpty && result.failedServerIds.isNotEmpty) {
|
||||
throw const _SearchUnavailableException();
|
||||
|
||||
@@ -461,7 +461,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
|
||||
child: Center(child: _buildQuickConnectPanel(theme)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
|
||||
@@ -26,6 +26,7 @@ import '../../utils/platform_detector.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../../widgets/system_bottom_inset.dart';
|
||||
|
||||
const previousStartupFailureKey = Key('logs-previous-startup-failure');
|
||||
|
||||
@@ -419,7 +420,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else
|
||||
else ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
sliver: SliverToBoxAdapter(
|
||||
@@ -435,6 +436,10 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Only the log body needs it: the empty state already fills
|
||||
// the viewport, so a trailing inset would just add slack.
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ import '../../widgets/overlay_sheet.dart';
|
||||
import '../../widgets/setting_tile.dart';
|
||||
import '../../widgets/settings_builder.dart';
|
||||
import '../../widgets/settings_section.dart';
|
||||
import '../../widgets/system_bottom_inset.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../watch_together/services/watch_together_relay_endpoint.dart';
|
||||
@@ -224,6 +225,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -79,7 +79,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
Widget _buildLoadingSpinner() {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||
body: Center(child: PlayerLoadingIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
|
||||
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||
const Center(child: PlayerLoadingIndicator()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
final startZoom = _pinchStartZoomScale;
|
||||
final filterManager = _videoFilterManager;
|
||||
if (!_isPinchZooming || startZoom == null || filterManager == null) return;
|
||||
final nextZoomScale = VideoFilterManager.normalizeZoomScale(startZoom * details.scale);
|
||||
// Snap through 100% so pinching back undoes a zoom exactly, which is
|
||||
// the touch path to an unzoomed picture (#1505).
|
||||
final nextZoomScale = VideoFilterManager.normalizeZoomScale(
|
||||
VideoFilterManager.snapPinchZoomScale(startZoom * details.scale),
|
||||
);
|
||||
|
||||
if (!_pinchZoomChanged) {
|
||||
if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold) {
|
||||
|
||||
@@ -109,7 +109,20 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
||||
if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles());
|
||||
return;
|
||||
}
|
||||
_trackManager?.cycleSubtitleTrack();
|
||||
_cycleSubtitleTrackNatively();
|
||||
}
|
||||
|
||||
/// Cycle through the native track list, for playback with no source
|
||||
/// catalog to advance through (downloads, and items whose server exposes no
|
||||
/// subtitle rows).
|
||||
///
|
||||
/// The manager owns the selection and the server write-back; the committed
|
||||
/// choice is this screen's, and the episode carry-over reads it, so a cycle
|
||||
/// that lands on Off has to be recorded here or the next episode inherits
|
||||
/// the choice this one started with.
|
||||
void _cycleSubtitleTrackNatively() {
|
||||
final cycled = _trackManager?.cycleSubtitleTrack();
|
||||
if (cycled != null) _rememberNativeSubtitleSelection(cycled);
|
||||
}
|
||||
|
||||
Future<void> _drainSubtitleCycles() async {
|
||||
@@ -127,7 +140,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
||||
if (_isOfflinePlayback || sourceTracks.isEmpty) {
|
||||
_pendingSubtitleCycleCount -= advances;
|
||||
for (var i = 0; i < advances; i++) {
|
||||
_trackManager?.cycleSubtitleTrack();
|
||||
_cycleSubtitleTrackNatively();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
hasCommittedSelection: committedSubtitleSelection != null,
|
||||
committedTrack: committedSubtitleSelection?.primaryTrack,
|
||||
nativeTrack: currentPlayer.state.track.subtitle,
|
||||
declinedPreference: committedSubtitleSelection?.declinedPreference,
|
||||
);
|
||||
final secondarySubtitlePreference = followServerSelections
|
||||
? null
|
||||
@@ -771,7 +772,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
plexClient: plexClient,
|
||||
getProfileSettings: () => userProfileProvider.profileSettings,
|
||||
preferredAudioTrack: currentAudioTrack,
|
||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||
// A declined carry stays alive for the native passes: freezing the
|
||||
// resolver's off verdict here would turn a metadata mismatch into a
|
||||
// navigation-priority off that no late track can undo (#1785).
|
||||
preferredSubtitleTrack:
|
||||
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||
);
|
||||
_trackManager = trackManager;
|
||||
|
||||
@@ -414,6 +414,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
mediaInfo: mediaInfo,
|
||||
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
|
||||
hasRenderedPlayback: () => _hasRenderedFirstFrame,
|
||||
subtitleOffIsDeliberate: () => _playbackSession?.subtitleSelection.declinedPreference == null,
|
||||
onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode'
|
||||
? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId)
|
||||
: null,
|
||||
|
||||
@@ -346,7 +346,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
plexClient: mediaClient is PlexClient ? mediaClient : null,
|
||||
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
||||
preferredAudioTrack: _preferredAudioTrack,
|
||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||
// Same rule as the reload flow: a declined preference is retried by
|
||||
// the native passes instead of being frozen into off (#1785).
|
||||
preferredSubtitleTrack:
|
||||
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,28 @@ class VideoPlayerMacPipPlaceholder extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's loading spinner.
|
||||
///
|
||||
/// Labelled rather than silent: a bare [CircularProgressIndicator] contributes
|
||||
/// no semantics at all, so a screen reader announced nothing while the picture
|
||||
/// was still coming up. The label also makes "the first frame has not rendered
|
||||
/// yet" an observable state instead of something inferred from the chrome,
|
||||
/// which a television no longer raises on startup (#1765).
|
||||
class PlayerLoadingIndicator extends StatelessWidget {
|
||||
final double strokeWidth;
|
||||
|
||||
const PlayerLoadingIndicator({super.key, this.strokeWidth = 4});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: strokeWidth,
|
||||
semanticsLabel: t.videoControls.loadingVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoPlayerBufferingOverlay extends StatelessWidget {
|
||||
final ValueListenable<bool> isBuffering;
|
||||
final ValueListenable<bool> hasFirstFrame;
|
||||
@@ -77,7 +99,7 @@ class VideoPlayerBufferingOverlay extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
|
||||
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
|
||||
child: const PlayerLoadingIndicator(strokeWidth: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -135,16 +135,32 @@ bool shouldAutoStartReloadedMedia({
|
||||
required bool startPaused,
|
||||
}) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused;
|
||||
|
||||
/// Whether a freshly opened player route starts with its chrome up.
|
||||
///
|
||||
/// A television starts with the controls down. Auto-hide cannot even arm until
|
||||
/// the first frame lands, so chrome raised by the route opening would park the
|
||||
/// whole OSD and timebar over the first seconds of picture (#1765). The route
|
||||
/// keeps its own loading surface and buffering overlay, the screen focus node
|
||||
/// owns back, and the first D-pad press raises the chrome, so a remote loses
|
||||
/// nothing. Pointer and touch platforms keep it: the viewer's hand is already
|
||||
/// on the surface, and the title and back affordance belong over the spinner.
|
||||
bool playerChromeStartsVisible({required bool isTv}) => !isTv;
|
||||
|
||||
/// Builds an item-agnostic subtitle preference for an episode replacement.
|
||||
///
|
||||
/// Source ids and sidecar URIs belong to the current media item. Only the
|
||||
/// committed semantic choice — a [SubtitleIntent] — may cross the item
|
||||
/// boundary; native state is a fallback for sessions created before
|
||||
/// source-backed selection was recorded.
|
||||
///
|
||||
/// [declinedPreference] is the committed selection's unserved carry: its off
|
||||
/// is fallout from a metadata mismatch, not a viewer choice, so it must not
|
||||
/// harden into an explicit off on the next item (#1785).
|
||||
SubtitlePreference? subtitlePreferenceForItemChange({
|
||||
required bool hasCommittedSelection,
|
||||
required SubtitleTrack? committedTrack,
|
||||
required SubtitleTrack? nativeTrack,
|
||||
SubtitlePreference? declinedPreference,
|
||||
}) {
|
||||
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
|
||||
if (track == null) return null;
|
||||
@@ -158,6 +174,15 @@ SubtitlePreference? subtitlePreferenceForItemChange({
|
||||
return normalize(nativeTrack, preserveOff: true);
|
||||
}
|
||||
|
||||
final committedIsOff = committedTrack == null || committedTrack.id == SubtitleTrack.off.id;
|
||||
if (declinedPreference != null && committedIsOff) {
|
||||
// Live native state wins — a late native pass may have served the
|
||||
// declined carry — otherwise the declined preference itself keeps
|
||||
// crossing item boundaries until the viewer or a richer catalog
|
||||
// settles it.
|
||||
return normalize(nativeTrack, preserveOff: false) ?? SubtitlePreference.demoteToIntent(declinedPreference);
|
||||
}
|
||||
|
||||
if (committedTrack == null) return const SubtitlePreference.off();
|
||||
|
||||
final committedPreference = normalize(committedTrack, preserveOff: true);
|
||||
@@ -165,6 +190,39 @@ SubtitlePreference? subtitlePreferenceForItemChange({
|
||||
return normalize(nativeTrack, preserveOff: false);
|
||||
}
|
||||
|
||||
/// Builds the committed [PlaybackSubtitleSelection] for a user's subtitle
|
||||
/// pick in one slot, leaving the other slot untouched.
|
||||
///
|
||||
/// [sourceTrack] and [sidecar] are null when the pick has no source-catalog
|
||||
/// identity — items without subtitle rows, or a native track the identity
|
||||
/// matcher cannot map. The raw native [track] is committed then (without
|
||||
/// source ids), so the session still reflects what is on screen and the next
|
||||
/// item boundary demotes it to a semantic intent instead of hardening the
|
||||
/// stale previous selection into an explicit off (#1785).
|
||||
PlaybackSubtitleSelection subtitleSelectionForUserPick({
|
||||
required PlaybackSubtitleSelection currentSelection,
|
||||
required bool isPrimarySlot,
|
||||
required SubtitleTrack track,
|
||||
MediaSubtitleTrack? sourceTrack,
|
||||
PlaybackSubtitleSidecar? sidecar,
|
||||
}) {
|
||||
final resolvedTrack = sourceTrack == null
|
||||
? track
|
||||
: PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
|
||||
return PlaybackSubtitleSelection(
|
||||
primaryTrack: isPrimarySlot ? resolvedTrack : currentSelection.primaryTrack,
|
||||
primarySourceStreamId: isPrimarySlot ? sourceTrack?.id : currentSelection.primarySourceStreamId,
|
||||
primarySidecar: isPrimarySlot ? sidecar : currentSelection.primarySidecar,
|
||||
secondaryTrack: isPrimarySlot ? currentSelection.secondaryTrack : resolvedTrack,
|
||||
secondarySourceStreamId: isPrimarySlot ? currentSelection.secondarySourceStreamId : sourceTrack?.id,
|
||||
secondarySidecar: isPrimarySlot ? currentSelection.secondarySidecar : sidecar,
|
||||
// A primary pick is a decision that retires any unresolved carry; a
|
||||
// secondary-only change must not relabel the primary's declined off as
|
||||
// deliberate (that would persist -1 and harden the next boundary).
|
||||
declinedPreference: isPrimarySlot ? null : currentSelection.declinedPreference,
|
||||
);
|
||||
}
|
||||
|
||||
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
||||
/// They are mutually exclusive by construction — entry points bail while a
|
||||
/// transition is in flight.
|
||||
@@ -251,8 +309,7 @@ _PlaybackOpenTiming _playbackOpenTiming({
|
||||
/// change should not silently rewrite the whole series' Plex prefs. The
|
||||
/// explicit path for that lives in the metadata-edit UI.
|
||||
TrackPreferencePersister _plexTrackPersister(PlexClient? Function() resolve) {
|
||||
return ({required int partId, required String trackType, int? streamID}) async {
|
||||
if (streamID == null) return;
|
||||
return ({required int partId, required String trackType, required int streamID}) async {
|
||||
final client = resolve();
|
||||
if (client == null) return;
|
||||
await (trackType == 'audio'
|
||||
@@ -466,6 +523,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// key events never escape the video player route.
|
||||
late final FocusNode _screenFocusNode;
|
||||
|
||||
/// Key for a context below this screen's own [OverlaySheetHost]. The State's
|
||||
/// context sits ABOVE the host, so resolving the controller with `context`
|
||||
/// always misses it and the screen would walk its back pipeline (hide chrome,
|
||||
/// then exit the player) while a sheet is still open (#1741).
|
||||
final GlobalKey _overlayChildKey = GlobalKey();
|
||||
|
||||
BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context;
|
||||
|
||||
// VLC-style in-player toast controller (rate changes, backend switch, etc.).
|
||||
final PlayerToastController _toastController = PlayerToastController();
|
||||
bool _reclaimingFocus = false;
|
||||
@@ -729,7 +794,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _hasFatalPlaybackError = false;
|
||||
|
||||
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
|
||||
final PlayerChromeController _chromeController = PlayerChromeController();
|
||||
final PlayerChromeController _chromeController = PlayerChromeController(
|
||||
initiallyVisible: playerChromeStartsVisible(isTv: PlatformDetector.isTV()),
|
||||
);
|
||||
|
||||
/// Lets startup-policy coverage assert the chrome this route actually opened
|
||||
/// with, rather than a controller the test seeded itself.
|
||||
@visibleForTesting
|
||||
PlayerChromeController get chromeController => _chromeController;
|
||||
|
||||
late final PlayerNavigationCoordinator _playerNavigationCoordinator;
|
||||
|
||||
@override
|
||||
@@ -1454,7 +1527,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
void _handleScreenPlayerNavigation(PlayerNavigationKey navigationKey) {
|
||||
if (navigationKey != PlayerNavigationKey.home) {
|
||||
final sheetController = OverlaySheetController.maybeOf(context);
|
||||
final sheetController = OverlaySheetController.maybeOf(_sheetContext);
|
||||
if (sheetController?.isOpen ?? false) {
|
||||
sheetController!.pop();
|
||||
return;
|
||||
@@ -1852,10 +1925,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (track.id == SubtitleTrack.off.id) {
|
||||
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
|
||||
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
|
||||
// Dropping only the secondary must not relabel the primary's
|
||||
// declined off as deliberate.
|
||||
_SubtitleSelectionSlot.secondary => PlaybackSubtitleSelection(
|
||||
primaryTrack: currentSelection.primaryTrack,
|
||||
primarySourceStreamId: currentSelection.primarySourceStreamId,
|
||||
primarySidecar: currentSelection.primarySidecar,
|
||||
declinedPreference: currentSelection.declinedPreference,
|
||||
),
|
||||
});
|
||||
if (mounted) _setPlayerState(() {});
|
||||
@@ -1864,7 +1940,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
final info = _currentMediaInfo;
|
||||
final currentPlayer = player;
|
||||
if (info == null || currentPlayer == null) return;
|
||||
|
||||
MediaSubtitleTrack? sourceTrack;
|
||||
final currentSourceId = switch (slot) {
|
||||
@@ -1875,41 +1950,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_SubtitleSelectionSlot.primary => currentSelection.primarySidecar,
|
||||
_SubtitleSelectionSlot.secondary => currentSelection.secondarySidecar,
|
||||
};
|
||||
if (sourceStreamId != null) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == sourceStreamId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
if (info != null) {
|
||||
if (sourceStreamId != null) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == sourceStreamId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == currentSourceId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == currentSourceId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
}
|
||||
if (sourceTrack == null && currentPlayer != null) {
|
||||
sourceTrack = findPlexTrackForMpvSubtitle(
|
||||
track,
|
||||
info.subtitleTracks,
|
||||
allMpvTracks: currentPlayer.state.tracks.subtitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
sourceTrack ??= findPlexTrackForMpvSubtitle(
|
||||
track,
|
||||
info.subtitleTracks,
|
||||
allMpvTracks: currentPlayer.state.tracks.subtitle,
|
||||
);
|
||||
if (sourceTrack == null) return;
|
||||
|
||||
final sidecar = _sidecarForSourceStreamId(session, sourceTrack.id);
|
||||
final resolvedTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
|
||||
final selection = PlaybackSubtitleSelection(
|
||||
primaryTrack: slot == _SubtitleSelectionSlot.primary ? resolvedTrack : currentSelection.primaryTrack,
|
||||
primarySourceStreamId: slot == _SubtitleSelectionSlot.primary
|
||||
? sourceTrack.id
|
||||
: currentSelection.primarySourceStreamId,
|
||||
primarySidecar: slot == _SubtitleSelectionSlot.primary ? sidecar : currentSelection.primarySidecar,
|
||||
secondaryTrack: slot == _SubtitleSelectionSlot.secondary ? resolvedTrack : currentSelection.secondaryTrack,
|
||||
secondarySourceStreamId: slot == _SubtitleSelectionSlot.secondary
|
||||
? sourceTrack.id
|
||||
: currentSelection.secondarySourceStreamId,
|
||||
secondarySidecar: slot == _SubtitleSelectionSlot.secondary ? sidecar : currentSelection.secondarySidecar,
|
||||
// No source identity (no catalog, or a native track the identity matcher
|
||||
// cannot map) still commits the raw pick: an uncommitted selection reads
|
||||
// as the session's previous choice — usually the initial off — and the
|
||||
// next episode boundary would harden that into an explicit off while the
|
||||
// viewer visibly watches with subtitles on (#1785). The raw track carries
|
||||
// no source ids, so it demotes to a semantic intent at the boundary.
|
||||
final sidecar = sourceTrack == null ? null : _sidecarForSourceStreamId(session, sourceTrack.id);
|
||||
final selection = subtitleSelectionForUserPick(
|
||||
currentSelection: currentSelection,
|
||||
isPrimarySlot: slot == _SubtitleSelectionSlot.primary,
|
||||
track: track,
|
||||
sourceTrack: sourceTrack,
|
||||
sidecar: sidecar,
|
||||
);
|
||||
_updatePlaybackSessionSubtitleSelection(session, selection);
|
||||
if (mounted) _setPlayerState(() {});
|
||||
@@ -1998,6 +2076,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_handleScreenPlayerNavigation(PlayerNavigationKey.back);
|
||||
},
|
||||
child: Builder(
|
||||
key: _overlayChildKey,
|
||||
builder: (sheetContext) => _isPlayerInitialized && player != null
|
||||
? _buildVideoPlayer(sheetContext)
|
||||
: (_playerInitializationError != null
|
||||
|
||||
@@ -139,7 +139,12 @@ abstract class BaseSharedPreferencesService {
|
||||
/// unreadable value — quarantining that one would reopen onto the stale
|
||||
/// in-memory map and write the bad value straight back.
|
||||
static Future<PrefsRepairOutcome> repairCorruptStore({bool reopenSafe = true}) async {
|
||||
final (:salvaged, :backupPath) = await PrefsRecovery.quarantine();
|
||||
final (:salvaged, :backupPath, :shape) = await PrefsRecovery.quarantine();
|
||||
// Conservative by construction: the warning is dropped only for bytes
|
||||
// proven to contain nothing. What the salvage recovered cannot stand in
|
||||
// for that — a store truncated mid-value matches no entry at all while
|
||||
// still holding most of a vault key in plaintext.
|
||||
final backupHoldsCredentials = !(shape?.allZero ?? false);
|
||||
|
||||
_resetGeneration++;
|
||||
_initializations.clear();
|
||||
@@ -158,6 +163,7 @@ abstract class BaseSharedPreferencesService {
|
||||
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
backupHoldsCredentials: backupHoldsCredentials,
|
||||
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
|
||||
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
|
||||
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
|
||||
@@ -190,6 +196,7 @@ abstract class BaseSharedPreferencesService {
|
||||
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
backupHoldsCredentials: backupHoldsCredentials,
|
||||
vaultKeySalvaged: salvaged.vaultKey != null,
|
||||
sessionsSalvaged: salvaged.sessions.length,
|
||||
sessionsLost: salvaged.losses,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../../media/media_rating.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../models/anilist/anilist_media.dart';
|
||||
import '../../models/catalog/catalog_cast_member.dart';
|
||||
@@ -218,8 +219,8 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
|
||||
CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl),
|
||||
];
|
||||
|
||||
static List<CatalogRatingSource>? _ratingsFor(double? meanRating) =>
|
||||
meanRating == null ? null : [CatalogRatingSource(source: 'anilist', value: meanRating)];
|
||||
static List<MediaRatingSource>? _ratingsFor(double? meanRating) =>
|
||||
meanRating == null ? null : [MediaRatingSource(source: 'anilist', value: meanRating)];
|
||||
|
||||
static CatalogNextEpisode? _nextEpisodeFor(AnilistNextAiringEpisode? airing) => airing == null
|
||||
? null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../media/media_rating.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../models/catalog/catalog_cast_member.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
@@ -7,6 +8,7 @@ import '../../utils/app_logger.dart';
|
||||
import '../../utils/country_codes.dart';
|
||||
import '../../utils/json_utils.dart';
|
||||
import '../plex_discover_client.dart';
|
||||
import '../plex_mappers.dart';
|
||||
import 'catalog_source.dart';
|
||||
import 'catalog_watchlist_machinery.dart';
|
||||
|
||||
@@ -259,8 +261,8 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
|
||||
}
|
||||
}
|
||||
|
||||
final headlineRating = _normalizedRating(metadata['rating']);
|
||||
final audienceRating = _normalizedRating(metadata['audienceRating']);
|
||||
final headlineRating = normalizedPlexRating(metadata['rating']);
|
||||
final audienceRating = normalizedPlexRating(metadata['audienceRating']);
|
||||
return CatalogItem(
|
||||
source: CatalogSourceId.plex,
|
||||
kind: kind,
|
||||
@@ -343,46 +345,17 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
|
||||
return credits.isEmpty ? null : credits;
|
||||
}
|
||||
|
||||
static List<CatalogRatingSource>? _ratingsFor(Map<String, dynamic> metadata) {
|
||||
final ratings = <CatalogRatingSource>[];
|
||||
final imdbVotes = flexibleInt(metadata['imdbRatingCount']);
|
||||
|
||||
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
|
||||
final value = _normalizedRating(rawValue);
|
||||
if (value == null) return;
|
||||
final source = _ratingSource(image: image, type: type, fallback: fallbackSource);
|
||||
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
|
||||
ratings.add(CatalogRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
|
||||
}
|
||||
|
||||
add(metadata['rating'], fallbackSource: 'critic', image: metadata['ratingImage']);
|
||||
add(metadata['audienceRating'], fallbackSource: 'audience', image: metadata['audienceRatingImage']);
|
||||
for (final rating in flexibleMapList(metadata['Rating'])) {
|
||||
add(rating['value'], fallbackSource: 'audience', image: rating['image'], type: rating['type']);
|
||||
}
|
||||
return ratings.isEmpty ? null : ratings;
|
||||
}
|
||||
|
||||
static String _ratingSource({Object? image, Object? type, required String fallback}) {
|
||||
final scheme = Uri.tryParse(_nonEmptyString(image) ?? '')?.scheme.toLowerCase();
|
||||
if (scheme == 'imdb') return 'imdb';
|
||||
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
|
||||
final ratingType = _nonEmptyString(type)?.toLowerCase() ?? fallback;
|
||||
if (scheme == 'rottentomatoes') {
|
||||
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
|
||||
}
|
||||
return switch (ratingType) {
|
||||
'critic' => 'critic',
|
||||
'audience' => 'audience',
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
static double? _normalizedRating(Object? value) {
|
||||
final rating = flexibleDouble(value);
|
||||
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
|
||||
return rating > 10 ? rating / 10 : rating;
|
||||
}
|
||||
static List<MediaRatingSource>? _ratingsFor(Map<String, dynamic> metadata) => plexRatingSources(
|
||||
rating: metadata['rating'],
|
||||
ratingImage: metadata['ratingImage'],
|
||||
audienceRating: metadata['audienceRating'],
|
||||
audienceRatingImage: metadata['audienceRatingImage'],
|
||||
ratingSources: [
|
||||
for (final rating in flexibleMapList(metadata['Rating']))
|
||||
(image: rating['image'], type: rating['type'], value: rating['value']),
|
||||
],
|
||||
imdbVotes: flexibleInt(metadata['imdbRatingCount']),
|
||||
);
|
||||
|
||||
static String? _overviewFor(Map<String, dynamic> metadata) {
|
||||
var overview = _nonEmptyString(metadata['summary']);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../media/media_rating.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../models/catalog/catalog_metadata.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
@@ -211,14 +212,14 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
|
||||
if (type == SimklCatalogType.anime && simklId != null) _animeIds.add(simklId);
|
||||
}
|
||||
|
||||
static List<CatalogRatingSource>? _ratingsFor(SimklRatings? ratings) {
|
||||
static List<MediaRatingSource>? _ratingsFor(SimklRatings? ratings) {
|
||||
if (ratings == null) return null;
|
||||
final result = <CatalogRatingSource>[];
|
||||
final result = <MediaRatingSource>[];
|
||||
for (final (source, rating) in [('simkl', ratings.simkl), ('imdb', ratings.imdb), ('mal', ratings.mal)]) {
|
||||
final value = rating?.rating;
|
||||
if (value == null || value < 0 || value > 10) continue;
|
||||
final votes = rating!.votes;
|
||||
result.add(CatalogRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
|
||||
result.add(MediaRatingSource(source: source, value: value, votes: votes != null && votes >= 0 ? votes : null));
|
||||
}
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../media/ids.dart';
|
||||
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_merge.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
@@ -53,6 +54,33 @@ Map<String, int> _searchKindCounts(Iterable<MediaItem> items) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Drop items belonging to a hidden library.
|
||||
///
|
||||
/// Items the backend could not attribute to a library ([MediaItem.libraryGlobalKey]
|
||||
/// is null) are kept: Plex search and `/library/shared/all` return shared and
|
||||
/// external rows that have no local section, and those are not something the
|
||||
/// user hid.
|
||||
List<MediaItem> _withoutHiddenLibraries(List<MediaItem> items, Set<String>? hiddenLibraryKeys) {
|
||||
if (hiddenLibraryKeys == null || hiddenLibraryKeys.isEmpty) return items;
|
||||
return items.where((item) {
|
||||
final libraryKey = item.libraryGlobalKey;
|
||||
return libraryKey == null || !hiddenLibraryKeys.contains(libraryKey);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// The server-local library ids [serverId] owns within [hiddenLibraryKeys],
|
||||
/// which hold cross-server `serverId:libraryId` global keys. Backends that
|
||||
/// cannot attribute a search hit to a library need these to scope the request.
|
||||
Set<String> _hiddenLibraryIdsOn(String serverId, Set<String>? hiddenLibraryKeys) {
|
||||
if (hiddenLibraryKeys == null || hiddenLibraryKeys.isEmpty) return const {};
|
||||
final ids = <String>{};
|
||||
for (final key in hiddenLibraryKeys) {
|
||||
final parsed = parseGlobalKey(key);
|
||||
if (parsed != null && parsed.serverId == serverId) ids.add(parsed.ratingKey);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Cross-server aggregation: fans calls out to every online client and
|
||||
/// merges the results. Single-server operations now go through the
|
||||
/// [MediaServerClient] interface directly (resolved via
|
||||
@@ -171,17 +199,8 @@ class DataAggregationService {
|
||||
failureMessage: (serverId) => 'Failed on-deck fetch from $serverId',
|
||||
fetch: (_, client) => client.fetchContinueWatching(count: limit),
|
||||
);
|
||||
final allOnDeck = fetched.items;
|
||||
|
||||
// Filter out items from hidden libraries
|
||||
List<MediaItem> filteredOnDeck = allOnDeck;
|
||||
if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) {
|
||||
filteredOnDeck = allOnDeck.where((item) {
|
||||
if (item.libraryId == null || item.serverId == null) return true;
|
||||
final globalKey = buildGlobalKey(ServerId(item.serverId!), item.libraryId!);
|
||||
return !hiddenLibraryKeys.contains(globalKey);
|
||||
}).toList();
|
||||
}
|
||||
var filteredOnDeck = _withoutHiddenLibraries(fetched.items, hiddenLibraryKeys);
|
||||
|
||||
// Sort by most recently viewed, falling back to addedAt for unwatched items.
|
||||
// Same key as JellyfinClient's continue-watching merge (MediaItem.recencySortKey)
|
||||
@@ -434,29 +453,35 @@ class DataAggregationService {
|
||||
final serverLibraries = libraries?[serverId];
|
||||
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
|
||||
final hubItemLimit = limit ?? defaultHubPreviewLimit;
|
||||
final hubs = shouldUseGlobalHubs
|
||||
? [
|
||||
...await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs),
|
||||
// Plex's promoted/global hub endpoint never includes music
|
||||
// libraries — append their per-library hubs so music rows
|
||||
// reach home. No-op (zero extra calls) without a visible
|
||||
// music library.
|
||||
...await _fetchLibraryHubsForClient(
|
||||
client,
|
||||
limit: hubItemLimit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraries: serverLibraries ?? const [],
|
||||
kinds: const {MediaKind.artist},
|
||||
),
|
||||
]
|
||||
: await _fetchLibraryHubsForClient(
|
||||
client,
|
||||
limit: hubItemLimit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraries: useGlobalHubs ? serverLibraries : null,
|
||||
);
|
||||
List<MediaHub> hubs;
|
||||
if (shouldUseGlobalHubs) {
|
||||
// Both legs are independent, so start them before awaiting either.
|
||||
// Spreading `...await a, ...await b` into one list literal evaluates
|
||||
// them in order, which serialised the music rows behind the global
|
||||
// hub round trip.
|
||||
final globalFuture = client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs);
|
||||
// Plex's promoted/global hub endpoint never includes music
|
||||
// libraries — append their per-library hubs so music rows
|
||||
// reach home. No-op (zero extra calls) without a visible
|
||||
// music library.
|
||||
final musicFuture = _fetchLibraryHubsForClient(
|
||||
client,
|
||||
limit: hubItemLimit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraries: serverLibraries ?? const [],
|
||||
kinds: const {MediaKind.artist},
|
||||
);
|
||||
hubs = [...await globalFuture, ...await musicFuture];
|
||||
} else {
|
||||
hubs = await _fetchLibraryHubsForClient(
|
||||
client,
|
||||
limit: hubItemLimit,
|
||||
hiddenLibraryKeys: hiddenLibraryKeys,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraries: useGlobalHubs ? serverLibraries : null,
|
||||
);
|
||||
}
|
||||
return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys);
|
||||
},
|
||||
);
|
||||
@@ -487,31 +512,39 @@ class DataAggregationService {
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// Sliding window rather than batches of three separated by a barrier: a
|
||||
// batch waits for its slowest member before the next one starts, so wall
|
||||
// time was the sum of per-batch maxima and one slow library stalled every
|
||||
// library queued behind it (#1784). Starting the next request the moment
|
||||
// any slot frees keeps the same peak concurrency with no head-of-line
|
||||
// blocking. Results are written back by index so hub order stays the
|
||||
// library order regardless of completion order.
|
||||
const concurrency = 3;
|
||||
final all = <MediaHub>[];
|
||||
for (var start = 0; start < visible.length; start += concurrency) {
|
||||
final batch = visible.skip(start).take(concurrency);
|
||||
final results = await Future.wait(
|
||||
batch.map((l) async {
|
||||
try {
|
||||
return await client.fetchLibraryHubs(
|
||||
l.id,
|
||||
libraryName: l.title,
|
||||
limit: limit,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraryKind: l.kind,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to fetch library hubs for ${l.globalKey}', error: e, stackTrace: st);
|
||||
return <MediaHub>[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (final list in results) {
|
||||
all.addAll(list);
|
||||
final results = List<List<MediaHub>>.filled(visible.length, const []);
|
||||
var next = 0;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final index = next++;
|
||||
if (index >= visible.length) return;
|
||||
final library = visible[index];
|
||||
try {
|
||||
results[index] = await client.fetchLibraryHubs(
|
||||
library.id,
|
||||
libraryName: library.title,
|
||||
limit: limit,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
libraryKind: library.kind,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to fetch library hubs for ${library.globalKey}', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
|
||||
await Future.wait([for (var i = 0; i < concurrency && i < visible.length; i++) worker()]);
|
||||
|
||||
return [for (final list in results) ...list];
|
||||
}
|
||||
|
||||
/// Filter hidden-library items and drop empty hubs.
|
||||
@@ -537,7 +570,16 @@ class DataAggregationService {
|
||||
|
||||
/// Search across all online servers (Plex + Jellyfin). Per-server outcomes
|
||||
/// distinguish authoritative empty results from failed or cancelled legs.
|
||||
Future<SearchAggregationResult> searchAcrossServers(String query, {int? limit, AbortController? abort}) async {
|
||||
///
|
||||
/// [hiddenLibraryKeys] excludes results the user has hidden, matching every
|
||||
/// other aggregated surface. Backends whose search rows carry no library id
|
||||
/// cannot be filtered here; they must scope the search server-side instead.
|
||||
Future<SearchAggregationResult> searchAcrossServers(
|
||||
String query, {
|
||||
int? limit,
|
||||
Set<String>? hiddenLibraryKeys,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return (
|
||||
items: const <MediaItem>[],
|
||||
@@ -566,7 +608,12 @@ class DataAggregationService {
|
||||
failureMessage: (serverId) => 'Search failed on $serverId',
|
||||
fetch: (serverId, client) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final items = await client.searchItems(query, limit: fetchLimit, abort: abort);
|
||||
final items = await client.searchItems(
|
||||
query,
|
||||
limit: fetchLimit,
|
||||
abort: abort,
|
||||
excludedLibraryIds: _hiddenLibraryIdsOn(serverId, hiddenLibraryKeys),
|
||||
);
|
||||
appLogger.i(
|
||||
'Search completed on $serverId in ${stopwatch.elapsedMilliseconds}ms: '
|
||||
'${items.length} results ${_searchKindCounts(items)}',
|
||||
@@ -575,7 +622,10 @@ class DataAggregationService {
|
||||
},
|
||||
);
|
||||
abort?.throwIfAborted();
|
||||
final items = rankMediaSearchResults(fetched.items, query, limit: resultLimit);
|
||||
// Before ranking, so hidden results cannot spend the `resultLimit` budget
|
||||
// and silently shrink what the user sees.
|
||||
final visible = _withoutHiddenLibraries(fetched.items, hiddenLibraryKeys);
|
||||
final items = rankMediaSearchResults(visible, query, limit: resultLimit);
|
||||
|
||||
appLogger.i(
|
||||
'Search aggregation completed: ${items.length} results '
|
||||
@@ -594,6 +644,15 @@ class DataAggregationService {
|
||||
/// Reverse external-id lookup fanned out to every online server (see
|
||||
/// [MediaServerClient.findByExternalIds]). One request wave per tap on an
|
||||
/// Explore catalog item; per-server failures are logged and skipped.
|
||||
///
|
||||
/// Every server contributes every copy it holds, not one apiece: the same
|
||||
/// movie routinely sits in a 4K library and an HD library on one server
|
||||
/// (#1754). Results are deduped by global key and ordered best-first with
|
||||
/// [compareLibraryCopies] so the chooser is stable across repeated passes.
|
||||
///
|
||||
/// Because per-server failures are dropped here, a caller holding earlier
|
||||
/// results must merge rather than replace (see [mergeLibraryCopies]) — a
|
||||
/// degraded wave is not evidence that a copy went away.
|
||||
Future<List<MediaItem>> findByExternalIdsAcrossServers(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
@@ -618,11 +677,11 @@ class DataAggregationService {
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st);
|
||||
return null;
|
||||
return const <MediaItem>[];
|
||||
}
|
||||
});
|
||||
|
||||
return (await Future.wait(futures)).nonNulls.toList();
|
||||
return mergeLibraryCopies(const [], (await Future.wait(futures)).expand((items) => items));
|
||||
}
|
||||
|
||||
/// Group libraries by server (internal aggregation helper).
|
||||
|
||||
@@ -253,14 +253,17 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
|
||||
// readers above do: one place to compare when a server adds a key.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–5;
|
||||
/// lyrics/other types beyond that map to [MediaStreamKind.unknown].
|
||||
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–4
|
||||
/// ([PlexStreamType]); anything else maps to [MediaStreamKind.unknown].
|
||||
///
|
||||
/// Type 4 is lyrics, not an embedded image — music tracks are the only items
|
||||
/// that carry it, so the mismatch only became visible once File Info was
|
||||
/// offered for tracks.
|
||||
MediaStreamKind plexStreamKind(Map<String, dynamic> s) => switch (flexibleInt(s['streamType'])) {
|
||||
PlexStreamType.video => MediaStreamKind.video,
|
||||
PlexStreamType.audio => MediaStreamKind.audio,
|
||||
PlexStreamType.subtitle => MediaStreamKind.subtitle,
|
||||
4 => MediaStreamKind.image,
|
||||
5 => MediaStreamKind.data,
|
||||
PlexStreamType.lyrics => MediaStreamKind.lyric,
|
||||
_ => MediaStreamKind.unknown,
|
||||
};
|
||||
|
||||
|
||||
@@ -28,12 +28,14 @@ class _JellyfinAuthenticationResponse {
|
||||
final String userId;
|
||||
final String userName;
|
||||
final bool isAdministrator;
|
||||
final String? primaryImageTag;
|
||||
|
||||
const _JellyfinAuthenticationResponse({
|
||||
required this.accessToken,
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.isAdministrator,
|
||||
this.primaryImageTag,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,6 +151,7 @@ class JellyfinConnectionAuthService {
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: validDeviceId,
|
||||
isAdministrator: auth.isAdministrator,
|
||||
primaryImageTag: auth.primaryImageTag,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
@@ -316,6 +319,7 @@ class JellyfinConnectionAuthService {
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: validDeviceId,
|
||||
isAdministrator: auth.isAdministrator,
|
||||
primaryImageTag: auth.primaryImageTag,
|
||||
);
|
||||
} finally {
|
||||
exchangeClient.close();
|
||||
@@ -419,6 +423,7 @@ class JellyfinConnectionAuthService {
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
isAdministrator: policy?['IsAdministrator'] as bool? ?? false,
|
||||
primaryImageTag: JellyfinConnection.readPrimaryImageTag(user),
|
||||
);
|
||||
} on TimeoutException {
|
||||
// MediaServerHttpClient normally wraps timeouts, but keep raw client
|
||||
@@ -445,6 +450,7 @@ class JellyfinConnectionAuthService {
|
||||
required String accessToken,
|
||||
required String deviceId,
|
||||
required bool isAdministrator,
|
||||
required String? primaryImageTag,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return JellyfinConnection(
|
||||
@@ -458,6 +464,7 @@ class JellyfinConnectionAuthService {
|
||||
accessToken: accessToken,
|
||||
deviceId: deviceId,
|
||||
isAdministrator: isAdministrator,
|
||||
primaryImageTag: primaryImageTag,
|
||||
status: ConnectionStatus.online,
|
||||
createdAt: now,
|
||||
lastAuthenticatedAt: now,
|
||||
|
||||
@@ -39,6 +39,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/device_identity.dart';
|
||||
import '../utils/failover_http_client.dart';
|
||||
import '../utils/media_server_retry.dart';
|
||||
import '../utils/future_extensions.dart';
|
||||
import '../utils/media_server_timeouts.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
@@ -111,7 +112,12 @@ class JellyfinClient
|
||||
_JellyfinLiveTvMethods,
|
||||
_JellyfinImageDownloadMethods,
|
||||
_JellyfinMetadataEditMethods
|
||||
implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable {
|
||||
implements
|
||||
MediaServerClient,
|
||||
SeasonEpisodePagingClient,
|
||||
MediaDeletionPermissionClient,
|
||||
ScopedMediaServerClient,
|
||||
GracefullyCloseable {
|
||||
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
||||
|
||||
@@ -171,6 +177,11 @@ class JellyfinClient
|
||||
baseUrl: connection.baseUrl,
|
||||
defaultHeaders: headers,
|
||||
logLabel: 'Jellyfin',
|
||||
// Same pool tuning Plex uses: the home fan-out issues several concurrent
|
||||
// requests per pass, and the untuned dart:io default drops idle
|
||||
// connections after 15s — a fresh TLS handshake per request on a
|
||||
// high-RTT/CDN link.
|
||||
usePlexApiClient: true,
|
||||
prioritizedEndpoints: connection.baseUrls,
|
||||
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
|
||||
onAllEndpointsExhausted: onAllEndpointsExhausted,
|
||||
@@ -311,9 +322,10 @@ class JellyfinClient
|
||||
/// real call to 401.
|
||||
///
|
||||
/// Side-effect: when the response body carries a fresh
|
||||
/// `Policy.IsAdministrator` that differs from the cached one, refresh the
|
||||
/// connection so admin-gated UI catches the server-side change without
|
||||
/// requiring re-auth (see [onConnectionUpdated]).
|
||||
/// `Policy.IsAdministrator` or primary profile-picture tag that differs
|
||||
/// from the cached value, refresh the connection so admin-gated UI and
|
||||
/// profile avatars catch server-side changes without requiring re-auth
|
||||
/// (see [onConnectionUpdated]).
|
||||
///
|
||||
/// 401/403 surfaces as [HealthStatus.authError] so the manager can
|
||||
/// distinguish a revoked token from a generic transport failure.
|
||||
@@ -326,17 +338,24 @@ class JellyfinClient
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final policy = data['Policy'];
|
||||
if (policy is Map<String, dynamic>) {
|
||||
final fresh = policy['IsAdministrator'] as bool?;
|
||||
if (fresh != null && fresh != _connection.isAdministrator) {
|
||||
_connection = _connection.copyWith(isAdministrator: fresh);
|
||||
final listener = onConnectionUpdated;
|
||||
if (listener != null) {
|
||||
try {
|
||||
await Future.sync(() => listener(_connection));
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
|
||||
}
|
||||
final freshIsAdministrator = policy is Map<String, dynamic> ? policy['IsAdministrator'] as bool? : null;
|
||||
final freshPrimaryImageTag = JellyfinConnection.readPrimaryImageTag(data);
|
||||
final isAdministratorChanged =
|
||||
freshIsAdministrator != null && freshIsAdministrator != _connection.isAdministrator;
|
||||
final primaryImageTagChanged = freshPrimaryImageTag != _connection.primaryImageTag;
|
||||
|
||||
if (isAdministratorChanged || primaryImageTagChanged) {
|
||||
_connection = _connection.copyWith(
|
||||
isAdministrator: freshIsAdministrator,
|
||||
primaryImageTag: freshPrimaryImageTag,
|
||||
clearPrimaryImageTag: primaryImageTagChanged && freshPrimaryImageTag == null,
|
||||
);
|
||||
final listener = onConnectionUpdated;
|
||||
if (listener != null) {
|
||||
try {
|
||||
await Future.sync(() => listener(_connection));
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,21 @@ part of '../../jellyfin_client.dart';
|
||||
|
||||
String _segment(String value) => Uri.encodeComponent(value);
|
||||
|
||||
/// Transport policy for a hub surface: bounded transient retries, no
|
||||
/// endpoint failover. See `_getItemsResponse`.
|
||||
typedef _HubRetryPolicy = ({String operation, List<Duration> attemptTimeouts});
|
||||
/// Transport policy for a hub surface: one whole-request deadline, retries
|
||||
/// only on immediate connection errors, no endpoint failover. See
|
||||
/// `_getItemsResponse` and [retryTransientMediaServerCall].
|
||||
typedef _HubRetryPolicy = ({String operation, Duration deadline});
|
||||
|
||||
const _HubRetryPolicy _homeHubRetry = (
|
||||
operation: 'Jellyfin home hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
|
||||
);
|
||||
const _HubRetryPolicy _homeHubRetry = (operation: 'Jellyfin home hubs', deadline: MediaServerTimeouts.homeHubDeadline);
|
||||
|
||||
const _HubRetryPolicy _libraryHubRetry = (
|
||||
operation: 'Jellyfin library hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.libraryHubDeadline,
|
||||
);
|
||||
|
||||
const _HubRetryPolicy _continueWatchingRetry = (
|
||||
operation: 'Jellyfin continue watching',
|
||||
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.homeHubDeadline,
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> _itemsArray(Object? data) {
|
||||
@@ -58,21 +56,57 @@ LibraryPage<T> _pagedItems<T>(
|
||||
/// list calls; we ask for the minimum extras needed to drive the
|
||||
/// MediaItem mapper:
|
||||
/// - `RecursiveItemCount`/`ChildCount` for series leaf count
|
||||
/// - `UserData` is included in defaults but pinned for safety
|
||||
/// - `PremiereDate` for sort-by-release-date and episode metadata
|
||||
/// - `OriginalTitle`/`SortName` for sort + alphabetised display
|
||||
/// - `Overview` so list rows can show their description
|
||||
///
|
||||
/// Heavier fields (`MediaSources`, `People`, `Genres`, `Tags`, `Studios`,
|
||||
/// `Taglines`, `ProviderIds`, `Chapters`) stay in [_detailFields] — together
|
||||
/// they added seconds to large-library pages on small home servers.
|
||||
const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview';
|
||||
///
|
||||
/// `UserData` and `PremiereDate` are deliberately absent: neither is a member
|
||||
/// of Jellyfin's `ItemFields` enum, so the server's
|
||||
/// `CommaDelimitedCollectionModelBinder` drops them element-by-element and
|
||||
/// they never did anything. `UserData` is governed by `EnableUserData`
|
||||
/// (default true) and `dto.PremiereDate` is set unconditionally.
|
||||
const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview';
|
||||
|
||||
/// Field set for the home / per-library hub rows (Recently Added, Continue
|
||||
/// Watching, Next Up). Poster cards render artwork, title, year and the
|
||||
/// watch badge; only `Overview` needs asking for, to feed the mobile hero
|
||||
/// (`discover_screen`) and the TV spotlight blurb.
|
||||
///
|
||||
/// Crucially this drops `RecursiveItemCount`/`ChildCount`. `/Items/Latest`
|
||||
/// groups a TV library by series, so those rows are Series FOLDER dtos and
|
||||
/// each count field costs a per-row DB query server-side
|
||||
/// (`Folder.GetRecursiveChildCount` / `Folder.GetChildCount`) — the same cost
|
||||
/// already documented on [_folderBrowseFields] and [_musicAlbumRowFields],
|
||||
/// which the home rows never got (#1784).
|
||||
///
|
||||
/// Watch state survives intact: Jellyfin computes `UserData.Played` from
|
||||
/// `UnplayedItemCount` when `RecursiveItemCount` is absent
|
||||
/// (`Folder.FillUserDataDtoValues`), and [MediaItem.unwatchedCount] falls back
|
||||
/// to `UserData.UnplayedItemCount`. Only the season progress bar needs real
|
||||
/// leaf totals, and seasons never appear on a hub row.
|
||||
const _hubRowFields = 'Overview';
|
||||
|
||||
/// How far back `/Shows/NextUp` looks for a series to resume, mirroring
|
||||
/// Jellyfin web's `maxDaysForNextUp` default. Without it the server's
|
||||
/// `GetNextUpSeriesKeys` scan is unbounded over every series the user has ever
|
||||
/// played an episode of.
|
||||
const _nextUpDateCutoffDays = 365;
|
||||
|
||||
String _nextUpDateCutoff() =>
|
||||
DateTime.now().toUtc().subtract(const Duration(days: _nextUpDateCutoffDays)).toIso8601String();
|
||||
|
||||
/// Existing episode-row requests can show Plex-style quality labels when the
|
||||
/// response includes `MediaSources`. Keep this off broad library/search/latest
|
||||
/// queries because it is the heaviest item field Jellyfin returns.
|
||||
const _episodeRowFields = '$_browseFields,MediaSources';
|
||||
|
||||
/// Media types global search surfaces. Episodes are included so a user can
|
||||
/// find a single episode by name.
|
||||
const _searchItemTypes = 'Movie,Series,Episode,MusicAlbum,Audio';
|
||||
|
||||
/// Folder-tree field set for MEDIA children. The tree renders
|
||||
/// title/thumb/watch state plus default dto fields (year, runtime, ratings);
|
||||
/// it deliberately skips `RecursiveItemCount`/`ChildCount` — per-item COUNT
|
||||
@@ -197,9 +231,51 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
// endpoints. We mirror that exactly so requests hash the same way against
|
||||
// proxy rules and rate limiters as a stock Jellyfin app.
|
||||
|
||||
/// Views as of the last load, reused by scoped search.
|
||||
///
|
||||
/// Scoped search runs on every debounced keystroke and `/Views` sits
|
||||
/// serially in front of its per-library legs, so re-fetching it each pass is
|
||||
/// pure added latency. `LibrariesProvider` loads libraries before the content
|
||||
/// tabs refresh (main_screen `_primeOnlineServices` awaits `loadLibraries()`),
|
||||
/// so this is already warm by the time the user can type, and every later
|
||||
/// load replaces it — search can never be working from a staler library list
|
||||
/// than the one on screen. Dies with the client, like Plex's
|
||||
/// `_providerLibraries`.
|
||||
List<MediaLibrary>? _loadedLibraryViews;
|
||||
|
||||
/// In-flight `/Views` request, shared by concurrent callers.
|
||||
///
|
||||
/// At cold start `LibrariesProvider.loadLibraries()` and
|
||||
/// `DataAggregationService.getHubsFromAllServers` both ask for libraries at
|
||||
/// the same time, and the hub fan-out sits *serially* behind its copy. Two
|
||||
/// identical uncached round trips is a full RTT of pure cold-start latency on
|
||||
/// a remote server (#1784). Plex has never paid it — its library list comes
|
||||
/// from the `/media/providers` response cached at client creation.
|
||||
///
|
||||
/// Single-flight only: once the request settles the next caller re-fetches,
|
||||
/// so a library added server-side still shows up on the next refresh.
|
||||
Future<List<MediaLibrary>>? _inFlightLibraries;
|
||||
|
||||
@override
|
||||
Future<List<MediaLibrary>> fetchLibraries() async {
|
||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
|
||||
Future<List<MediaLibrary>> fetchLibraries() {
|
||||
final inFlight = _inFlightLibraries;
|
||||
if (inFlight != null) return inFlight;
|
||||
|
||||
final request = _fetchLibraries().then((libraries) {
|
||||
_loadedLibraryViews = libraries;
|
||||
return libraries;
|
||||
});
|
||||
_inFlightLibraries = request;
|
||||
return request.whenComplete(() {
|
||||
if (identical(_inFlightLibraries, request)) _inFlightLibraries = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// [abort] tears the view fetch down with the pass that owns it — a
|
||||
/// superseded search must not leave `/Views` running.
|
||||
Future<List<MediaLibrary>> _fetchLibraries({AbortController? abort}) async {
|
||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort);
|
||||
abort?.throwIfAborted();
|
||||
throwIfHttpError(response);
|
||||
final items = _itemsArray(response.data);
|
||||
// Jellyfin surfaces the user's collection (BoxSet) and playlist roots as
|
||||
@@ -536,12 +612,23 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
/// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex
|
||||
/// OnDeck semantics: returns the resume episode when one exists, or S1E1
|
||||
/// when the user hasn't started. Movies and other kinds short-circuit.
|
||||
///
|
||||
/// The chain stays sequential — firing both together measured no better,
|
||||
/// because the requests contend rather than overlap (`/Shows/NextUp` went
|
||||
/// from 380ms alone to 1395ms beside the detail fetch) and a movie would pay
|
||||
/// for a request it can never use. Instead the item is handed to
|
||||
/// [onItemReady] the moment it lands, so the caller can paint without
|
||||
/// waiting for the on-deck round trip (#1784).
|
||||
@override
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
|
||||
String id, {
|
||||
void Function(MediaItem item)? onItemReady,
|
||||
}) async {
|
||||
final item = await fetchItem(id);
|
||||
if (item == null || item.kind != MediaKind.show) {
|
||||
return (item: item, onDeckEpisode: null);
|
||||
}
|
||||
onItemReady?.call(item);
|
||||
final nextUp = await _safeFetchItemsArray('/Shows/NextUp', {
|
||||
'seriesId': id,
|
||||
'userId': connection.userId,
|
||||
@@ -553,8 +640,33 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
return (item: item, onDeckEpisode: onDeckEpisode);
|
||||
}
|
||||
|
||||
/// In-flight `fetchItem` requests, keyed by item id.
|
||||
///
|
||||
/// Opening a detail screen issues two identical full-detail GETs for the
|
||||
/// same id at the same time — `_loadFullMetadata` and, from
|
||||
/// `_initWatchlistState`, `fetchExternalIds` — and playback start adds three
|
||||
/// more. Each one makes the server rebuild the whole dto (People, Chapters
|
||||
/// and MediaSources are a DB query apiece, Trickplay several), so the
|
||||
/// duplicates are expensive on both ends (#1784).
|
||||
///
|
||||
/// Single-flight only: once a request settles the next caller re-fetches, so
|
||||
/// nothing here can serve a stale item.
|
||||
final Map<String, Future<MediaItem?>> _inFlightItems = {};
|
||||
|
||||
@override
|
||||
Future<MediaItem?> fetchItem(String id) async {
|
||||
Future<MediaItem?> fetchItem(String id) {
|
||||
final existing = _inFlightItems[id];
|
||||
if (existing != null) return existing;
|
||||
|
||||
late final Future<MediaItem?> request;
|
||||
request = _fetchItemOnce(id).whenComplete(() {
|
||||
if (identical(_inFlightItems[id], request)) _inFlightItems.remove(id);
|
||||
});
|
||||
_inFlightItems[id] = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
Future<MediaItem?> _fetchItemOnce(String id) async {
|
||||
final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}';
|
||||
// Contract:
|
||||
// - 200 with parseable Map → MediaItem
|
||||
@@ -1084,25 +1196,85 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
|
||||
// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
|
||||
// only matches folder-derived MusicArtist rows (under folder names), so
|
||||
// tag-only artists would never appear in search. The artists leg is
|
||||
// best-effort — a music-endpoint hiccup shouldn't sink video search.
|
||||
Future<List<MediaItem>> searchItems(
|
||||
String query, {
|
||||
int limit = 100,
|
||||
AbortController? abort,
|
||||
Set<String> excludedLibraryIds = const {},
|
||||
}) async {
|
||||
if (excludedLibraryIds.isEmpty) return _searchEverywhere(query, limit: limit, abort: abort);
|
||||
|
||||
// Jellyfin search rows cannot be attributed to a library after the fact:
|
||||
// there is no library field, and `ParentId` (which this request does not
|
||||
// even ask for) resolves to a season or physical folder, never the owning
|
||||
// CollectionFolder. A hidden library can therefore only be excluded by
|
||||
// scoping the request, one per visible library.
|
||||
var libraries = _loadedLibraryViews;
|
||||
if (libraries == null) {
|
||||
libraries = await _fetchLibraries(abort: abort);
|
||||
// `??=`, not `=`: an explicit library load that started later can finish
|
||||
// first, and this older response must not clobber its newer views.
|
||||
_loadedLibraryViews ??= libraries;
|
||||
}
|
||||
abort?.throwIfAborted();
|
||||
final visible = [
|
||||
for (final library in libraries)
|
||||
if (!excludedLibraryIds.contains(library.id)) library,
|
||||
];
|
||||
// Nothing hidden actually belongs to this server — keep the single query.
|
||||
if (visible.length == libraries.length) return _searchEverywhere(query, limit: limit, abort: abort);
|
||||
if (visible.isEmpty) return const [];
|
||||
|
||||
// Each leg keeps the full candidate budget. Splitting it would cap a
|
||||
// library that holds every match (two visible libraries, 100 matches in
|
||||
// one, would return 50), and the caller's pre-ranking budget guarantee is
|
||||
// worth more than the payload saved.
|
||||
const concurrency = 3;
|
||||
// Legs can overlap: `/Artists` resolves parentId to an *ancestor* filter,
|
||||
// so an artist with tracks in two visible music libraries comes back from
|
||||
// both. Ranking does not deduplicate, so the first hit wins here — the
|
||||
// same merge the Plex search does across its supplemental legs.
|
||||
final deduplicated = <String, MediaItem>{};
|
||||
for (var start = 0; start < visible.length; start += concurrency) {
|
||||
abort?.throwIfAborted();
|
||||
final batch = visible.skip(start).take(concurrency);
|
||||
final results = await Future.wait([
|
||||
for (final library in batch) _searchLibrary(library, query, limit: limit, abort: abort),
|
||||
]);
|
||||
for (final items in results) {
|
||||
for (final item in items) {
|
||||
deduplicated.putIfAbsent(item.id, () => item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return deduplicated.values.toList();
|
||||
}
|
||||
|
||||
/// Unscoped search: one request across every library the user can see.
|
||||
///
|
||||
/// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
|
||||
/// only matches folder-derived MusicArtist rows (under folder names), so
|
||||
/// tag-only artists would never appear in search. The artists leg is
|
||||
/// best-effort — a music-endpoint hiccup shouldn't sink video search.
|
||||
Future<List<MediaItem>> _searchEverywhere(String query, {required int limit, AbortController? abort}) async {
|
||||
final results = await Future.wait([
|
||||
_fetchItemsArray('/Items', {
|
||||
'userId': connection.userId,
|
||||
'SearchTerm': query,
|
||||
'Recursive': 'true',
|
||||
'Limit': limit.toString(),
|
||||
'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
|
||||
'IncludeItemTypes': _searchItemTypes,
|
||||
'Fields': _browseFields,
|
||||
// Search ranks and trims client-side and never reads the total, which
|
||||
// the server pays for separately on a broad term.
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort),
|
||||
_safeFetchItemsArray('/Artists', {
|
||||
'userId': connection.userId,
|
||||
'searchTerm': query,
|
||||
'Limit': limit.toString(),
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort),
|
||||
]);
|
||||
@@ -1110,17 +1282,94 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
return _mapItems([...results.first, ...results[1]]);
|
||||
}
|
||||
|
||||
/// Search a single library. Results are stamped with it, which is the only
|
||||
/// way a Jellyfin search hit ever learns which library it came from.
|
||||
Future<List<MediaItem>> _searchLibrary(
|
||||
MediaLibrary library,
|
||||
String query, {
|
||||
required int limit,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
// MusicAlbum is a folder DTO: requesting UserData or count fields makes
|
||||
// small servers compute recursive unplayed/count data per album (#1552).
|
||||
// Keep albums and Audio leaves on separate requests so albums can disable
|
||||
// UserData while tracks retain their cheap direct play-state lookup.
|
||||
final itemFutures = library.kind == MediaKind.artist
|
||||
? <Future<List<Map<String, dynamic>>>>[
|
||||
_fetchItemsArray('/Items', {
|
||||
'userId': connection.userId,
|
||||
'SearchTerm': query,
|
||||
'Recursive': 'true',
|
||||
'Limit': limit.toString(),
|
||||
'IncludeItemTypes': 'MusicAlbum',
|
||||
'ParentId': library.id,
|
||||
'Fields': _musicAlbumRowFields,
|
||||
'EnableUserData': 'false',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort),
|
||||
_fetchItemsArray('/Items', {
|
||||
'userId': connection.userId,
|
||||
'SearchTerm': query,
|
||||
'Recursive': 'true',
|
||||
'Limit': limit.toString(),
|
||||
'IncludeItemTypes': 'Audio',
|
||||
'ParentId': library.id,
|
||||
'Fields': _musicTrackRowFields,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort),
|
||||
]
|
||||
: <Future<List<Map<String, dynamic>>>>[
|
||||
_fetchItemsArray('/Items', {
|
||||
'userId': connection.userId,
|
||||
'SearchTerm': query,
|
||||
'Recursive': 'true',
|
||||
'Limit': limit.toString(),
|
||||
'IncludeItemTypes': _searchItemTypes,
|
||||
'ParentId': library.id,
|
||||
'Fields': _browseFields,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort),
|
||||
];
|
||||
// `/Artists` takes parentId and resolves it to an ancestor filter, but
|
||||
// only a music library can contain any — asking elsewhere just buys an
|
||||
// empty response.
|
||||
final artistsFuture = library.kind == MediaKind.artist
|
||||
? _safeFetchItemsArray('/Artists', {
|
||||
'userId': connection.userId,
|
||||
'searchTerm': query,
|
||||
'Limit': limit.toString(),
|
||||
'parentId': library.id,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, abort: abort)
|
||||
: Future<List<Map<String, dynamic>>>.value(const []);
|
||||
|
||||
final results = await Future.wait([...itemFutures, artistsFuture]);
|
||||
abort?.throwIfAborted();
|
||||
return [
|
||||
for (final item in _mapItems([for (final result in results) ...result]))
|
||||
item.copyWith(libraryId: library.id, libraryTitle: library.title),
|
||||
];
|
||||
}
|
||||
|
||||
/// Jellyfin removed `anyProviderIdEquals` (silently ignored on 10.11.10, so
|
||||
/// it returns the unfiltered page), leaving a title search verified against
|
||||
/// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and
|
||||
/// has no meaning in Jellyfin's provider-id model, so it is ignored.
|
||||
///
|
||||
/// Every id-verified candidate of the first matching title is returned, not
|
||||
/// just the first: one movie can sit in both a 4K library and an HD library
|
||||
/// as two separate items, and the caller shows the user each copy (#1754).
|
||||
///
|
||||
/// Jellyfin cannot report season ordering to a non-admin: on 10.11.10,
|
||||
/// `/Library/VirtualFolders` returns 403 and Series items omit
|
||||
/// `DisplayOrder`. Resolve against an unknown provider rather than guessing
|
||||
/// from `ProviderIds`, so only seasons on which TVDB and TMDB agree are gated.
|
||||
@override
|
||||
Future<MediaItem?> findByExternalIds(
|
||||
Future<List<MediaItem>> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> titles = const [],
|
||||
@@ -1133,7 +1382,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
MediaKind.show => 'Series',
|
||||
_ => null,
|
||||
};
|
||||
if (itemType == null || !ids.hasAny || titles.isEmpty) return null;
|
||||
if (itemType == null || !ids.hasAny || titles.isEmpty) return const [];
|
||||
|
||||
final seasonIndex = season?.agreedSeason;
|
||||
final shouldGateSeason = kind == MediaKind.show && seasonIndex != null && seasonIndex > 1;
|
||||
@@ -1155,20 +1404,34 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'years': ?years,
|
||||
...jellyfinImageQueryParameters,
|
||||
});
|
||||
final match = ExternalIds.jellyfinCandidateMatching(candidates, ids);
|
||||
if (match == null) continue;
|
||||
final item = _mapItems([match]).firstOrNull;
|
||||
if (item == null) continue;
|
||||
final matches = _mapItems(ExternalIds.jellyfinCandidatesMatching(candidates, ids));
|
||||
if (matches.isEmpty) continue;
|
||||
|
||||
if (shouldGateSeason) {
|
||||
final children = await fetchChildren(item.id);
|
||||
if (!children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return _withLibraryFromAncestors(item);
|
||||
final kept = shouldGateSeason ? await _keepMatchesWithSeason(matches, seasonIndex) : matches;
|
||||
// A title that verified but has no season-gated survivor is a definitive
|
||||
// "this server has the show, just not that season"; broader title forms
|
||||
// would only reach other shows.
|
||||
if (kept.isEmpty) return const [];
|
||||
return Future.wait([for (final item in kept) _withLibraryFromAncestors(item)]);
|
||||
}
|
||||
return null;
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Keep only the series that actually have [seasonIndex]. One
|
||||
/// `fetchChildren` per candidate, issued concurrently because the match list
|
||||
/// is deliberately never truncated (see
|
||||
/// [MediaServerClient.findByExternalIds]).
|
||||
Future<List<MediaItem>> _keepMatchesWithSeason(List<MediaItem> items, int seasonIndex) async {
|
||||
final kept = await Future.wait([
|
||||
for (final item in items)
|
||||
fetchChildren(
|
||||
item.id,
|
||||
).then((children) => children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)),
|
||||
]);
|
||||
return [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
if (kept[index]) items[index],
|
||||
];
|
||||
}
|
||||
|
||||
/// Best-effort library stamp for items found outside a library context
|
||||
@@ -1238,7 +1501,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
_fetchItemsArray('/UserItems/Resume', {
|
||||
'userId': connection.userId,
|
||||
'Limit': ?count?.toString(),
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'MediaTypes': 'Video',
|
||||
'Recursive': 'true',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
@@ -1247,8 +1510,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
_safeFetchItemsArray('/Shows/NextUp', {
|
||||
'userId': connection.userId,
|
||||
'Limit': ?count?.toString(),
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'EnableResumable': 'false',
|
||||
'NextUpDateCutoff': _nextUpDateCutoff(),
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: _continueWatchingRetry),
|
||||
@@ -1343,7 +1607,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
|
||||
'Limit': limit.toString(),
|
||||
'ParentId': ?parentId,
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'IncludeItemTypes': ?latestItemTypes,
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: retry);
|
||||
@@ -1371,7 +1635,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'Limit': limit.toString(),
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'MediaTypes': 'Video',
|
||||
'Recursive': 'true',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
@@ -1382,8 +1646,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'Limit': limit.toString(),
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'EnableResumable': 'false',
|
||||
'NextUpDateCutoff': _nextUpDateCutoff(),
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: retry)
|
||||
@@ -1525,7 +1790,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'IncludeItemTypes': 'Movie,Series,Episode,Video,MusicVideo,Photo',
|
||||
'SortBy': 'DateCreated,SortName,ProductionYear',
|
||||
'SortOrder': 'Descending,Descending,Descending',
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
@@ -1556,7 +1821,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'Recursive': 'true',
|
||||
'EnableTotalRecordCount': 'true',
|
||||
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
|
||||
@@ -1573,9 +1838,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'Fields': _hubRowFields,
|
||||
'ParentId': ?parentId,
|
||||
'EnableResumable': 'false',
|
||||
'NextUpDateCutoff': _nextUpDateCutoff(),
|
||||
'EnableTotalRecordCount': 'true',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
@@ -1860,14 +2126,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}
|
||||
|
||||
/// GET [path], optionally under a hub-surface transport policy ([retry]):
|
||||
/// bounded transient retries with per-attempt timeouts and **no endpoint
|
||||
/// failover** — a slow hub row must not move the whole client off an
|
||||
/// otherwise working endpoint (same policy as Plex's three hub fetches;
|
||||
/// see [retryTransientMediaServerCall] / [FailoverHttpClient]).
|
||||
/// one whole-request deadline, retries only on immediate connection errors,
|
||||
/// and **no endpoint failover** — a slow hub row must not move the whole
|
||||
/// client off an otherwise working endpoint (same policy as Plex's three hub
|
||||
/// fetches; see [retryTransientMediaServerCall] / [FailoverHttpClient]).
|
||||
///
|
||||
/// [timeout] and [allowEndpointFailover] configure the un-retried path only; a
|
||||
/// [retry] policy carries its own per-attempt timeouts and always disables
|
||||
/// failover.
|
||||
/// [retry] policy carries its own deadline and always disables failover.
|
||||
Future<MediaServerResponse> _getItemsResponse(
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters,
|
||||
@@ -1888,7 +2153,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
abort?.throwIfAborted();
|
||||
return retryTransientMediaServerCall(
|
||||
operation: retry.operation,
|
||||
attemptTimeouts: retry.attemptTimeouts,
|
||||
deadline: retry.deadline,
|
||||
call: (timeout, attemptAbort) => _http.get(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
|
||||
@@ -144,4 +144,54 @@ mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
|
||||
throwIfHttpError(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `/Items?ids=` rather than `/Users/{id}/Items/{id}`: the single-item route
|
||||
/// ignores `Fields` and returns the whole dto (measured 33 KB / ~150 ms on a
|
||||
/// remote server), while the list route honours it and answers with a ~0.5 KB
|
||||
/// body in ~40 ms. Images and user data are switched off for the same reason.
|
||||
///
|
||||
/// An id the user cannot see comes back as an empty `Items` array, which is
|
||||
/// the same answer as "not allowed" for gating purposes.
|
||||
///
|
||||
/// A context menu waits on this, so the probe carries a real wall-clock
|
||||
/// ceiling: [MediaServerHttpClient] applies its `timeout` to the connect and
|
||||
/// receive phases separately (so it alone would allow roughly double), and
|
||||
/// `allowEndpointFailover: false` keeps a dead endpoint from walking the
|
||||
/// candidate list while the user holds a long-press. On expiry the request is
|
||||
/// aborted rather than left running, and the timeout propagates so the caller
|
||||
/// fails closed.
|
||||
// No `@override`: like [fetchSeasonEpisodesPage], this satisfies an optional
|
||||
// capability interface that the concrete client implements, not a member of
|
||||
// the mixin's superclass constraint.
|
||||
Future<bool?> fetchDeletePermission(MediaItem item) async {
|
||||
final abort = AbortController();
|
||||
try {
|
||||
final response = await _http
|
||||
.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'ids': item.id,
|
||||
'userId': connection.userId,
|
||||
'Fields': 'CanDelete',
|
||||
'EnableImages': 'false',
|
||||
'EnableUserData': 'false',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
},
|
||||
timeout: MediaServerTimeouts.jellyfinDeletePermission,
|
||||
abort: abort,
|
||||
allowEndpointFailover: false,
|
||||
)
|
||||
.namedTimeout(MediaServerTimeouts.jellyfinDeletePermission, operation: 'jellyfin delete permission');
|
||||
throwIfHttpError(response);
|
||||
final items = _itemsArray(response.data);
|
||||
if (items.isEmpty) return false;
|
||||
return items.first['CanDelete'] as bool?;
|
||||
} on TimeoutException catch (e) {
|
||||
// Stop the request rather than leave it running, and hand the caller the
|
||||
// same exception shape `MediaServerHttpClient` raises for its own
|
||||
// per-phase expiries.
|
||||
abort.abort();
|
||||
throw MediaServerHttpException.from(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_part.dart';
|
||||
import '../media/media_rating.dart';
|
||||
import '../media/media_role.dart';
|
||||
import '../media/media_stream.dart';
|
||||
import '../media/media_version.dart';
|
||||
@@ -127,6 +128,40 @@ class JellyfinImageAbsolutizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute URL of a Jellyfin user's own profile picture, or `null` when the
|
||||
/// user has none (absent [tag]) — returning null keeps us from firing a
|
||||
/// request that can only 404.
|
||||
///
|
||||
/// Unlike item artwork this endpoint carries **no `api_key`**: the user-image
|
||||
/// GET has never been authenticated (no `[Authorize]`, and Jellyfin sets no
|
||||
/// ASP.NET `FallbackPolicy`) on any release from 10.6 through 12.0-dev.
|
||||
/// Leaving the token out keeps it off the image cache key and out of anything
|
||||
/// that logs or persists the URL.
|
||||
///
|
||||
/// The legacy `/Users/{id}/Images/Primary` route is used rather than 10.9's
|
||||
/// `/UserImage` because Plezy declares no minimum server version; upstream
|
||||
/// still routes the legacy shape and annotates it "Kept for backwards
|
||||
/// compatibility". The `{imageType}` segment is bound but ignored server-side
|
||||
/// — it always serves the profile image.
|
||||
///
|
||||
/// [tag] is the server's `PrimaryImageTag`, `MD5(imagePath + lastModified)`,
|
||||
/// so the URL changes exactly when the picture does and is a safe immutable
|
||||
/// cache key. [maxSize] is honoured up to 10.10 and silently ignored from
|
||||
/// 10.11 on, so callers must still bound the decode themselves.
|
||||
String? jellyfinUserImageUrl({
|
||||
required String baseUrl,
|
||||
required String userId,
|
||||
required String? tag,
|
||||
int maxSize = 240,
|
||||
}) {
|
||||
if (baseUrl.isEmpty || userId.isEmpty || tag == null || tag.isEmpty) return null;
|
||||
final uri = JellyfinImageAbsolutizer.joinUri(
|
||||
baseUrl: baseUrl,
|
||||
urlOrPath: '/Users/${Uri.encodeComponent(userId)}/Images/Primary',
|
||||
);
|
||||
return uri.replace(queryParameters: {'tag': tag, 'maxWidth': '$maxSize', 'maxHeight': '$maxSize'}).toString();
|
||||
}
|
||||
|
||||
/// Pure mapping functions from Jellyfin's `BaseItemDto` JSON shape into the
|
||||
/// neutral [MediaItem] / [MediaLibrary] domain types.
|
||||
///
|
||||
@@ -236,6 +271,7 @@ class JellyfinMappers {
|
||||
addedAt: jellyfinIsoToEpochSeconds(item['DateCreated'] as String?),
|
||||
updatedAt: jellyfinIsoToEpochSeconds(item['DateLastSaved'] as String? ?? item['DateModified'] as String?),
|
||||
rating: (item['CommunityRating'] as num?)?.toDouble(),
|
||||
ratings: _ratingSources(item, kind),
|
||||
isFavorite: _userData(item)?['IsFavorite'] as bool?,
|
||||
genres: _stringList(item['Genres']),
|
||||
directors: _peopleByType(item['People'], 'Director'),
|
||||
@@ -368,6 +404,36 @@ class JellyfinMappers {
|
||||
return total - unplayed;
|
||||
}
|
||||
|
||||
/// Jellyfin's two rating slots, community score first.
|
||||
///
|
||||
/// `BaseItemDto` has no per-source array — the server collapses whatever the
|
||||
/// metadata fetchers found into these two fields, so this is at most two
|
||||
/// entries. Both arrive on every response; neither is gated behind `Fields`.
|
||||
///
|
||||
/// `CommunityRating` is 0-10 but its provenance is unknowable from the DTO
|
||||
/// (TMDB `vote_average`, IMDb via OMDb, or a local NFO — last writer wins),
|
||||
/// so it stays the generic `audience` source with no brand badge.
|
||||
/// `CriticRating` is the Rotten Tomatoes Tomatometer as a 0-100 percent, so
|
||||
/// it is divided rather than range-sniffed: a Tomatometer of 9 means 9%.
|
||||
static List<MediaRatingSource>? _ratingSources(Map<String, dynamic> item, MediaKind kind) {
|
||||
// Photo items reuse CommunityRating for the EXIF 0-5 star rating.
|
||||
if (kind == MediaKind.photo) return null;
|
||||
|
||||
final ratings = <MediaRatingSource>[];
|
||||
if (_finiteRating(item['CommunityRating']) case final community? when community >= 0 && community <= 10) {
|
||||
ratings.add(MediaRatingSource(source: 'audience', value: community));
|
||||
}
|
||||
if (_finiteRating(item['CriticRating']) case final critic? when critic >= 0 && critic <= 100) {
|
||||
ratings.add(MediaRatingSource(source: 'rottenTomatoesCritic', value: critic / 10));
|
||||
}
|
||||
return ratings.isEmpty ? null : ratings;
|
||||
}
|
||||
|
||||
static double? _finiteRating(Object? value) {
|
||||
final rating = flexibleDouble(value);
|
||||
return rating != null && rating.isFinite ? rating : null;
|
||||
}
|
||||
|
||||
static String? _firstString(Object? list) {
|
||||
if (list is List && list.isNotEmpty && list.first is String) return list.first as String;
|
||||
return null;
|
||||
|
||||
@@ -70,12 +70,19 @@ List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, i
|
||||
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
|
||||
}
|
||||
|
||||
/// Marks the row Jellyfin selected for this user, and only that row.
|
||||
///
|
||||
/// `DefaultSubtitleStreamIndex` is the server's whole answer: it folds in the
|
||||
/// user's `SubtitleMode`, their language preference, and any per-item choice
|
||||
/// Plezy persisted through playback progress reports (including `-1` for a
|
||||
/// deliberate off). A null index is part of that answer — "play no subtitle" —
|
||||
/// not a missing field. Synthesising a selection from the container's
|
||||
/// default/forced flags overrode `SubtitleMode: None` at the server-selected
|
||||
/// priority, so a user who turned subtitles off on the server had them
|
||||
/// switched back on by every fresh item (#1779).
|
||||
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
|
||||
return [
|
||||
for (final track in tracks)
|
||||
track.withSelected(
|
||||
defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
|
||||
),
|
||||
for (final track in tracks) track.withSelected(defaultStreamIndex != null && track.index == defaultStreamIndex),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ import 'plex_constants.dart';
|
||||
/// Browse responses retain up to three backdrops so hero surfaces can rotate
|
||||
/// artwork without allowing image-tag payloads to grow without bound.
|
||||
const jellyfinBackdropImageLimit = 3;
|
||||
|
||||
/// `Thumb` is deliberately absent: `JellyfinMappers` never reads
|
||||
/// `ImageTags['Thumb']`, and `parentThumbPath`/`grandparentThumbPath` are built
|
||||
/// from the season/series *Primary* tags. Asking for it added a dead image type
|
||||
/// to ~40 requests and widened the server's inherited-image parent walk.
|
||||
const jellyfinImageQueryParameters = <String, String>{
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
|
||||
'EnableImageTypes': 'Primary,Backdrop,Logo',
|
||||
'ImageTypeLimit': '$jellyfinBackdropImageLimit',
|
||||
};
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ import '../utils/watch_state_notifier.dart';
|
||||
/// Both Plex and Jellyfin go through the unified
|
||||
/// [MediaServerClient.reportPlayback*] surface — Plex maps the three signals
|
||||
/// onto `/:/timeline` updates with appropriate `state`, Jellyfin uses the
|
||||
/// three `/Sessions/Playing*` endpoints. Scrobble fires once the position
|
||||
/// crosses the client's [watchedThreshold] (per-server pref on Plex, fixed
|
||||
/// 90% on Jellyfin).
|
||||
/// three `/Sessions/Playing*` endpoints.
|
||||
///
|
||||
/// Local watched state flips as soon as the position crosses the client's
|
||||
/// [MediaServerClient.watchedThreshold] (per-server pref on Plex, fixed 90% on
|
||||
/// Jellyfin). The *server-side* mark is a separate decision: both backends
|
||||
/// already mark an item played from a threshold crossing they observe in the
|
||||
/// reports this tracker sends, so an explicit mark is issued only for sessions
|
||||
/// that gave them no such crossing (#1287, #1740).
|
||||
class PlaybackProgressTracker {
|
||||
/// Server client for online progress updates (null when offline). Pinned
|
||||
/// for the tracker's lifetime — one playback session against the server
|
||||
@@ -82,6 +87,14 @@ class PlaybackProgressTracker {
|
||||
/// must not turn an unrendered native clock position into watched progress.
|
||||
final bool Function()? hasRenderedPlayback;
|
||||
|
||||
/// Whether an off subtitle state is a real decision (viewer or server)
|
||||
/// rather than the fallout of a declined cross-item carry. When false, the
|
||||
/// off state is not reported as an explicit `-1` stream index — persisting
|
||||
/// it would make Jellyfin hand the off back as this item's default on every
|
||||
/// later open, latching a metadata mismatch into a server-side choice
|
||||
/// (#1785). Callers default to deliberate.
|
||||
final bool Function()? subtitleOffIsDeliberate;
|
||||
|
||||
/// Timer for periodic progress updates
|
||||
Timer? _progressTimer;
|
||||
|
||||
@@ -96,9 +109,44 @@ class PlaybackProgressTracker {
|
||||
/// Timer ticks to skip before retrying after failures (exponential backoff).
|
||||
int _ticksToSkip = 0;
|
||||
|
||||
/// Whether we've already scrobbled (marked as watched) for this playback session.
|
||||
/// Whether this playback session considers the item watched locally. Latched
|
||||
/// on the first observed threshold crossing, delivered to the server or not.
|
||||
bool _scrobbled = false;
|
||||
|
||||
/// The backend has received a report from this session at a position that is
|
||||
/// both strictly positive and below [MediaServerClient.watchedThreshold], so
|
||||
/// a later at-or-above report reads as a crossing.
|
||||
///
|
||||
/// Position zero does not count. Verified against PMS 1.43: a session
|
||||
/// reporting `time=0` and then the full duration is not marked played, while
|
||||
/// the same session starting at `time=1000` is. Plex treats a zero position
|
||||
/// as session initialisation rather than progress, so it has nothing to
|
||||
/// cross from. (Retention of a resume point is a separate, higher bar —
|
||||
/// reports at 5s and 30s arm the crossing without persisting an offset.)
|
||||
bool _deliveredBelow = false;
|
||||
|
||||
/// The backend has received an at-or-above-threshold report while already
|
||||
/// holding a sub-threshold offset — it observed the crossing and marked the
|
||||
/// item itself, so an explicit mark would record the same watch twice
|
||||
/// (#1287 Jellyfin, #1740 Plex).
|
||||
bool _serverObservedCrossing = false;
|
||||
|
||||
/// The terminal stopped report has been delivered; no further report can
|
||||
/// change what the backend saw.
|
||||
bool _sessionEnded = false;
|
||||
|
||||
/// The in-flight [_settleServerMark], so the terminal stopped report can wait
|
||||
/// for the explicit mark it triggers instead of leaving it racing teardown.
|
||||
Future<void>? _pendingSettle;
|
||||
|
||||
/// The server-side mark is resolved: either the backend marked the item from
|
||||
/// its own crossing, or we issued the explicit mark. Reset on failure so the
|
||||
/// next delivered report retries.
|
||||
bool _serverMarkSettled = false;
|
||||
|
||||
/// The post-watch hook has run; it fires at most once per tracker.
|
||||
bool _scrobbledHookRan = false;
|
||||
|
||||
/// Whether the final stopped progress event was already emitted locally.
|
||||
bool _stopProgressNotified = false;
|
||||
|
||||
@@ -109,7 +157,8 @@ class PlaybackProgressTracker {
|
||||
|
||||
static const Duration _progressNotifyDelta = Duration(seconds: 30);
|
||||
|
||||
final PlaybackReportSession? _reportSession;
|
||||
/// Built in the constructor body so the delivery callback can bind `this`.
|
||||
late final PlaybackReportSession? _reportSession;
|
||||
|
||||
PlaybackProgressTracker({
|
||||
required this.client,
|
||||
@@ -125,17 +174,21 @@ class PlaybackProgressTracker {
|
||||
this.onPausedKeepalive,
|
||||
this.canReportPlayback,
|
||||
this.hasRenderedPlayback,
|
||||
this.subtitleOffIsDeliberate,
|
||||
this.updateInterval = const Duration(seconds: 10),
|
||||
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
|
||||
assert(isOffline || client != null, 'client is required when isOffline is false'),
|
||||
_reportSession = isOffline || client == null
|
||||
? null
|
||||
: PlaybackReportSession(
|
||||
client: client,
|
||||
itemId: metadata.id,
|
||||
playSessionId: playSessionId,
|
||||
playMethod: playMethod,
|
||||
);
|
||||
assert(isOffline || client != null, 'client is required when isOffline is false') {
|
||||
final reportingClient = client;
|
||||
_reportSession = isOffline || reportingClient == null
|
||||
? null
|
||||
: PlaybackReportSession(
|
||||
client: reportingClient,
|
||||
itemId: metadata.id,
|
||||
playSessionId: playSessionId,
|
||||
playMethod: playMethod,
|
||||
onDelivered: _onReportDelivered,
|
||||
);
|
||||
}
|
||||
|
||||
void startTracking() {
|
||||
if (_progressTimer != null) {
|
||||
@@ -203,6 +256,12 @@ class PlaybackProgressTracker {
|
||||
void resumeAfterStoppedReport() {
|
||||
_stoppedProgressFuture = null;
|
||||
_reportSession?.resetAfterStop();
|
||||
// A re-armed session is a new server-side session: backends only act on a
|
||||
// threshold crossing observed within one, so it must earn its own
|
||||
// below-threshold report before we can rely on it again.
|
||||
_deliveredBelow = false;
|
||||
_serverObservedCrossing = false;
|
||||
_sessionEnded = false;
|
||||
}
|
||||
|
||||
Future<void> _sendProgress(String state, {Duration? positionOverride}) async {
|
||||
@@ -240,6 +299,10 @@ class PlaybackProgressTracker {
|
||||
// by a fatal error, use the last position captured while output was
|
||||
// healthy rather than the still-advancing native media clock.
|
||||
final accepted = await _sendOnlineProgress(state, position, duration, allowScrobble: canCommitStoppedProgress);
|
||||
// The explicit mark is resolved at session end, so it has to ride the
|
||||
// terminal report's future — callers that await the stop before tearing
|
||||
// the player down would otherwise drop it.
|
||||
await _pendingSettle;
|
||||
_resetBackoff();
|
||||
if (accepted && canCommitStoppedProgress) {
|
||||
_notifyProgressIfNeeded(position, duration, force: true);
|
||||
@@ -363,44 +426,128 @@ class PlaybackProgressTracker {
|
||||
return info == null ? PlaybackStreamSelection.none : PlaybackStreamSelection(mediaSourceId: info.mediaSourceId);
|
||||
}
|
||||
|
||||
/// Records what the backend actually received, then re-evaluates whether the
|
||||
/// explicit mark is still needed.
|
||||
///
|
||||
/// Both backends mark an item played from a watched-threshold *crossing*
|
||||
/// observed inside a single reporting session — a report below the threshold
|
||||
/// followed by one at or above it. Absolute position is not enough: a session
|
||||
/// whose every report sits above the threshold, or one resuming past it, is
|
||||
/// never marked server-side.
|
||||
void _onReportDelivered(PlaybackReportSnapshot snapshot) {
|
||||
final threshold = client?.watchedThreshold;
|
||||
// isWatchedProgress reports false for an unknown duration; treating that as
|
||||
// a below-threshold report would wrongly arm the crossing.
|
||||
if (threshold == null || snapshot.duration.inMilliseconds <= 0) return;
|
||||
if (snapshot.isStopped) _sessionEnded = true;
|
||||
if (isWatchedProgress(
|
||||
positionMs: snapshot.position.inMilliseconds,
|
||||
durationMs: snapshot.duration.inMilliseconds,
|
||||
threshold: threshold,
|
||||
)) {
|
||||
if (_deliveredBelow) _serverObservedCrossing = true;
|
||||
} else if (snapshot.position > Duration.zero) {
|
||||
// Zero is session initialisation, not progress: the backend has nothing
|
||||
// to cross from, so it will never mark the item off such a session.
|
||||
_deliveredBelow = true;
|
||||
}
|
||||
// _settleServerMark swallows its own failures, so this never escapes.
|
||||
final settle = _settleServerMark(client);
|
||||
_pendingSettle = settle;
|
||||
unawaited(settle);
|
||||
}
|
||||
|
||||
/// Issues the explicit server-side mark, but only once it is clear the
|
||||
/// backend will not record the watch itself.
|
||||
///
|
||||
/// Called after the local crossing latches and again on every delivered
|
||||
/// report — between them those cover every transition that can change the
|
||||
/// answer.
|
||||
Future<void> _settleServerMark(MediaServerClient? c) async {
|
||||
if (c == null || !_scrobbled || _serverMarkSettled) return;
|
||||
// Backends that mark played from the playback-stopped report do it there,
|
||||
// and the terminal stop is always sent. An explicit mark on top would
|
||||
// double-scrobble through the Jellyfin Trakt plugin (#1287).
|
||||
if (c.marksWatchedOnPlaybackStopped) {
|
||||
_serverMarkSettled = true;
|
||||
await _runScrobbledHook();
|
||||
return;
|
||||
}
|
||||
// The backend observed the crossing and marked the item itself. Marking
|
||||
// again records the same watch twice (#1740).
|
||||
if (_serverObservedCrossing) {
|
||||
_serverMarkSettled = true;
|
||||
await _runScrobbledHook();
|
||||
return;
|
||||
}
|
||||
// Never mark while the session is still live. A crossing can appear at any
|
||||
// point until the stop: even a session that began past the threshold can
|
||||
// seek back below it and cross again, and the backend records that crossing
|
||||
// itself. No eager decision can know a future rewind won't create one.
|
||||
// Marking eagerly and then hitting that path reproduces the very
|
||||
// double-count this guards against — verified against PMS 1.43, where an
|
||||
// explicit mark followed by an in-session crossing leaves viewCount at 2
|
||||
// with a Play History row (#1740).
|
||||
//
|
||||
// A session that only ever sent its stop reaches this already ended, so the
|
||||
// common crossing-less case is still resolved immediately.
|
||||
if (!_sessionEnded) return;
|
||||
// The session is over and the backend never saw a crossing: a resume that
|
||||
// stayed past the threshold, one that only sent its stop, or one whose
|
||||
// crossing was coalesced away and never re-delivered. It will not mark this
|
||||
// itself.
|
||||
_serverMarkSettled = true;
|
||||
try {
|
||||
await c.markWatched(metadata);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to mark ${metadata.id} watched', error: e);
|
||||
_serverMarkSettled = false; // Retry on the next delivered report.
|
||||
return;
|
||||
}
|
||||
await _runScrobbledHook();
|
||||
}
|
||||
|
||||
/// Runs the post-watch hook once, after the item's own watched state is
|
||||
/// accounted for server-side.
|
||||
///
|
||||
/// Its production caller marks same-file sibling episodes (#1500) with real
|
||||
/// server writes, so it must never run ahead of the primary: a resumed
|
||||
/// session whose explicit mark is still pending — or has just failed — would
|
||||
/// otherwise leave the siblings watched and the episode actually played
|
||||
/// unwatched. Hook failures are logged and never un-settle the mark.
|
||||
Future<void> _runScrobbledHook() async {
|
||||
final hook = onScrobbled;
|
||||
if (hook == null || _scrobbledHookRan) return;
|
||||
_scrobbledHookRan = true;
|
||||
try {
|
||||
await hook();
|
||||
} catch (e) {
|
||||
appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _maybeScrobble(MediaServerClient c, Duration position, Duration duration) async {
|
||||
// Explicitly scrobble once progress crosses the watched threshold.
|
||||
// Some servers (Plex with no active play session, Jellyfin always)
|
||||
// don't auto-mark from progress updates alone.
|
||||
if (!_scrobbled &&
|
||||
isWatchedProgress(
|
||||
if (_scrobbled ||
|
||||
!isWatchedProgress(
|
||||
positionMs: position.inMilliseconds,
|
||||
durationMs: duration.inMilliseconds,
|
||||
threshold: c.watchedThreshold,
|
||||
)) {
|
||||
final percent = position.inMilliseconds / duration.inMilliseconds;
|
||||
final threshold = c.watchedThreshold;
|
||||
_scrobbled = true;
|
||||
try {
|
||||
// Backends that mark the item played from the playback-stopped report
|
||||
// (Jellyfin) only emit the local watch event here — an explicit
|
||||
// markWatched would double-scrobble via the Trakt plugin (#1287).
|
||||
// Plex still issues the server call. Either path emits the watched
|
||||
// event through WatchStateNotifier, so no extra notify is needed.
|
||||
await c.markWatchedFromPlaybackStop(metadata);
|
||||
appLogger.d(
|
||||
'Scrobbled ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to scrobble ${metadata.id}', error: e);
|
||||
_scrobbled = false; // Retry on next tick
|
||||
}
|
||||
// After (and only after) the primary mark succeeded. A failure here
|
||||
// must not reset _scrobbled — that would re-scrobble the primary
|
||||
// item and inflate its view count.
|
||||
if (_scrobbled && onScrobbled != null) {
|
||||
try {
|
||||
await onScrobbled!();
|
||||
} catch (e) {
|
||||
appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
final percent = position.inMilliseconds / duration.inMilliseconds;
|
||||
final threshold = c.watchedThreshold;
|
||||
_scrobbled = true;
|
||||
// Local state flips on the observed crossing, whether or not the backend
|
||||
// received that particular report. The server-side mark is a separate
|
||||
// question, answered by _settleServerMark once delivery is known.
|
||||
c.notifyWatchedFromPlaybackSession(metadata);
|
||||
appLogger.d(
|
||||
'Watched ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)',
|
||||
);
|
||||
// The #1500 sibling hook runs from _settleServerMark, once this item's own
|
||||
// watched state is accounted for server-side.
|
||||
await _settleServerMark(c);
|
||||
}
|
||||
|
||||
Future<PlaybackStreamSelection> _currentStreamSelectionForProgress() async {
|
||||
@@ -467,7 +614,11 @@ class PlaybackProgressTracker {
|
||||
|
||||
int? _currentSubtitleStreamIndex(MediaSourceInfo info) {
|
||||
final track = player.state.track.subtitle;
|
||||
if (track == null || track.id == 'no') return -1;
|
||||
if (track == null || track.id == 'no') {
|
||||
// An off that merely fell out of a declined carry is withheld rather
|
||||
// than persisted as an explicit -1 (see [subtitleOffIsDeliberate]).
|
||||
return (subtitleOffIsDeliberate?.call() ?? true) ? -1 : null;
|
||||
}
|
||||
|
||||
if (track.isExternal && track.uri != null) {
|
||||
for (final mediaTrack in info.subtitleTracks) {
|
||||
|
||||
@@ -64,6 +64,7 @@ class PlaybackReportSession {
|
||||
this.playSessionId,
|
||||
this.playMethod,
|
||||
this.liveStreamId,
|
||||
this.onDelivered,
|
||||
});
|
||||
|
||||
final MediaServerClient client;
|
||||
@@ -72,6 +73,16 @@ class PlaybackReportSession {
|
||||
final String? playMethod;
|
||||
final String? liveStreamId;
|
||||
|
||||
/// Invoked with each snapshot the backend actually received, right after its
|
||||
/// report call returns.
|
||||
///
|
||||
/// Coalesced and superseded snapshots never reach this: [report] resolving
|
||||
/// `true` is not delivery. A same-state heartbeat arriving while the start
|
||||
/// report is in flight is dropped by [_reportProgress] yet still completes
|
||||
/// its future, so callers that need to know what the server saw — such as
|
||||
/// watched-threshold crossing detection — must key on this instead.
|
||||
final void Function(PlaybackReportSnapshot snapshot)? onDelivered;
|
||||
|
||||
_PlaybackReportState _state = _PlaybackReportState.idle;
|
||||
PlaybackReportSnapshot? _startSnapshot;
|
||||
_PendingProgressReport? _pendingProgress;
|
||||
@@ -256,6 +267,7 @@ class PlaybackReportSession {
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
);
|
||||
onDelivered?.call(snapshot);
|
||||
}
|
||||
|
||||
Future<bool> _sendProgress(PlaybackReportSnapshot snapshot) async {
|
||||
@@ -273,6 +285,7 @@ class PlaybackReportSession {
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
);
|
||||
onDelivered?.call(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -287,5 +300,6 @@ class PlaybackReportSession {
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
report: snapshot.report,
|
||||
);
|
||||
onDelivered?.call(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ class PlaybackSubtitleSelection {
|
||||
final PlaybackSubtitleSidecar? secondarySidecar;
|
||||
final List<PlaybackSubtitleSidecar> preloadedSidecars;
|
||||
|
||||
/// The primary preference the resolver could not serve when this selection
|
||||
/// is off. Distinguishes "the carried choice declined and the ladder fell
|
||||
/// through" from a deliberate off (#1785): the open flow hands the declined
|
||||
/// preference back to the track manager so late-arriving native tracks may
|
||||
/// still serve it, and progress reports must not persist the fallout as an
|
||||
/// explicit server-side off. A user or server decision leaves this null.
|
||||
final SubtitlePreference? declinedPreference;
|
||||
|
||||
const PlaybackSubtitleSelection({
|
||||
required this.primaryTrack,
|
||||
this.primarySourceStreamId,
|
||||
@@ -53,9 +61,10 @@ class PlaybackSubtitleSelection {
|
||||
this.secondarySourceStreamId,
|
||||
this.secondarySidecar,
|
||||
this.preloadedSidecars = const [],
|
||||
this.declinedPreference,
|
||||
});
|
||||
|
||||
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const []})
|
||||
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const [], this.declinedPreference})
|
||||
: primaryTrack = SubtitleTrack.off,
|
||||
primarySourceStreamId = null,
|
||||
primarySidecar = null,
|
||||
@@ -192,13 +201,25 @@ class PlaybackSubtitleResolver {
|
||||
);
|
||||
final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio);
|
||||
final primary = primaryResult?.track;
|
||||
// A non-off preference that still lands on off (or resolves to a track
|
||||
// this catalog cannot back) was declined, not chosen — keep it on the
|
||||
// selection so the open flow can retry it against native tracks (#1785).
|
||||
final declinedPreference = primaryPreference != null && primaryPreference is! SubtitleOffPreference
|
||||
? primaryPreference
|
||||
: null;
|
||||
if (primary == null || primary.id == SubtitleTrack.off.id) {
|
||||
return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars);
|
||||
return PlaybackSubtitleSelection.off(
|
||||
preloadedSidecars: preloadedSidecars,
|
||||
declinedPreference: declinedPreference,
|
||||
);
|
||||
}
|
||||
|
||||
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
|
||||
if (primaryCandidate == null) {
|
||||
return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars);
|
||||
return PlaybackSubtitleSelection.off(
|
||||
preloadedSidecars: preloadedSidecars,
|
||||
declinedPreference: declinedPreference,
|
||||
);
|
||||
}
|
||||
|
||||
_SubtitleCandidate? secondaryCandidate;
|
||||
|
||||
+179
-94
@@ -844,6 +844,16 @@ class PlexClient
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Every raw `Metadata` entry of a container, for callers that must inspect
|
||||
/// each sibling rather than assume the first is the only one (the
|
||||
/// external-id reverse lookup: `/library/all` is server-wide, so one movie
|
||||
/// held by two libraries answers with two entries).
|
||||
List<Map<String, dynamic>> _getMetadataJsonList(MediaServerResponse response) {
|
||||
final metadata = _getMediaContainer(response)?['Metadata'];
|
||||
if (metadata is! List) return const [];
|
||||
return metadata.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Directory'] != null) {
|
||||
@@ -1528,7 +1538,11 @@ class PlexClient
|
||||
const allowedTypes = {'movie', 'show', 'artist', 'album', 'track'};
|
||||
if (!allowedTypes.contains(type)) continue;
|
||||
|
||||
results.add(_createTaggedMetadata(metadata));
|
||||
// Library-aware: search rows normally carry `librarySectionID`, but the
|
||||
// tolerant resolver also accepts the `librarySectionKey` /
|
||||
// `targetLibrarySectionID` forms. Without a section id the item cannot
|
||||
// be matched against the user's hidden libraries.
|
||||
results.add(_createTaggedMetadataWithLibrary(metadata));
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to parse search result', error: e);
|
||||
}
|
||||
@@ -1550,7 +1564,7 @@ class PlexClient
|
||||
|
||||
final response = await retryTransientMediaServerCall(
|
||||
operation: 'Plex continue watching hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.homeHubDeadline,
|
||||
call: (timeout, abort) => _getWithFailover(
|
||||
continueWatchingHubKey ?? '/hubs',
|
||||
queryParameters: queryParameters,
|
||||
@@ -1665,8 +1679,23 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chapters and markers from cached metadata or fetch if needed
|
||||
/// Uses same cache key as other metadata methods for consistency
|
||||
/// Chapters and markers for [ratingKey], from the shared
|
||||
/// `/library/metadata/{id}` cache row.
|
||||
///
|
||||
/// Cache-first is safe here because of an ordering contract, not because
|
||||
/// markers are static: in the normal online player flow
|
||||
/// [getPlaybackInitialization] runs [getVideoPlaybackData] — a
|
||||
/// network-first read of this same cache key with a superset of the query
|
||||
/// params — before the controls mount and load extras, so the row this
|
||||
/// serves was refreshed seconds earlier. Offline, and when that read fell
|
||||
/// back to cache, the row is as old as the cache; a caller that needs the
|
||||
/// current server state (e.g. after a PMS intro-detection pass finished)
|
||||
/// must pass [forceRefresh].
|
||||
///
|
||||
/// The network call here deliberately stays lean (no `checkFiles` /
|
||||
/// `includeStreams`) and runs only on a cache miss or [forceRefresh], so it
|
||||
/// rarely overwrites the shared row with a payload thin enough to force the
|
||||
/// re-fetch in [_fetchFileInfo].
|
||||
Future<PlaybackExtras> getPlaybackExtras(
|
||||
String ratingKey, {
|
||||
String? introPattern,
|
||||
@@ -2215,7 +2244,7 @@ class PlexClient
|
||||
required String path,
|
||||
required Map<String, dynamic> queryParameters,
|
||||
required String operation,
|
||||
required List<Duration> attemptTimeouts,
|
||||
required Duration deadline,
|
||||
required String failureLabel,
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
@@ -2224,7 +2253,7 @@ class PlexClient
|
||||
try {
|
||||
final response = await retryTransientMediaServerCall(
|
||||
operation: operation,
|
||||
attemptTimeouts: attemptTimeouts,
|
||||
deadline: deadline,
|
||||
call: (timeout, abort) => _getWithFailover(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
@@ -2262,7 +2291,7 @@ class PlexClient
|
||||
path: '/hubs/sections/$sectionId',
|
||||
queryParameters: {'count': limit, 'includeGuids': 1},
|
||||
operation: 'Plex library hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.libraryHubDeadline,
|
||||
failureLabel: 'library hubs',
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
librarySectionTitle: libraryName,
|
||||
@@ -2276,7 +2305,7 @@ class PlexClient
|
||||
path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs',
|
||||
queryParameters: {'count': limit, 'includeGuids': 1},
|
||||
operation: 'Plex global hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.homeHubDeadline,
|
||||
failureLabel: 'global hubs',
|
||||
);
|
||||
|
||||
@@ -2285,7 +2314,7 @@ class PlexClient
|
||||
path: '/hubs/metadata/$ratingKey/related',
|
||||
queryParameters: {'count': count},
|
||||
operation: 'Plex related hubs',
|
||||
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
|
||||
deadline: MediaServerTimeouts.libraryHubDeadline,
|
||||
failureLabel: 'related hubs',
|
||||
filter: _videoOrCollectionHubItem,
|
||||
);
|
||||
@@ -3578,8 +3607,16 @@ class PlexClient
|
||||
return getFirstCharacters(libraryId, filters: filters);
|
||||
}
|
||||
|
||||
/// [excludedLibraryIds] is unused: `/library/search` has no section-scoping
|
||||
/// parameter, and every row carries its `librarySectionID`, so the caller
|
||||
/// filters hidden libraries out of the mapped results.
|
||||
@override
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
|
||||
Future<List<MediaItem>> searchItems(
|
||||
String query, {
|
||||
int limit = 100,
|
||||
AbortController? abort,
|
||||
Set<String> excludedLibraryIds = const {},
|
||||
}) async {
|
||||
final results = await _search(query, limit: limit, abort: abort);
|
||||
return results.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
@@ -3732,10 +3769,14 @@ class PlexClient
|
||||
}
|
||||
|
||||
/// Full item with on-deck episode from a single `/library/metadata/{id}`
|
||||
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck];
|
||||
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
|
||||
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck]. Both
|
||||
/// halves arrive together, so there is no window in which the item is known
|
||||
/// and on-deck is not — `onItemReady` is intentionally never invoked.
|
||||
@override
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
|
||||
String id, {
|
||||
void Function(MediaItem item)? onItemReady,
|
||||
}) async {
|
||||
try {
|
||||
final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup);
|
||||
final itemDto = result['metadata'] as PlexMetadataDto?;
|
||||
@@ -3802,9 +3843,18 @@ class PlexClient
|
||||
@override
|
||||
double get watchedThreshold => watchedThresholdPercent / 100.0;
|
||||
|
||||
/// Plex's `/:/timeline?state=stopped` doesn't reliably mark watched without
|
||||
/// an active play session, so the in-player auto-scrobble still issues the
|
||||
/// explicit `markWatched` (`/:/scrobble`). See [marksWatchedOnPlaybackStopped].
|
||||
/// A single `/:/timeline?state=stopped` does not mark watched: PMS only acts
|
||||
/// on a threshold crossing it observes inside one session — a report below
|
||||
/// `LibraryVideoPlayedThreshold` followed by one at or above it. Verified
|
||||
/// against PMS 1.43: consecutive above-threshold reports mark nothing (it
|
||||
/// won't even store an above-threshold `viewOffset`), and a resume point left
|
||||
/// by an earlier session does not arm a new one.
|
||||
///
|
||||
/// So paths with no observable crossing — queued offline replay, external
|
||||
/// players, same-file siblings — still need the explicit `markWatched`
|
||||
/// (`/:/scrobble`). In-player sessions that did produce a crossing must not
|
||||
/// send it: PMS has already recorded the watch, and the extra call inflates
|
||||
/// `viewCount` and (before PMS 1.40) adds a second Play History row (#1740).
|
||||
@override
|
||||
bool get marksWatchedOnPlaybackStopped => false;
|
||||
|
||||
@@ -3817,38 +3867,67 @@ class PlexClient
|
||||
return ExternalIds.fromGuids(guids);
|
||||
}
|
||||
|
||||
/// Drop a sequel match when the server does not actually have that season.
|
||||
/// Map id-verified candidates to items, dropping any sequel the server does
|
||||
/// not actually have that season of.
|
||||
///
|
||||
/// Only an [ExternalSeasonRef.agreedSeason] is gated on. Which provider a
|
||||
/// library numbers its seasons by is a server-side setting no dataset can
|
||||
/// supply, and reading it costs two extra requests per lookup, so a
|
||||
/// disagreeing ref is left ungated rather than gated on a guess.
|
||||
Future<MediaItem?> _applyExternalIdSeasonGate(
|
||||
Map<String, dynamic> metadata, {
|
||||
///
|
||||
/// Gating costs one `fetchChildren` per show candidate. The candidate list
|
||||
/// is deliberately never truncated (see
|
||||
/// [MediaServerClient.findByExternalIds]), so the gates run concurrently
|
||||
/// rather than letting latency grow with the number of library copies.
|
||||
Future<List<MediaItem>> _gateExternalIdMatches(
|
||||
Iterable<Map<String, dynamic>> candidates, {
|
||||
required MediaKind kind,
|
||||
required ExternalSeasonRef? season,
|
||||
}) async {
|
||||
final item = PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata));
|
||||
if (kind != MediaKind.show) return item;
|
||||
|
||||
final entries = [
|
||||
for (final metadata in candidates)
|
||||
(metadata: metadata, item: PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata))),
|
||||
];
|
||||
final seasonIndex = season?.agreedSeason;
|
||||
if (seasonIndex == null || seasonIndex <= 1) return item;
|
||||
if (kind != MediaKind.show || seasonIndex == null || seasonIndex <= 1) {
|
||||
return [for (final entry in entries) entry.item];
|
||||
}
|
||||
|
||||
final ratingKey = metadata['ratingKey']?.toString();
|
||||
// Cannot ask the question => do not gate.
|
||||
if (ratingKey == null || ratingKey.isEmpty) return item;
|
||||
|
||||
final children = await fetchChildren(ratingKey);
|
||||
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex) ? item : null;
|
||||
final kept = await Future.wait([for (final entry in entries) _hasSeason(entry.metadata, seasonIndex)]);
|
||||
return [
|
||||
for (var index = 0; index < entries.length; index++)
|
||||
if (kept[index]) entries[index].item,
|
||||
];
|
||||
}
|
||||
|
||||
/// Server-wide title filter + client-side external-ID verification. Plex's
|
||||
/// `guid=` field filter matches only the item's primary `plex://` guid
|
||||
/// (verified against PMS 1.43), so external ids in modern `Guid` arrays are
|
||||
/// verified after title search. A resolved primary [plexGuid] can use the
|
||||
/// exact server-side filter directly.
|
||||
Future<bool> _hasSeason(Map<String, dynamic> metadata, int seasonIndex) async {
|
||||
final ratingKey = metadata['ratingKey']?.toString();
|
||||
// Cannot ask the question => do not gate.
|
||||
if (ratingKey == null || ratingKey.isEmpty) return true;
|
||||
final children = await fetchChildren(ratingKey);
|
||||
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex);
|
||||
}
|
||||
|
||||
/// Server-wide external-id reverse lookup. Plex's `guid=` field filter
|
||||
/// matches only the item's primary `plex://` guid (verified against PMS
|
||||
/// 1.43), so a resolved [plexGuid] uses that exact filter while ids in
|
||||
/// modern `Guid` arrays are verified client-side after a title search.
|
||||
///
|
||||
/// `/library/all` is server-wide — it is not scoped to a section — so a
|
||||
/// movie held by both a 4K and an HD library answers as two sibling
|
||||
/// `Metadata` entries, each carrying its own `librarySectionID`. Every
|
||||
/// id-verified entry is kept (#1754).
|
||||
///
|
||||
/// An exact-guid hit does not short-circuit the title ladder: a library
|
||||
/// still on a legacy agent carries `com.plexapp.agents.*` as its primary
|
||||
/// guid, so that copy is invisible to the `guid=` filter and only the
|
||||
/// id-verified title search finds it. Likewise the year-filtered page can
|
||||
/// surface a copy the unfiltered page cut off at the container size, so
|
||||
/// both contribute. The extra requests are spent once per uncached lookup,
|
||||
/// off the render path and memoized for the session by
|
||||
/// `CatalogLibraryMatcher`.
|
||||
@override
|
||||
Future<MediaItem?> findByExternalIds(
|
||||
Future<List<MediaItem>> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> titles = const [],
|
||||
@@ -3861,82 +3940,88 @@ class PlexClient
|
||||
MediaKind.show => 2,
|
||||
_ => null,
|
||||
};
|
||||
if (plexType == null) return null;
|
||||
if (!ids.hasAny && plexGuid == null) return null;
|
||||
if (titles.isEmpty && plexGuid == null) return null;
|
||||
if (plexType == null) return const [];
|
||||
if (!ids.hasAny && plexGuid == null) return const [];
|
||||
if (titles.isEmpty && plexGuid == null) return const [];
|
||||
|
||||
// Rating-key keyed so the guid filter and the title ladder can both
|
||||
// contribute without doubling a copy they agree on. Kept in three buckets
|
||||
// because the result order is exact-guid hits, then modern `Guid`
|
||||
// matches, then legacy-agent ones.
|
||||
final exact = <String, Map<String, dynamic>>{};
|
||||
final modern = <String, Map<String, dynamic>>{};
|
||||
final legacy = <String, Map<String, dynamic>>{};
|
||||
|
||||
void collect(Map<String, Map<String, dynamic>> into, Map<String, dynamic> item) {
|
||||
final ratingKey = item['ratingKey']?.toString();
|
||||
if (ratingKey == null || ratingKey.isEmpty) return;
|
||||
into.putIfAbsent(ratingKey, () => item);
|
||||
}
|
||||
|
||||
if (plexGuid != null) {
|
||||
final response = await _getWithFailover(
|
||||
'/library/all',
|
||||
queryParameters: {'guid': plexGuid, 'type': plexType, 'includeGuids': 1},
|
||||
);
|
||||
final metadata = _getFirstMetadataJson(response);
|
||||
if (metadata != null) {
|
||||
return _applyExternalIdSeasonGate(metadata, kind: kind, season: season);
|
||||
for (final item in _getMetadataJsonList(response)) {
|
||||
collect(exact, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Title attempts confirm candidates by external-id intersection, so
|
||||
// without external ids they cannot match anything — stop at the exact
|
||||
// guid lookup instead of burning requests that always come back empty.
|
||||
if (!ids.hasAny) return null;
|
||||
|
||||
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(
|
||||
String title, {
|
||||
required int size,
|
||||
String? years,
|
||||
}) async {
|
||||
final response = await _getWithFailover(
|
||||
'/library/all',
|
||||
queryParameters: {
|
||||
'title': title,
|
||||
'type': plexType,
|
||||
'includeGuids': 1,
|
||||
'X-Plex-Container-Size': size,
|
||||
'year': ?years,
|
||||
},
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
final metadata = container?['Metadata'];
|
||||
if (metadata is! List) return (modern: null, legacy: null);
|
||||
|
||||
Map<String, dynamic>? legacy;
|
||||
for (final item in metadata) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final guids = item['Guid'];
|
||||
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
|
||||
return (modern: item, legacy: legacy);
|
||||
}
|
||||
if (legacy == null && ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
|
||||
legacy = item;
|
||||
}
|
||||
}
|
||||
return (modern: null, legacy: legacy);
|
||||
}
|
||||
|
||||
// Not `resolve(null)`: when the two providers disagree the season number is
|
||||
// unresolvable but the entry is still a sequel, and the ±1 window around a
|
||||
// sequel's own year excludes the parent show (its year is season one's).
|
||||
final skipYearWindow = season?.isSequel ?? false;
|
||||
for (var index = 0; index < titles.length; index++) {
|
||||
final title = titles[index];
|
||||
final size = index == 0 ? 20 : 50;
|
||||
({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})? filtered;
|
||||
if (index == 0 && year != null && !skipYearWindow) {
|
||||
filtered = await attempt(title, size: size, years: '${year - 1},$year,${year + 1}');
|
||||
final modern = filtered.modern;
|
||||
if (modern != null) {
|
||||
return _applyExternalIdSeasonGate(modern, kind: kind, season: season);
|
||||
if (ids.hasAny) {
|
||||
Future<bool> attempt(String title, {required int size, String? years}) async {
|
||||
final response = await _getWithFailover(
|
||||
'/library/all',
|
||||
queryParameters: {
|
||||
'title': title,
|
||||
'type': plexType,
|
||||
'includeGuids': 1,
|
||||
'X-Plex-Container-Size': size,
|
||||
'year': ?years,
|
||||
},
|
||||
);
|
||||
var matched = false;
|
||||
for (final item in _getMetadataJsonList(response)) {
|
||||
final guids = item['Guid'];
|
||||
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
|
||||
collect(modern, item);
|
||||
matched = true;
|
||||
} else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
|
||||
collect(legacy, item);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
final unfiltered = await attempt(title, size: size);
|
||||
final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy;
|
||||
if (match != null) {
|
||||
return _applyExternalIdSeasonGate(match, kind: kind, season: season);
|
||||
// Not `resolve(null)`: when the two providers disagree the season number is
|
||||
// unresolvable but the entry is still a sequel, and the ±1 window around a
|
||||
// sequel's own year excludes the parent show (its year is season one's).
|
||||
final skipYearWindow = season?.isSequel ?? false;
|
||||
for (var index = 0; index < titles.length; index++) {
|
||||
final title = titles[index];
|
||||
final size = index == 0 ? 20 : 50;
|
||||
final filteredMatched = index == 0 && year != null && !skipYearWindow
|
||||
? await attempt(title, size: size, years: '${year - 1},$year,${year + 1}')
|
||||
: false;
|
||||
final unfilteredMatched = await attempt(title, size: size);
|
||||
// The ladder exists to widen a title that matched nothing; once a
|
||||
// title has produced copies, broader forms would only add other shows.
|
||||
if (filteredMatched || unfilteredMatched) break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
final ordered = <String, Map<String, dynamic>>{...exact};
|
||||
for (final bucket in [modern, legacy]) {
|
||||
for (final entry in bucket.entries) {
|
||||
ordered.putIfAbsent(entry.key, () => entry.value);
|
||||
}
|
||||
}
|
||||
if (ordered.isEmpty) return const [];
|
||||
return _gateExternalIdMatches(ordered.values, kind: kind, season: season);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_part.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../media/media_rating.dart';
|
||||
import '../media/media_role.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../media/media_stream.dart';
|
||||
@@ -300,6 +301,24 @@ class PlexRoleDto {
|
||||
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => _$PlexRoleDtoFromJson(json);
|
||||
}
|
||||
|
||||
/// One entry of Plex's `Rating[]` child array, present on
|
||||
/// `/library/metadata/{id}` responses but never on section listings.
|
||||
///
|
||||
/// [image] is a source URI (`imdb://image.rating`,
|
||||
/// `rottentomatoes://image.rating.ripe`, `themoviedb://image.rating`) and
|
||||
/// [type] is `critic` or `audience`; together they attribute [value].
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexRatingDto {
|
||||
final String? image;
|
||||
final String? type;
|
||||
@JsonKey(fromJson: flexibleDouble)
|
||||
final double? value;
|
||||
|
||||
const PlexRatingDto({this.image, this.type, this.value});
|
||||
|
||||
factory PlexRatingDto.fromJson(Map<String, dynamic> json) => _$PlexRatingDtoFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexMediaVersionDto {
|
||||
@JsonKey(fromJson: flexibleIntOrZero)
|
||||
@@ -605,6 +624,10 @@ class PlexMetadataDto {
|
||||
final List<PlexRoleDto>? role;
|
||||
@JsonKey(name: 'Media', includeToJson: false)
|
||||
final List<PlexMediaVersionDto>? mediaVersions;
|
||||
@JsonKey(name: 'Rating', includeToJson: false)
|
||||
final List<PlexRatingDto>? ratingSources;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? imdbRatingCount;
|
||||
@JsonKey(name: 'Genre', fromJson: _tagListFromJson, includeToJson: false)
|
||||
final List<String>? genre;
|
||||
@JsonKey(name: 'Director', fromJson: _tagListFromJson, includeToJson: false)
|
||||
@@ -693,6 +716,8 @@ class PlexMetadataDto {
|
||||
this.childCount,
|
||||
this.role,
|
||||
this.mediaVersions,
|
||||
this.ratingSources,
|
||||
this.imdbRatingCount,
|
||||
this.genre,
|
||||
this.director,
|
||||
this.writer,
|
||||
@@ -825,6 +850,8 @@ class PlexMetadataDto {
|
||||
int? childCount,
|
||||
List<PlexRoleDto>? role,
|
||||
List<PlexMediaVersionDto>? mediaVersions,
|
||||
List<PlexRatingDto>? ratingSources,
|
||||
int? imdbRatingCount,
|
||||
List<String>? genre,
|
||||
List<String>? director,
|
||||
List<String>? writer,
|
||||
@@ -895,6 +922,8 @@ class PlexMetadataDto {
|
||||
childCount: childCount ?? this.childCount,
|
||||
role: role ?? this.role,
|
||||
mediaVersions: mediaVersions ?? this.mediaVersions,
|
||||
ratingSources: ratingSources ?? this.ratingSources,
|
||||
imdbRatingCount: imdbRatingCount ?? this.imdbRatingCount,
|
||||
genre: genre ?? this.genre,
|
||||
director: director ?? this.director,
|
||||
writer: writer ?? this.writer,
|
||||
@@ -937,6 +966,75 @@ Map<String, Object?>? _rawMetadata(PlexMetadataDto dto) {
|
||||
return raw.isEmpty ? null : raw;
|
||||
}
|
||||
|
||||
/// Every attributed score a Plex payload carries, headline first.
|
||||
///
|
||||
/// Section listings only ever send the scalar pair ([rating]/[ratingImage] and
|
||||
/// [audienceRating]/[audienceRatingImage]), so they yield one or two entries.
|
||||
/// `/library/metadata/{id}` additionally sends the `Rating[]` child array —
|
||||
/// IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, so
|
||||
/// detail responses yield up to four. Plex offers no listing parameter that
|
||||
/// includes the array, which is why cards stay shorter than detail screens.
|
||||
///
|
||||
/// Entries are deduped on `(source, value)` because the array repeats whichever
|
||||
/// source the scalar pair was drawn from. Insertion order keeps the server's
|
||||
/// own headline first, so `ratings.first` matches the scalar `rating` the
|
||||
/// cards and sorts already use.
|
||||
List<MediaRatingSource>? plexRatingSources({
|
||||
Object? rating,
|
||||
Object? ratingImage,
|
||||
Object? audienceRating,
|
||||
Object? audienceRatingImage,
|
||||
Iterable<({Object? image, Object? type, Object? value})> ratingSources = const [],
|
||||
int? imdbVotes,
|
||||
}) {
|
||||
final ratings = <MediaRatingSource>[];
|
||||
|
||||
void add(Object? rawValue, {required String fallbackSource, Object? image, Object? type}) {
|
||||
final value = normalizedPlexRating(rawValue);
|
||||
if (value == null) return;
|
||||
final source = _plexRatingSource(image: image, type: type, fallback: fallbackSource);
|
||||
if (ratings.any((rating) => rating.source == source && rating.value == value)) return;
|
||||
ratings.add(MediaRatingSource(source: source, value: value, votes: source == 'imdb' ? imdbVotes : null));
|
||||
}
|
||||
|
||||
add(rating, fallbackSource: 'critic', image: ratingImage);
|
||||
add(audienceRating, fallbackSource: 'audience', image: audienceRatingImage);
|
||||
for (final entry in ratingSources) {
|
||||
add(entry.value, fallbackSource: 'audience', image: entry.image, type: entry.type);
|
||||
}
|
||||
return ratings.isEmpty ? null : ratings;
|
||||
}
|
||||
|
||||
String _plexRatingSource({Object? image, Object? type, required String fallback}) {
|
||||
final scheme = Uri.tryParse(_nonEmptyRatingString(image) ?? '')?.scheme.toLowerCase();
|
||||
if (scheme == 'imdb') return 'imdb';
|
||||
if (scheme == 'themoviedb' || scheme == 'tmdb') return 'tmdb';
|
||||
final ratingType = _nonEmptyRatingString(type)?.toLowerCase() ?? fallback;
|
||||
if (scheme == 'rottentomatoes') {
|
||||
return ratingType == 'critic' ? 'rottenTomatoesCritic' : 'rottenTomatoesAudience';
|
||||
}
|
||||
return switch (ratingType) {
|
||||
'critic' => 'critic',
|
||||
'audience' => 'audience',
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Coerce a Plex score onto the neutral 0-10 scale, rejecting out-of-range
|
||||
/// noise. Plex sends percentages as 0-10 already; a stray 0-100 value is
|
||||
/// folded rather than dropped.
|
||||
double? normalizedPlexRating(Object? value) {
|
||||
final rating = flexibleDouble(value);
|
||||
if (rating == null || !rating.isFinite || rating < 0 || rating > 100) return null;
|
||||
return rating > 10 ? rating / 10 : rating;
|
||||
}
|
||||
|
||||
String? _nonEmptyRatingString(Object? value) {
|
||||
if (value is! String) return null;
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
/// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers].
|
||||
///
|
||||
/// Methods come in two flavours:
|
||||
@@ -1011,10 +1109,18 @@ class PlexMappers {
|
||||
addedAt: dto.addedAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
rating: dto.rating,
|
||||
audienceRating: dto.audienceRating,
|
||||
userRating: dto.userRating,
|
||||
ratingImage: dto.ratingImage,
|
||||
audienceRatingImage: dto.audienceRatingImage,
|
||||
ratings: plexRatingSources(
|
||||
rating: dto.rating,
|
||||
ratingImage: dto.ratingImage,
|
||||
audienceRating: dto.audienceRating,
|
||||
audienceRatingImage: dto.audienceRatingImage,
|
||||
ratingSources: [
|
||||
for (final entry in dto.ratingSources ?? const <PlexRatingDto>[])
|
||||
(image: entry.image, type: entry.type, value: entry.value),
|
||||
],
|
||||
imdbVotes: dto.imdbRatingCount,
|
||||
),
|
||||
genres: dto.genre,
|
||||
directors: dto.director,
|
||||
writers: dto.writer,
|
||||
|
||||
@@ -16,6 +16,13 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
|
||||
count: flexibleInt(json['count']),
|
||||
);
|
||||
|
||||
PlexRatingDto _$PlexRatingDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexRatingDto(
|
||||
image: json['image'] as String?,
|
||||
type: json['type'] as String?,
|
||||
value: flexibleDouble(json['value']),
|
||||
);
|
||||
|
||||
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
|
||||
PlexMediaVersionDto(
|
||||
id: flexibleIntOrZero(json['id']),
|
||||
@@ -123,6 +130,10 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
|
||||
mediaVersions: (json['Media'] as List<dynamic>?)
|
||||
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
ratingSources: (json['Rating'] as List<dynamic>?)
|
||||
?.map((e) => PlexRatingDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
imdbRatingCount: flexibleInt(json['imdbRatingCount']),
|
||||
genre: _tagListFromJson(json['Genre']),
|
||||
director: _tagListFromJson(json['Director']),
|
||||
writer: _tagListFromJson(json['Writer']),
|
||||
@@ -191,6 +202,7 @@ Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
|
||||
'leafCount': ?instance.leafCount,
|
||||
'viewedLeafCount': ?instance.viewedLeafCount,
|
||||
'childCount': ?instance.childCount,
|
||||
'imdbRatingCount': ?instance.imdbRatingCount,
|
||||
'audioLanguage': ?instance.audioLanguage,
|
||||
'subtitleLanguage': ?instance.subtitleLanguage,
|
||||
'subtitleMode': ?instance.subtitleMode,
|
||||
|
||||
@@ -74,7 +74,6 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
|
||||
List<MediaVersion> availableVersions = [];
|
||||
var selectedMediaIndex = 0;
|
||||
var selectedPartIndex = 0;
|
||||
final markers = plexMarkersFromCacheJson(metadataJson);
|
||||
|
||||
if (metadataJson != null) {
|
||||
final mediaList = _mapList(metadataJson['Media']);
|
||||
@@ -148,7 +147,6 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
|
||||
videoUrl: videoUrl,
|
||||
mediaInfo: mediaInfo,
|
||||
availableVersions: availableVersions,
|
||||
markers: markers,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
selectedPartIndex: selectedPartIndex,
|
||||
);
|
||||
|
||||
@@ -142,14 +142,26 @@ class PrefsRepairOutcome {
|
||||
required this.vaultKeySalvaged,
|
||||
required this.sessionsSalvaged,
|
||||
required this.sessionsLost,
|
||||
this.backupHoldsCredentials = true,
|
||||
this.settingsReset = true,
|
||||
this.requiresRestart = false,
|
||||
});
|
||||
|
||||
/// Absolute path of the quarantined store. It contains credentials in
|
||||
/// plaintext: never upload it, attach it to a report, or log its contents.
|
||||
/// Absolute path of the quarantined store. When [backupHoldsCredentials] it
|
||||
/// contains credentials in plaintext: never upload it, attach it to a
|
||||
/// report, or log its contents.
|
||||
final String? backupPath;
|
||||
|
||||
/// Whether the quarantined file may still hold credential material.
|
||||
///
|
||||
/// Derived from the bytes, never from what the salvage recovered: a store
|
||||
/// truncated mid-value keeps most of a vault key or an entire refresh token
|
||||
/// in plaintext while matching no entry at all, because the salvage pattern
|
||||
/// requires the value's closing quote. Only a file proven to contain
|
||||
/// nothing — every byte zero (#1732) — drops the warning, because a warning
|
||||
/// attached to a file of zeros is the one that teaches users to ignore it.
|
||||
final bool backupHoldsCredentials;
|
||||
|
||||
/// Whether [credentialVaultKeyPref] was recovered. When false every stored
|
||||
/// server and profile token becomes undecryptable and must be re-acquired.
|
||||
final bool vaultKeySalvaged;
|
||||
@@ -187,6 +199,11 @@ class SalvagedPrefsCredentials {
|
||||
|
||||
/// Credential slots that were present but could not be decoded.
|
||||
final int losses;
|
||||
|
||||
/// Deliberately no "does this hold credentials" helper. What [salvage]
|
||||
/// recovered says nothing about what the file still contains: a store
|
||||
/// truncated mid-value keeps most of a vault key in plaintext and matches
|
||||
/// nothing. Ask [PrefsStoreShape] about the bytes instead.
|
||||
}
|
||||
|
||||
/// Recovers a damaged desktop preference store without silently destroying
|
||||
@@ -448,28 +465,73 @@ abstract final class PrefsRecovery {
|
||||
|
||||
static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-');
|
||||
|
||||
/// Quarantines the damaged store and returns what was salvaged.
|
||||
/// Reports what a repair would recover, without touching the store.
|
||||
///
|
||||
/// The consent dialog has to name a cost before anything is destroyed, and
|
||||
/// the cost is not uniform: a store that still holds a readable vault key
|
||||
/// keeps every server and profile signed in, while one that does not — an
|
||||
/// all-zero file being the canonical case (#1732) — signs the user out of
|
||||
/// everything. Promising the cheap outcome for the expensive case is worse
|
||||
/// than saying nothing, so the dialog runs this first and branches on it.
|
||||
///
|
||||
/// Pure with respect to the filesystem. [salvage] does register every secret
|
||||
/// it decodes with `LogRedactionManager`, which is idempotent and wanted
|
||||
/// early regardless of whether the user goes on to accept the repair.
|
||||
static Future<SalvagedPrefsCredentials> previewSalvage({File? storeFileOverride}) async {
|
||||
final File file;
|
||||
try {
|
||||
file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) return SalvagedPrefsCredentials.empty;
|
||||
} on Object {
|
||||
return SalvagedPrefsCredentials.empty;
|
||||
}
|
||||
|
||||
try {
|
||||
return salvage(_decodeForSalvage(await file.readAsBytes()));
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
// Only a preview; an unreadable file is the repair's problem to report,
|
||||
// not a reason to fail before the user has even been asked.
|
||||
appLogger.w('Could not preview the damaged preference store', error: error, stackTrace: stackTrace);
|
||||
return SalvagedPrefsCredentials.empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes damaged store bytes as text for the salvage pass.
|
||||
///
|
||||
/// Lossy rather than strict, because `File.readAsString` surfaces a UTF-8
|
||||
/// decode failure as a `FileSystemException` and would abort on exactly the
|
||||
/// damaged store this exists to rescue. For a well-formed document this is
|
||||
/// identical to a strict decode; for a damaged one it still exposes the
|
||||
/// ASCII credential entries.
|
||||
static String _decodeForSalvage(List<int> bytes) => const Utf8Decoder(allowMalformed: true).convert(bytes);
|
||||
|
||||
/// Quarantines the damaged store and returns what was salvaged, alongside
|
||||
/// the measured shape of the bytes that were moved aside.
|
||||
///
|
||||
/// The caller opens a fresh store afterwards and reseeds it; see
|
||||
/// `BaseSharedPreferencesService.repairCorruptStore`.
|
||||
///
|
||||
/// The damaged file is *moved*, never deleted: it is the only remaining copy
|
||||
/// of any credential that could not be salvaged.
|
||||
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath})> quarantine({File? storeFileOverride}) async {
|
||||
///
|
||||
/// [shape] is returned rather than inferred from [salvage] because the two
|
||||
/// answer different questions. Salvage reports what could be *recovered*;
|
||||
/// only the bytes can say whether anything sensitive is still *in there*.
|
||||
/// A store truncated mid-value — the ordinary damage shape — holds most of a
|
||||
/// vault key or an entire refresh token in plaintext while
|
||||
/// [_stringEntryPattern] matches nothing at all, because it requires the
|
||||
/// value's closing quote.
|
||||
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath, PrefsStoreShape? shape})> quarantine({
|
||||
File? storeFileOverride,
|
||||
}) async {
|
||||
final file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) {
|
||||
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null);
|
||||
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null, shape: null);
|
||||
}
|
||||
|
||||
// Read bytes and decode lossily rather than calling `readAsString`, which
|
||||
// surfaces a UTF-8 decode failure as a `FileSystemException` and would
|
||||
// abort the repair on exactly the damaged store it exists to rescue. For a
|
||||
// well-formed document this is identical to a strict decode; for a damaged
|
||||
// one it still exposes the ASCII credential entries to the salvage pass.
|
||||
// A genuine read failure propagates — that is not something a repair fixes.
|
||||
final raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes());
|
||||
|
||||
final salvaged = salvage(raw);
|
||||
final bytes = await file.readAsBytes();
|
||||
final shape = PrefsStoreShape.of(bytes);
|
||||
final salvaged = salvage(_decodeForSalvage(bytes));
|
||||
|
||||
final stamp = _stamp();
|
||||
final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json'));
|
||||
@@ -489,10 +551,10 @@ abstract final class PrefsRecovery {
|
||||
|
||||
appLogger.w(
|
||||
'Quarantined a corrupt preference store'
|
||||
' (vault key salvaged: ${salvaged.vaultKey != null},'
|
||||
' ($shape; vault key salvaged: ${salvaged.vaultKey != null},'
|
||||
' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})',
|
||||
);
|
||||
return (salvaged: salvaged, backupPath: backup.path);
|
||||
return (salvaged: salvaged, backupPath: backup.path, shape: shape);
|
||||
}
|
||||
|
||||
/// Deletes a quarantined store so the user is not left holding a
|
||||
|
||||
@@ -89,10 +89,20 @@ class SeerrClient {
|
||||
// ---------- Discover / search ----------
|
||||
|
||||
/// `/discover/movies` — popular movies.
|
||||
Future<SeerrPage<SeerrMedia>> getPopularMovies({int page = 1}) => _mediaPage('/discover/movies', page, 'movie');
|
||||
///
|
||||
/// Deliberately unlocalized. Overseerr and Jellyseerr bind this route's
|
||||
/// `language` query parameter to `originalLanguage`, i.e. TMDB's
|
||||
/// `with_original_language`, so sending the app locale narrows the shelf to
|
||||
/// titles *originally made* in that language (#1763). The display language
|
||||
/// here comes from the instance/user locale, which already wins over the
|
||||
/// query value, so omitting it costs nothing.
|
||||
Future<SeerrPage<SeerrMedia>> getPopularMovies({int page = 1}) =>
|
||||
_mediaPage('/discover/movies', page, 'movie', localized: false);
|
||||
|
||||
/// `/discover/tv` — popular series.
|
||||
Future<SeerrPage<SeerrMedia>> getPopularTv({int page = 1}) => _mediaPage('/discover/tv', page, 'tv');
|
||||
/// `/discover/tv` — popular series. Unlocalized for the same reason as
|
||||
/// [getPopularMovies].
|
||||
Future<SeerrPage<SeerrMedia>> getPopularTv({int page = 1}) =>
|
||||
_mediaPage('/discover/tv', page, 'tv', localized: false);
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> getUpcomingMovies({int page = 1}) =>
|
||||
_mediaPage('/discover/movies/upcoming', page, 'movie');
|
||||
@@ -118,8 +128,16 @@ class SeerrClient {
|
||||
Future<SeerrPage<SeerrMedia>> getTvRecommendations(int tmdbId, {int page = 1}) =>
|
||||
_mediaPage('/tv/$tmdbId/recommendations', page, 'tv');
|
||||
|
||||
Future<SeerrPage<SeerrMedia>> _mediaPage(String path, int page, String? coerceMediaType) async {
|
||||
final data = await _request('GET', path, query: {'page': page, 'language': _language});
|
||||
/// [localized] adds the app locale as `language`. Only the two paged
|
||||
/// discover routes opt out; everywhere else Seerr treats it as the display
|
||||
/// language, which is what we want.
|
||||
Future<SeerrPage<SeerrMedia>> _mediaPage(
|
||||
String path,
|
||||
int page,
|
||||
String? coerceMediaType, {
|
||||
bool localized = true,
|
||||
}) async {
|
||||
final data = await _request('GET', path, query: {'page': page, if (localized) 'language': _language});
|
||||
return _parseMediaPage(data, coerceMediaType);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,11 @@ class StartupFailureRecord {
|
||||
String get headline => '[$phaseId] $errorType: $message';
|
||||
|
||||
/// Full plain-text block for the clipboard and the diagnostics upload.
|
||||
///
|
||||
/// Includes [repairable] because the uploaded text is usually all a
|
||||
/// maintainer gets. Without it a report of "still broken" cannot be told
|
||||
/// apart from "the screen offered no way forward" and "a repair was offered
|
||||
/// and not taken" — the ambiguity that stalled #1732 for two days.
|
||||
String describe() {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('Plezy startup failure')
|
||||
@@ -214,6 +219,7 @@ class StartupFailureRecord {
|
||||
..writeln('When: ${timestamp.toUtc().toIso8601String()}')
|
||||
..writeln('Phase: $phaseId')
|
||||
..writeln('Error: $errorType')
|
||||
..writeln('Repair offered: ${repairable ? 'yes' : 'no'}')
|
||||
..writeln('Message: $message');
|
||||
final stack = stackTrace;
|
||||
if (stack != null && stack.isNotEmpty) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import '../utils/track_label_builder.dart';
|
||||
/// stream indexes) or lack server-side stream selection leave this null.
|
||||
/// [trackType] is `'audio'` or `'subtitle'`.
|
||||
typedef TrackPreferencePersister =
|
||||
Future<void> Function({required int partId, required String trackType, int? streamID});
|
||||
Future<void> Function({required int partId, required String trackType, required int streamID});
|
||||
|
||||
/// Manages track (audio + subtitle) lifecycle: external subtitle loading,
|
||||
/// automatic track selection, server preference sync, and cycling.
|
||||
@@ -385,10 +385,12 @@ class TrackManager {
|
||||
|
||||
// ── Track cycling (remote/keyboard shortcuts) ──────────────────────
|
||||
|
||||
/// Cycle to the next subtitle track and save the preference.
|
||||
void cycleSubtitleTrack() {
|
||||
/// Cycle to the next subtitle track, save the preference, and return the
|
||||
/// track now playing so the caller can record it as the committed choice.
|
||||
/// Returns null when there was nothing to cycle.
|
||||
SubtitleTrack? cycleSubtitleTrack() {
|
||||
final tracks = player.state.tracks.subtitle.where((t) => t.id != 'auto').toList();
|
||||
if (tracks.isEmpty) return;
|
||||
if (tracks.isEmpty) return null;
|
||||
|
||||
final current = player.state.track.subtitle;
|
||||
final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
|
||||
@@ -403,6 +405,7 @@ class TrackManager {
|
||||
: 'Subtitles: ${TrackLabelBuilder.subtitleLabel(title: next.title, language: next.language, codec: next.codec, forced: next.isForced, index: nextIndex).joined}';
|
||||
showMessage?.call(label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/// Cycle to the next audio track and save the preference.
|
||||
@@ -530,7 +533,15 @@ class TrackManager {
|
||||
}
|
||||
|
||||
/// Save the stream selection for the current part to the server.
|
||||
///
|
||||
/// A null [streamID] means no server stream could be identified for the
|
||||
/// chosen track. There is no local fallback store, so the choice is simply
|
||||
/// lost — say so instead of reporting a save that never happened.
|
||||
Future<void> _saveTrackPreferences({required int partId, required String trackType, int? streamID}) async {
|
||||
if (streamID == null) {
|
||||
appLogger.w('Not saving $trackType stream selection: no server stream matched the selected track');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!isActive()) return;
|
||||
final persist = persistTrackPreference;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user