Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98bd085bff | ||
|
|
23f9afa5b1 | ||
|
|
efb0e885ae | ||
|
|
5fb4c78385 | ||
|
|
5a666fd10f | ||
|
|
56312a9eb2 | ||
|
|
976bdbd021 | ||
|
|
274b31ebac | ||
|
|
e1ef52aba1 | ||
|
|
9db6f54b7a | ||
|
|
39ee089212 | ||
|
|
89d54d8f1d | ||
|
|
44773fd742 | ||
|
|
4f4a7338e0 | ||
|
|
10f9139ac6 | ||
|
|
3c1ab3cc6c | ||
|
|
c597390e1c |
@@ -72,7 +72,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.17.1"
|
||||
sha256 "6b9ed5b89dd6b1a00389bbc2d3d9d67f31bfb731be6496bdffff36c473100e38"
|
||||
version "2.18.0"
|
||||
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -58,12 +58,18 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.1.3"
|
||||
val mpvSha256 = "cc5dfa97b140934515691082d1125afedb796ea55357a9051d4ac09a9f6f39ac"
|
||||
val mpvVersion = "v1.2.2"
|
||||
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
// Dev-only escape hatch: point the build at a locally built libmpv AAR
|
||||
// (libmpv-android's build-local.sh output) to test fork patches before a
|
||||
// release is pinned. The URL + sha256 above stay authoritative otherwise.
|
||||
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
|
||||
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
|
||||
|
||||
val media3Version = "1.11.0"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
@@ -76,6 +82,8 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
inputs.property("localOverride", localMpvAar ?: "")
|
||||
localMpvAar?.let { inputs.file(it) }
|
||||
outputs.files(aar, manifest)
|
||||
doLast {
|
||||
mpvDir.parentFile.mkdirs()
|
||||
@@ -83,15 +91,20 @@ val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
if (localMpvAar != null) {
|
||||
File(localMpvAar).copyTo(stagedAar, overwrite = true)
|
||||
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
|
||||
} else {
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
|
||||
@@ -79,18 +79,26 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
|
||||
/** The IEC 61937 track shape a codec's spdif burst rides. */
|
||||
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
|
||||
|
||||
private class MpvSpdifCodec(val name: String, val encoding: Int, val shape: MpvIecShape)
|
||||
private class MpvSpdifCodec(
|
||||
val name: String,
|
||||
val encoding: Int,
|
||||
val shape: MpvIecShape,
|
||||
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
|
||||
val raw: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
|
||||
* bitstream, and the IEC 61937 track shape the codec's burst rides.
|
||||
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
|
||||
*
|
||||
* `ad_spdif` fixes the geometry per codec: AC3 and the DTS core are stereo frames at the mixer
|
||||
* rate, E-AC3 a stereo frame at 192kHz, TrueHD as MAT and DTS-HD MA 8-channel 192kHz bursts
|
||||
* (`audio/decode/ad_spdif.c:216-267`). Only AC3 and the DTS core used to be listed because
|
||||
* libmpv's `ao_audiotrack` squeezed every burst into a stereo track at the mixer rate; v1.1.0
|
||||
* skips that clamp for spdif and takes the channel mask from the burst instead
|
||||
* (`audio/out/ao_audiotrack.c:678-731`).
|
||||
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
|
||||
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
|
||||
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
|
||||
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
|
||||
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
|
||||
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
|
||||
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
|
||||
* forms are not recoverable from the burst stream.
|
||||
*
|
||||
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
|
||||
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
|
||||
@@ -99,31 +107,37 @@ private class MpvSpdifCodec(val name: String, val encoding: Int, val shape: MpvI
|
||||
* conservative choice.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K),
|
||||
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
|
||||
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K),
|
||||
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
|
||||
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
|
||||
* advertises the codec's encoding *and* [supportsShape] takes the track shape its burst needs.
|
||||
* Plain `dts` is dropped whenever `dts-hd` qualifies, which already covers the core burst.
|
||||
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
|
||||
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
|
||||
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
|
||||
* already covers the core burst.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
*
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
|
||||
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
|
||||
* channel counts above the route's PCM maximum, neither of which describes what an IEC
|
||||
* 61937 track carries.
|
||||
* channel counts above the route's PCM maximum, neither of which describes what a
|
||||
* passthrough track carries.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(
|
||||
supportsEncoding: (Int) -> Boolean,
|
||||
supportsShape: (MpvIecShape) -> Boolean
|
||||
supportsShape: (MpvIecShape) -> Boolean,
|
||||
supportsRawTrack: (Int) -> Boolean = { false }
|
||||
): String {
|
||||
val carried = MPV_SPDIF_CODECS.filter { supportsEncoding(it.encoding) && supportsShape(it.shape) }
|
||||
val carried = MPV_SPDIF_CODECS.filter {
|
||||
supportsEncoding(it.encoding) &&
|
||||
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
|
||||
}
|
||||
val dtsHd = carried.any { it.name == "dts-hd" }
|
||||
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
|
||||
}
|
||||
@@ -132,16 +146,17 @@ internal fun mpvSpdifCodecs(
|
||||
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
|
||||
*
|
||||
* Two conditions per codec, both required:
|
||||
* - The route must accept the exact track shape mpv opens for that codec's burst — stereo
|
||||
* IEC 61937 at 48kHz ([supportsMpvIecShape]), stereo at 192kHz
|
||||
* ([supportsMpvHighRateIecShape]) or 192kHz/7.1 ([supportsIecCarrier]). Advertising the raw
|
||||
* encoding only says the receiver decodes it, not that the HAL takes an IEC 61937 AudioTrack:
|
||||
* #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv spdif attempt
|
||||
* strands playback on a dead audio output, leaving nothing but AAC playable. The shapes are
|
||||
* independent, so none of them may veto the whole list: a route that takes the 192kHz carrier
|
||||
* but not the 48kHz stereo frame still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
|
||||
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
|
||||
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
|
||||
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
|
||||
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
|
||||
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
|
||||
* The probes are independent, so none of them may veto the whole list: a route that takes the
|
||||
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
|
||||
* - The receiver must decode the codec itself — the raw encoding on the current
|
||||
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
|
||||
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
|
||||
* the platform may additionally transcode, which is exactly why the AO prefers it.)
|
||||
*/
|
||||
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
|
||||
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
|
||||
@@ -158,13 +173,16 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
// Every shape costs real route probes and is shared by more than one codec, so probe each once.
|
||||
val probed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(capabilities::supportsEncoding) { shape ->
|
||||
probed.getOrPut(shape) { routeTakesIecShape(context, shape) }
|
||||
}
|
||||
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
|
||||
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
|
||||
val rawProbed = HashMap<Int, Boolean>(3)
|
||||
val codecs = mpvSpdifCodecs(
|
||||
capabilities::supportsEncoding,
|
||||
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
|
||||
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
|
||||
)
|
||||
if (codecs.isEmpty()) {
|
||||
Log.i(TAG, "Route takes no IEC 61937 track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
|
||||
} else {
|
||||
Log.i(TAG, "mpv will bitstream: $codecs")
|
||||
}
|
||||
@@ -198,6 +216,30 @@ internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
|
||||
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
|
||||
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
|
||||
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
|
||||
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
|
||||
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
|
||||
*/
|
||||
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
|
||||
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
|
||||
},
|
||||
hdmiRouteAdvertised = { true }
|
||||
)
|
||||
|
||||
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
|
||||
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
@@ -280,8 +322,11 @@ internal fun iecRouteSupported(
|
||||
else -> hdmiRouteAdvertised()
|
||||
}
|
||||
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
|
||||
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
|
||||
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
|
||||
|
||||
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
|
||||
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
@@ -326,8 +371,11 @@ private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCou
|
||||
}
|
||||
|
||||
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat =
|
||||
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
|
||||
|
||||
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(encoding)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
|
||||
@@ -155,11 +155,52 @@ class AudioOutputPolicyTest {
|
||||
|
||||
@Test
|
||||
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
|
||||
// Every encoding advertised, but no IEC 61937 track shape opens: mpv has no decode fallback
|
||||
// for a named codec, so nothing may be named (#1991).
|
||||
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
|
||||
// decode fallback for a named codec, so nothing may be named (#1991).
|
||||
assertEquals("", spdifCodecs(allEncodings, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
|
||||
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
|
||||
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
|
||||
// so raw support alone must qualify the core codecs.
|
||||
assertEquals(
|
||||
"ac3,eac3,dts",
|
||||
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawSupportNeverQualifiesTheLosslessCodecs() {
|
||||
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
|
||||
// carrier or decode. A route that takes every raw track but no carrier must not name them.
|
||||
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawProbeIsOnlyConsultedForRawCandidates() {
|
||||
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
|
||||
val codecs = mpvSpdifCodecs(
|
||||
{ true },
|
||||
{ true },
|
||||
{ encoding ->
|
||||
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
|
||||
throw AssertionError("raw probe consulted for a carrier-only codec")
|
||||
}
|
||||
true
|
||||
}
|
||||
)
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
|
||||
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
|
||||
// must not resurrect the plain name beside it.
|
||||
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iecRouteIsNeverOfferedBelowApi24() {
|
||||
// ENCODING_IEC61937 does not exist there.
|
||||
@@ -262,5 +303,6 @@ class AudioOutputPolicyTest {
|
||||
|
||||
private val allShapes = MpvIecShape.values().toSet()
|
||||
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>): String = mpvSpdifCodecs({ it in encodings }, { it in shapes })
|
||||
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
|
||||
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
|
||||
}
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Defolt keyfiyyət",
|
||||
"cellularQualityTitle": "Mobil şəbəkədə defolt keyfiyyət",
|
||||
"cellularQualitySameAsDefault": "Defolt keyfiyyətlə eyni",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musiqi keyfiyyəti",
|
||||
"subtitleStyling": "Altyazı tənzimləmələri",
|
||||
"subtitleStylingDescription": "Altyazı görünüşünü özünüləşdirin",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Səsi tənzimləmək üçün sağ kənarda yuxarı və ya aşağı sürüşdürün",
|
||||
"gesturePinchToZoom": "Çimdiklə yaxınlaşdır",
|
||||
"gesturePinchToZoomDescription": "Yaxınlaşdırmaq və ya uzaqlaşdırmaq üçün videoda çimdik hərəkəti edin",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "İdarəetmələr",
|
||||
"rememberPlayerChanges": "Pleyer dəyişikliklərini yadda saxla",
|
||||
"rememberPlayerChangesDescription": "Oxutma zamanı edilən dəyişikliklərin harada saxlanacağı və yenidən tətbiq ediləcəyi",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Təşkilatçı",
|
||||
"hostBadge": "TƏŞKİLATÇI",
|
||||
"youAreHost": "Təşkilatçı sizsiniz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Başqaları ilə izlənilir",
|
||||
"endSession": "Seansı bitir",
|
||||
"leaveSession": "Seansdan çıx",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Качество по подразбиране",
|
||||
"cellularQualityTitle": "Качество по подразбиране при мобилни данни",
|
||||
"cellularQualitySameAsDefault": "Същото като качеството по подразбиране",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Качество на музиката",
|
||||
"subtitleStyling": "Стил на субтитрите",
|
||||
"subtitleStylingDescription": "Настройване на вида на субтитрите",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Плъзни нагоре или надолу по десния ръб, за да регулираш силата на звука",
|
||||
"gesturePinchToZoom": "Стискане за мащабиране",
|
||||
"gesturePinchToZoomDescription": "Стисни видеото, за да увеличиш или намалиш мащаба",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Контроли",
|
||||
"rememberPlayerChanges": "Запомняне на промените в плейъра",
|
||||
"rememberPlayerChangesDescription": "Къде се записва и откъде се прилага отново промяна, направена по време на възпроизвеждане",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вие сте организаторът",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Гледате с други",
|
||||
"endSession": "Край на сесията",
|
||||
"leaveSession": "Напусни сесията",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standardkvalitet",
|
||||
"cellularQualityTitle": "Standardkvalitet på mobildata",
|
||||
"cellularQualitySameAsDefault": "Samme som standardkvalitet",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikkvalitet",
|
||||
"subtitleStyling": "Undertekststil",
|
||||
"subtitleStylingDescription": "Tilpas underteksters udseende",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Stryg op eller ned i højre kant for at justere lydstyrken",
|
||||
"gesturePinchToZoom": "Klem for at zoome",
|
||||
"gesturePinchToZoomDescription": "Klem på videoen for at zoome ind eller ud",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Kontroller",
|
||||
"rememberPlayerChanges": "Husk afspillerændringer",
|
||||
"rememberPlayerChangesDescription": "Hvor en ændring under afspilning gemmes og anvendes igen",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Vært",
|
||||
"hostBadge": "VÆRT",
|
||||
"youAreHost": "Du er vært",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Ser med andre",
|
||||
"endSession": "Afslut session",
|
||||
"leaveSession": "Forlad session",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standardqualität",
|
||||
"cellularQualityTitle": "Standardqualität im Mobilfunknetz",
|
||||
"cellularQualitySameAsDefault": "Wie Standardqualität",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikqualität",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Wische am rechten Rand nach oben oder unten, um die Lautstärke anzupassen",
|
||||
"gesturePinchToZoom": "Zum Zoomen kneifen",
|
||||
"gesturePinchToZoomDescription": "Kneife auf dem Video, um hinein- oder herauszuzoomen",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Steuerung",
|
||||
"rememberPlayerChanges": "Playeränderungen merken",
|
||||
"rememberPlayerChangesDescription": "Wo eine während der Wiedergabe vorgenommene Änderung gespeichert und erneut angewendet wird",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Du bist der Host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Mit anderen schauen",
|
||||
"endSession": "Sitzung beenden",
|
||||
"leaveSession": "Sitzung verlassen",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Default Quality",
|
||||
"cellularQualityTitle": "Default Quality on Cellular",
|
||||
"cellularQualitySameAsDefault": "Same as Default Quality",
|
||||
"directPlayCoveredQuality": "Play Smaller Videos at Original Quality",
|
||||
"directPlayCoveredQualityDescription": "Direct play videos already within the quality limit instead of transcoding them",
|
||||
"musicQualityTitle": "Music Quality",
|
||||
"subtitleStyling": "Subtitle Styling",
|
||||
"subtitleStylingDescription": "Customize subtitle appearance",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Swipe up or down on the right edge to adjust volume",
|
||||
"gesturePinchToZoom": "Pinch to Zoom",
|
||||
"gesturePinchToZoomDescription": "Pinch on the video to zoom in or out",
|
||||
"rememberBrightnessLevel": "Remember Brightness Level",
|
||||
"rememberBrightnessLevelDescription": "Start playback at the brightness set by the last swipe",
|
||||
"controls": "Controls",
|
||||
"rememberPlayerChanges": "Remember Player Changes",
|
||||
"rememberPlayerChangesDescription": "Where a change made during playback is saved and reapplied from",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "You are the host",
|
||||
"makeHost": "Make host",
|
||||
"makeHostQuestion": "Transfer host?",
|
||||
"makeHostConfirm": "${name} will control playback and drive the session for everyone.",
|
||||
"transfer": "Transfer",
|
||||
"hostChangedTo": "${name} is now the host",
|
||||
"youAreNowHost": "You are now the host",
|
||||
"hostTransferFailed": "Couldn't make ${name} the host",
|
||||
"watchingWithOthers": "Watching with others",
|
||||
"endSession": "End Session",
|
||||
"leaveSession": "Leave Session",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Calidad predeterminada",
|
||||
"cellularQualityTitle": "Calidad predeterminada en datos móviles",
|
||||
"cellularQualitySameAsDefault": "Igual que la calidad predeterminada",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Calidad de música",
|
||||
"subtitleStyling": "Estilo de subtítulos",
|
||||
"subtitleStylingDescription": "Personalizar la apariencia de los subtítulos",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Desliza hacia arriba o abajo en el borde derecho para ajustar el volumen",
|
||||
"gesturePinchToZoom": "Pellizcar para hacer zoom",
|
||||
"gesturePinchToZoomDescription": "Pellizca el video para acercar o alejar",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Controles",
|
||||
"rememberPlayerChanges": "Recordar cambios del reproductor",
|
||||
"rememberPlayerChangesDescription": "Dónde se guarda y se vuelve a aplicar un cambio realizado durante la reproducción",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Anfitrión",
|
||||
"hostBadge": "ANFITRIÓN",
|
||||
"youAreHost": "Eres el anfitrión",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Viendo contenido con otras personas",
|
||||
"endSession": "Finalizar sesión",
|
||||
"leaveSession": "Salir de la sesión",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Qualité par défaut",
|
||||
"cellularQualityTitle": "Qualité par défaut sur les données mobiles",
|
||||
"cellularQualitySameAsDefault": "Identique à la qualité par défaut",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Qualité de la musique",
|
||||
"subtitleStyling": "Style des sous-titres",
|
||||
"subtitleStylingDescription": "Personnaliser l’apparence des sous-titres",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Balayez vers le haut ou le bas sur le bord droit pour régler le volume",
|
||||
"gesturePinchToZoom": "Pincer pour zoomer",
|
||||
"gesturePinchToZoomDescription": "Pincez la vidéo pour zoomer ou dézoomer",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Commandes",
|
||||
"rememberPlayerChanges": "Mémoriser les modifications du lecteur",
|
||||
"rememberPlayerChangesDescription": "Où une modification effectuée pendant la lecture est enregistrée et réappliquée",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Hôte",
|
||||
"hostBadge": "HÔTE",
|
||||
"youAreHost": "Vous êtes l'hôte",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Regarder avec d'autres personnes",
|
||||
"endSession": "Terminer la session",
|
||||
"leaveSession": "Quitter la session",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Alapértelmezett minőség",
|
||||
"cellularQualityTitle": "Alapértelmezett minőség mobilhálózaton",
|
||||
"cellularQualitySameAsDefault": "Ugyanaz, mint az alapértelmezett minőség",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Zene minősége",
|
||||
"subtitleStyling": "Feliratok stílusa",
|
||||
"subtitleStylingDescription": "Feliratok megjelenésének testreszabása",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Húzd felfelé vagy lefelé a jobb szélén a hangerő beállításához",
|
||||
"gesturePinchToZoom": "Csípés a nagyításhoz",
|
||||
"gesturePinchToZoomDescription": "Csípj a videóra a nagyításhoz vagy kicsinyítéshez",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Vezérlők",
|
||||
"rememberPlayerChanges": "Lejátszó módosításainak megjegyzése",
|
||||
"rememberPlayerChangesDescription": "A lejátszás közben végzett módosítások mentési és újbóli alkalmazási helye",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Házigazda",
|
||||
"hostBadge": "HÁZIGAZDA",
|
||||
"youAreHost": "Te vagy a házigazda",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Nézés másokkal",
|
||||
"endSession": "Munkamenet befejezése",
|
||||
"leaveSession": "Munkamenet elhagyása",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Qualità predefinita",
|
||||
"cellularQualityTitle": "Qualità predefinita sulla rete mobile",
|
||||
"cellularQualitySameAsDefault": "Come la qualità predefinita",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Qualità musicale",
|
||||
"subtitleStyling": "Stile sottotitoli",
|
||||
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Scorri verso l'alto o il basso sul bordo destro per regolare il volume",
|
||||
"gesturePinchToZoom": "Pizzica per lo zoom",
|
||||
"gesturePinchToZoomDescription": "Pizzica il video per ingrandire o ridurre",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Controlli",
|
||||
"rememberPlayerChanges": "Ricorda le modifiche del lettore",
|
||||
"rememberPlayerChangesDescription": "Dove viene salvata e riapplicata una modifica effettuata durante la riproduzione",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Sei l'host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "In visione con altri partecipanti",
|
||||
"endSession": "Termina la sessione",
|
||||
"leaveSession": "Abbandona la sessione",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "デフォルト画質",
|
||||
"cellularQualityTitle": "モバイルデータ通信時のデフォルト画質",
|
||||
"cellularQualitySameAsDefault": "デフォルト画質と同じ",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "音楽の音質",
|
||||
"subtitleStyling": "字幕スタイル",
|
||||
"subtitleStylingDescription": "字幕の外観をカスタマイズ",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "右端を上下にスワイプして音量を調整します",
|
||||
"gesturePinchToZoom": "ピンチでズーム",
|
||||
"gesturePinchToZoomDescription": "動画をピンチしてズームイン・アウト",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "コントロール",
|
||||
"rememberPlayerChanges": "プレーヤーの変更を記憶",
|
||||
"rememberPlayerChangesDescription": "再生中に行った変更を保存し、再適用する場所",
|
||||
@@ -1575,6 +1579,13 @@
|
||||
"host": "ホスト",
|
||||
"hostBadge": "ホスト",
|
||||
"youAreHost": "あなたはホストです",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "他の人と視聴中",
|
||||
"endSession": "セッションを終了",
|
||||
"leaveSession": "セッションを退出",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Әдепкі сапа",
|
||||
"cellularQualityTitle": "Мобильді желідегі әдепкі сапа",
|
||||
"cellularQualitySameAsDefault": "Әдепкі сапамен бірдей",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Музыка сапасы",
|
||||
"subtitleStyling": "Субтитр баптаулары",
|
||||
"subtitleStylingDescription": "Субтитрлердің сыртқы келбетін теңшеу",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Оң жақ шетінде жоғары немесе төмен сырғытып, дыбыс деңгейін реттеу",
|
||||
"gesturePinchToZoom": "Шымшу арқылы масштабтау",
|
||||
"gesturePinchToZoomDescription": "Видеода шымшып, жақындату немесе алыстату",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Басқару элементтері",
|
||||
"rememberPlayerChanges": "Ойнатқыш өзгерістерін есте сақтау",
|
||||
"rememberPlayerChangesDescription": "Ойнату кезінде жасалған өзгеріс сақталатын және қайта қолданылатын орын",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Ұйымдастырушы",
|
||||
"hostBadge": "ҰЙЫМДАСТЫРУШЫ",
|
||||
"youAreHost": "Сіз ұйымдастырушысыз",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Басқалармен бірге көрілуде",
|
||||
"endSession": "Сеансты аяқтау",
|
||||
"leaveSession": "Сеанстан шығу",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "기본 화질",
|
||||
"cellularQualityTitle": "셀룰러에서 기본 화질",
|
||||
"cellularQualitySameAsDefault": "기본 화질과 동일",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "음악 음질",
|
||||
"subtitleStyling": "자막 스타일",
|
||||
"subtitleStylingDescription": "자막 모양을 사용자 지정합니다",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "오른쪽 가장자리에서 위아래로 스와이프하여 볼륨을 조절합니다",
|
||||
"gesturePinchToZoom": "핀치 줌",
|
||||
"gesturePinchToZoomDescription": "비디오에서 핀치하여 확대하거나 축소합니다",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "컨트롤",
|
||||
"rememberPlayerChanges": "플레이어 변경 사항 기억",
|
||||
"rememberPlayerChangesDescription": "재생 중 변경한 사항을 저장하고 다시 적용할 위치",
|
||||
@@ -1575,6 +1579,13 @@
|
||||
"host": "호스트",
|
||||
"hostBadge": "호스트",
|
||||
"youAreHost": "호스트입니다",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "다른 사람과 함께 시청 중",
|
||||
"endSession": "세션 종료",
|
||||
"leaveSession": "세션 나가기",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standardkvalitet",
|
||||
"cellularQualityTitle": "Standardkvalitet på mobilnett",
|
||||
"cellularQualitySameAsDefault": "Samme som standardkvalitet",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikkvalitet",
|
||||
"subtitleStyling": "Undertekststil",
|
||||
"subtitleStylingDescription": "Tilpass utseendet på undertekster",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Sveip opp eller ned på høyre kant for å justere volumet",
|
||||
"gesturePinchToZoom": "Klyp for å zoome",
|
||||
"gesturePinchToZoomDescription": "Klyp på videoen for å zoome inn eller ut",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Kontroller",
|
||||
"rememberPlayerChanges": "Husk endringer i spilleren",
|
||||
"rememberPlayerChangesDescription": "Hvor en endring under avspilling lagres og brukes på nytt",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Vert",
|
||||
"hostBadge": "VERT",
|
||||
"youAreHost": "Du er verten",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Ser med andre",
|
||||
"endSession": "Avslutt økt",
|
||||
"leaveSession": "Forlat økt",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standaardkwaliteit",
|
||||
"cellularQualityTitle": "Standaardkwaliteit bij mobiele data",
|
||||
"cellularQualitySameAsDefault": "Zelfde als standaardkwaliteit",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Muziekkwaliteit",
|
||||
"subtitleStyling": "Ondertitelopmaak",
|
||||
"subtitleStylingDescription": "Pas de weergave van ondertitels aan",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Veeg op de rechterrand omhoog of omlaag om het volume aan te passen",
|
||||
"gesturePinchToZoom": "Knijpen om te zoomen",
|
||||
"gesturePinchToZoomDescription": "Knijp op de video om in of uit te zoomen",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Bediening",
|
||||
"rememberPlayerChanges": "Spelerwijzigingen onthouden",
|
||||
"rememberPlayerChangesDescription": "Waar een wijziging tijdens het afspelen wordt opgeslagen en opnieuw toegepast",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Host",
|
||||
"hostBadge": "HOST",
|
||||
"youAreHost": "Jij bent de host",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Kijken met anderen",
|
||||
"endSession": "Sessie beëindigen",
|
||||
"leaveSession": "Sessie verlaten",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Domyślna jakość",
|
||||
"cellularQualityTitle": "Domyślna jakość w sieci komórkowej",
|
||||
"cellularQualitySameAsDefault": "Taka sama jak domyślna jakość",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Jakość muzyki",
|
||||
"subtitleStyling": "Styl napisów",
|
||||
"subtitleStylingDescription": "Dostosuj wygląd napisów",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Przesuwaj palcem w górę lub w dół na prawej krawędzi, aby regulować głośność",
|
||||
"gesturePinchToZoom": "Powiększanie ściskaniem",
|
||||
"gesturePinchToZoomDescription": "Ściśnij wideo palcami, aby powiększyć lub pomniejszyć",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Sterowanie",
|
||||
"rememberPlayerChanges": "Zapamiętuj zmiany odtwarzacza",
|
||||
"rememberPlayerChangesDescription": "Miejsce zapisywania i ponownego stosowania zmian dokonanych podczas odtwarzania",
|
||||
@@ -1605,6 +1609,13 @@
|
||||
"host": "Gospodarz",
|
||||
"hostBadge": "GOSPODARZ",
|
||||
"youAreHost": "Jesteś gospodarzem",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Oglądasz z innymi",
|
||||
"endSession": "Zakończ sesję",
|
||||
"leaveSession": "Opuść sesję",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Qualidade padrão",
|
||||
"cellularQualityTitle": "Qualidade padrão nos dados móveis",
|
||||
"cellularQualitySameAsDefault": "Igual à qualidade padrão",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Qualidade da música",
|
||||
"subtitleStyling": "Estilo de Legendas",
|
||||
"subtitleStylingDescription": "Personalizar aparência das legendas",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Deslize para cima ou para baixo na borda direita para ajustar o volume",
|
||||
"gesturePinchToZoom": "Pinça para zoom",
|
||||
"gesturePinchToZoomDescription": "Pince o vídeo para ampliar ou reduzir",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Controles",
|
||||
"rememberPlayerChanges": "Lembrar alterações do reprodutor",
|
||||
"rememberPlayerChangesDescription": "Onde uma alteração feita durante a reprodução é salva e reaplicada",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Anfitrião",
|
||||
"hostBadge": "ANFITRIÃO",
|
||||
"youAreHost": "Você é o anfitrião",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Assistindo com outras pessoas",
|
||||
"endSession": "Encerrar sessão",
|
||||
"leaveSession": "Sair da sessão",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Качество по умолчанию",
|
||||
"cellularQualityTitle": "Качество по умолчанию в мобильной сети",
|
||||
"cellularQualitySameAsDefault": "Как качество по умолчанию",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Качество музыки",
|
||||
"subtitleStyling": "Стиль субтитров",
|
||||
"subtitleStylingDescription": "Настроить внешний вид субтитров",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Проведите вверх или вниз по правому краю, чтобы изменить громкость",
|
||||
"gesturePinchToZoom": "Щипок для масштабирования",
|
||||
"gesturePinchToZoomDescription": "Сведите или разведите пальцы на видео, чтобы изменить масштаб",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Элементы управления",
|
||||
"rememberPlayerChanges": "Запоминать изменения плеера",
|
||||
"rememberPlayerChangesDescription": "Где сохраняется и откуда повторно применяется изменение, сделанное во время воспроизведения",
|
||||
@@ -1605,6 +1609,13 @@
|
||||
"host": "Организатор",
|
||||
"hostBadge": "ОРГАНИЗАТОР",
|
||||
"youAreHost": "Вы — организатор",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Совместный просмотр",
|
||||
"endSession": "Завершить сессию",
|
||||
"leaveSession": "Покинуть сессию",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 43736 (1988 per locale)
|
||||
/// Strings: 43747 (1988 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -633,6 +633,12 @@ class Translations$settings$en {
|
||||
/// en: 'Same as Default Quality'
|
||||
String get cellularQualitySameAsDefault => 'Same as Default Quality';
|
||||
|
||||
/// en: 'Play Smaller Videos at Original Quality'
|
||||
String get directPlayCoveredQuality => 'Play Smaller Videos at Original Quality';
|
||||
|
||||
/// en: 'Direct play videos already within the quality limit instead of transcoding them'
|
||||
String get directPlayCoveredQualityDescription => 'Direct play videos already within the quality limit instead of transcoding them';
|
||||
|
||||
/// en: 'Music Quality'
|
||||
String get musicQualityTitle => 'Music Quality';
|
||||
|
||||
@@ -1203,6 +1209,12 @@ class Translations$settings$en {
|
||||
/// en: 'Pinch on the video to zoom in or out'
|
||||
String get gesturePinchToZoomDescription => 'Pinch on the video to zoom in or out';
|
||||
|
||||
/// en: 'Remember Brightness Level'
|
||||
String get rememberBrightnessLevel => 'Remember Brightness Level';
|
||||
|
||||
/// en: 'Start playback at the brightness set by the last swipe'
|
||||
String get rememberBrightnessLevelDescription => 'Start playback at the brightness set by the last swipe';
|
||||
|
||||
/// en: 'Controls'
|
||||
String get controls => 'Controls';
|
||||
|
||||
@@ -4016,6 +4028,27 @@ class Translations$watchTogether$en {
|
||||
/// en: 'You are the host'
|
||||
String get youAreHost => 'You are the host';
|
||||
|
||||
/// en: 'Make host'
|
||||
String get makeHost => 'Make host';
|
||||
|
||||
/// en: 'Transfer host?'
|
||||
String get makeHostQuestion => 'Transfer host?';
|
||||
|
||||
/// en: '${name} will control playback and drive the session for everyone.'
|
||||
String makeHostConfirm({required Object name}) => '${name} will control playback and drive the session for everyone.';
|
||||
|
||||
/// en: 'Transfer'
|
||||
String get transfer => 'Transfer';
|
||||
|
||||
/// en: '${name} is now the host'
|
||||
String hostChangedTo({required Object name}) => '${name} is now the host';
|
||||
|
||||
/// en: 'You are now the host'
|
||||
String get youAreNowHost => 'You are now the host';
|
||||
|
||||
/// en: 'Couldn't make ${name} the host'
|
||||
String hostTransferFailed({required Object name}) => 'Couldn\'t make ${name} the host';
|
||||
|
||||
/// en: 'Watching with others'
|
||||
String get watchingWithOthers => 'Watching with others';
|
||||
|
||||
@@ -7098,6 +7131,8 @@ extension on Translations {
|
||||
'settings.defaultQualityTitle' => 'Default Quality',
|
||||
'settings.cellularQualityTitle' => 'Default Quality on Cellular',
|
||||
'settings.cellularQualitySameAsDefault' => 'Same as Default Quality',
|
||||
'settings.directPlayCoveredQuality' => 'Play Smaller Videos at Original Quality',
|
||||
'settings.directPlayCoveredQualityDescription' => 'Direct play videos already within the quality limit instead of transcoding them',
|
||||
'settings.musicQualityTitle' => 'Music Quality',
|
||||
'settings.subtitleStyling' => 'Subtitle Styling',
|
||||
'settings.subtitleStylingDescription' => 'Customize subtitle appearance',
|
||||
@@ -7288,6 +7323,8 @@ extension on Translations {
|
||||
'settings.gestureVolumeSwipeDescription' => 'Swipe up or down on the right edge to adjust volume',
|
||||
'settings.gesturePinchToZoom' => 'Pinch to Zoom',
|
||||
'settings.gesturePinchToZoomDescription' => 'Pinch on the video to zoom in or out',
|
||||
'settings.rememberBrightnessLevel' => 'Remember Brightness Level',
|
||||
'settings.rememberBrightnessLevelDescription' => 'Start playback at the brightness set by the last swipe',
|
||||
'settings.controls' => 'Controls',
|
||||
'settings.rememberPlayerChanges' => 'Remember Player Changes',
|
||||
'settings.rememberPlayerChangesDescription' => 'Where a change made during playback is saved and reapplied from',
|
||||
@@ -7422,12 +7459,12 @@ extension on Translations {
|
||||
'fileInfo.filePresent' => 'File Present',
|
||||
'fileInfo.fileReadable' => 'Readable by Server',
|
||||
'fileInfo.streamPath' => 'Stream Path',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.optimizedForStreaming' => 'Optimized for Streaming',
|
||||
'fileInfo.has64bitOffsets' => '64-bit Offsets',
|
||||
'fileInfo.protocol' => 'Protocol',
|
||||
'fileInfo.mediaType' => 'Media Type',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'fileInfo.sourceKind' => 'Source Kind',
|
||||
'fileInfo.optimizedVersion' => 'Optimized Version',
|
||||
'fileInfo.optimizationTarget' => 'Optimization Target',
|
||||
@@ -7936,12 +7973,12 @@ extension on Translations {
|
||||
'libraries.groupings.tracks' => 'Tracks',
|
||||
'libraries.groupings.folders' => 'Folders',
|
||||
'libraries.filterCategories.genre' => 'Genre',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.year' => 'Year',
|
||||
'libraries.filterCategories.contentRating' => 'Content Rating',
|
||||
'libraries.filterCategories.tag' => 'Tag',
|
||||
'libraries.filterCategories.unwatched' => 'Unwatched',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'libraries.filterCategories.unplayed' => 'Unplayed',
|
||||
'libraries.filterCategories.favorites' => 'Favorites',
|
||||
'libraries.sortLabels.title' => 'Title',
|
||||
@@ -8346,6 +8383,13 @@ extension on Translations {
|
||||
'watchTogether.host' => 'Host',
|
||||
'watchTogether.hostBadge' => 'HOST',
|
||||
'watchTogether.youAreHost' => 'You are the host',
|
||||
'watchTogether.makeHost' => 'Make host',
|
||||
'watchTogether.makeHostQuestion' => 'Transfer host?',
|
||||
'watchTogether.makeHostConfirm' => ({required Object name}) => '${name} will control playback and drive the session for everyone.',
|
||||
'watchTogether.transfer' => 'Transfer',
|
||||
'watchTogether.hostChangedTo' => ({required Object name}) => '${name} is now the host',
|
||||
'watchTogether.youAreNowHost' => 'You are now the host',
|
||||
'watchTogether.hostTransferFailed' => ({required Object name}) => 'Couldn\'t make ${name} the host',
|
||||
'watchTogether.watchingWithOthers' => 'Watching with others',
|
||||
'watchTogether.endSession' => 'End Session',
|
||||
'watchTogether.leaveSession' => 'Leave Session',
|
||||
@@ -8443,6 +8487,8 @@ extension on Translations {
|
||||
'downloads.editSyncRule' => 'Edit sync rule',
|
||||
'downloads.removeSyncRule' => 'Remove sync rule',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.removeListSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"?',
|
||||
'downloads.deleteSyncRuleDownloads' => 'Also delete associated downloads',
|
||||
'downloads.deleteSyncRuleDownloadsDescription' => 'Downloads used by another sync rule or profile will be kept.',
|
||||
@@ -8454,8 +8500,6 @@ extension on Translations {
|
||||
'downloads.syncRuleCleanupUnavailable' => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
|
||||
'downloads.activeSyncRules' => 'Sync rules',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.noSyncRules' => 'No sync rules',
|
||||
'downloads.manageSyncRule' => 'Manage sync',
|
||||
'downloads.editEpisodeCount' => 'Episode count',
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standardkvalitet",
|
||||
"cellularQualityTitle": "Standardkvalitet på mobildata",
|
||||
"cellularQualitySameAsDefault": "Samma som standardkvalitet",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musikkvalitet",
|
||||
"subtitleStyling": "Utseende för undertexter",
|
||||
"subtitleStylingDescription": "Anpassa undertexternas utseende",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Svep uppåt eller nedåt i högerkanten för att justera volymen",
|
||||
"gesturePinchToZoom": "Nyp för att zooma",
|
||||
"gesturePinchToZoomDescription": "Nyp på videon för att zooma in eller ut",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Kontroller",
|
||||
"rememberPlayerChanges": "Kom ihåg spelarändringar",
|
||||
"rememberPlayerChangesDescription": "Var en ändring under uppspelning sparas och tillämpas igen",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Värd",
|
||||
"hostBadge": "VÄRD",
|
||||
"youAreHost": "Du är värden",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Tittar med andra",
|
||||
"endSession": "Avsluta session",
|
||||
"leaveSession": "Lämna session",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Varsayılan Kalite",
|
||||
"cellularQualityTitle": "Hücresel Veride Varsayılan Kalite",
|
||||
"cellularQualitySameAsDefault": "Varsayılan Kaliteyle Aynı",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Müzik Kalitesi",
|
||||
"subtitleStyling": "Altyazı Biçimlendirmesi",
|
||||
"subtitleStylingDescription": "Altyazı görünümünü özelleştirin",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Sesi ayarlamak için sağ kenarda yukarı veya aşağı kaydırın",
|
||||
"gesturePinchToZoom": "Kıstırarak Yakınlaştır",
|
||||
"gesturePinchToZoomDescription": "Yakınlaştırmak veya uzaklaştırmak için videoyu kıstırın",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Kontroller",
|
||||
"rememberPlayerChanges": "Oynatıcı değişikliklerini hatırla",
|
||||
"rememberPlayerChangesDescription": "Oynatma sırasında yapılan değişikliklerin kaydedilip yeniden uygulanacağı yer",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Kurucu",
|
||||
"hostBadge": "KURUCU",
|
||||
"youAreHost": "Kurucu sizsiniz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Başkalarıyla birlikte izleniyor",
|
||||
"endSession": "Oturumu Bitir",
|
||||
"leaveSession": "Oturumdan Ayrıl",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "Standart sifat",
|
||||
"cellularQualityTitle": "Mobil tarmoqda standart sifat",
|
||||
"cellularQualitySameAsDefault": "Standart sifat bilan bir xil",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "Musiqa sifati",
|
||||
"subtitleStyling": "Subtitr sozlamalari",
|
||||
"subtitleStylingDescription": "Subtitrlar koʻrinishini moslashtiring",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "Ovozni sozlash uchun oʻng chekkada yuqoriga yoki pastga suring",
|
||||
"gesturePinchToZoom": "Chimchilab masshtablash",
|
||||
"gesturePinchToZoomDescription": "Videoni yaqinlashtirish yoki uzoqlashtirish uchun chimchilang",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "Boshqaruv elementlari",
|
||||
"rememberPlayerChanges": "Pleyer oʻzgarishlarini eslab qolish",
|
||||
"rememberPlayerChangesDescription": "Ijro vaqtida qilingan oʻzgarish qayerda saqlanishi va qayta qoʻllanishi",
|
||||
@@ -1585,6 +1589,13 @@
|
||||
"host": "Tashkilotchi",
|
||||
"hostBadge": "TASHKILOTCHI",
|
||||
"youAreHost": "Siz tashkilotchisiz",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "Boshqalar bilan tomosha qilinmoqda",
|
||||
"endSession": "Seansni yakunlash",
|
||||
"leaveSession": "Seansdan chiqish",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "預設畫質",
|
||||
"cellularQualityTitle": "行動網路上的預設畫質",
|
||||
"cellularQualitySameAsDefault": "與預設畫質相同",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "音樂品質",
|
||||
"subtitleStyling": "字幕樣式",
|
||||
"subtitleStylingDescription": "調整字幕外觀",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "在螢幕右側邊緣上下滑動以調整音量",
|
||||
"gesturePinchToZoom": "雙指縮放",
|
||||
"gesturePinchToZoomDescription": "在影片上雙指捏合以放大或縮小",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "控制",
|
||||
"rememberPlayerChanges": "記住播放器變更",
|
||||
"rememberPlayerChangesDescription": "播放期間所做的變更要儲存並從何處重新套用",
|
||||
@@ -1575,6 +1579,13 @@
|
||||
"host": "主持人",
|
||||
"hostBadge": "主持人",
|
||||
"youAreHost": "您是主持人",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "與他人一起觀看",
|
||||
"endSession": "結束工作階段",
|
||||
"leaveSession": "離開工作階段",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"defaultQualityTitle": "默认画质",
|
||||
"cellularQualityTitle": "移动网络默认画质",
|
||||
"cellularQualitySameAsDefault": "与默认画质相同",
|
||||
"directPlayCoveredQuality": "",
|
||||
"directPlayCoveredQualityDescription": "",
|
||||
"musicQualityTitle": "音乐音质",
|
||||
"subtitleStyling": "字幕样式",
|
||||
"subtitleStylingDescription": "调整字幕外观",
|
||||
@@ -388,6 +390,8 @@
|
||||
"gestureVolumeSwipeDescription": "在屏幕右边缘上下滑动以调节音量",
|
||||
"gesturePinchToZoom": "双指缩放",
|
||||
"gesturePinchToZoomDescription": "在视频上双指捏合以放大或缩小",
|
||||
"rememberBrightnessLevel": "",
|
||||
"rememberBrightnessLevelDescription": "",
|
||||
"controls": "控制",
|
||||
"rememberPlayerChanges": "记住播放器更改",
|
||||
"rememberPlayerChangesDescription": "播放期间所做的更改保存并重新应用的位置",
|
||||
@@ -1575,6 +1579,13 @@
|
||||
"host": "主持人",
|
||||
"hostBadge": "主持人",
|
||||
"youAreHost": "你是主持人",
|
||||
"makeHost": "",
|
||||
"makeHostQuestion": "",
|
||||
"makeHostConfirm": "",
|
||||
"transfer": "",
|
||||
"hostChangedTo": "",
|
||||
"youAreNowHost": "",
|
||||
"hostTransferFailed": "",
|
||||
"watchingWithOthers": "与他人一起观看",
|
||||
"endSession": "结束会话",
|
||||
"leaveSession": "离开会话",
|
||||
|
||||
@@ -59,6 +59,7 @@ import 'providers/offline_watch_provider.dart';
|
||||
import 'providers/shader_provider.dart';
|
||||
import 'utils/snackbar_helper.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/library_events/library_event_service.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
import 'services/data_aggregation_service.dart';
|
||||
import 'services/credential_vault.dart';
|
||||
@@ -1229,6 +1230,7 @@ class MainApp extends StatefulWidget {
|
||||
class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
late final MultiServerManager _serverManager;
|
||||
late final DataAggregationService _aggregationService;
|
||||
late final LibraryEventService _libraryEventService;
|
||||
late final AppDatabase _appDatabase;
|
||||
late final DownloadManagerService _downloadManager;
|
||||
late final OfflineWatchSyncService _offlineWatchSyncService;
|
||||
@@ -1270,6 +1272,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
|
||||
_serverManager = MultiServerManager();
|
||||
_aggregationService = DataAggregationService(_serverManager);
|
||||
_libraryEventService = LibraryEventService(_serverManager);
|
||||
_appDatabase = widget.appDatabase;
|
||||
|
||||
PlexApiCache.initialize(_appDatabase);
|
||||
@@ -1319,6 +1322,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
_removeConnectivitySyncListener();
|
||||
_memoryCheckTimer?.cancel();
|
||||
|
||||
_libraryEventService.dispose();
|
||||
_downloadManager.dispose();
|
||||
// Quitting straight from the player is a real stop: the trackers that own
|
||||
// their own watched semantics need the terminal report before the process
|
||||
@@ -1343,6 +1347,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
_memoryCheckTimer?.cancel();
|
||||
_appLifecycleListener.dispose();
|
||||
if (!_shutdownStarted) {
|
||||
_libraryEventService.dispose();
|
||||
_downloadManager.dispose();
|
||||
_serverManager.dispose();
|
||||
}
|
||||
@@ -1509,6 +1514,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// App came back to foreground - trigger sync check
|
||||
_offlineWatchSyncService.onAppResumed();
|
||||
unawaited(TrackerCoordinator.instance.flushWriteQueue());
|
||||
// Re-arm the per-server library push channels torn down on pause
|
||||
// (and any that exhausted their reconnect attempts).
|
||||
_libraryEventService.resume();
|
||||
// Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep.
|
||||
// On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown
|
||||
// to avoid piling up network probes from rapid alt-tabbing.
|
||||
@@ -1527,6 +1535,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
}
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
// Backgrounded: drop the library push sockets — they are
|
||||
// foreground-only, and the stale-resume refresh covers the gap.
|
||||
_libraryEventService.suspend();
|
||||
// Database is session-scoped and must survive suspend/resume.
|
||||
// Closing here would kill the Drift isolate channel while services
|
||||
// (sync, downloads, cache) still hold references to the executor.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'ids.dart';
|
||||
|
||||
/// One coalesced "this server's library content changed" signal from a
|
||||
/// server push channel (#1646).
|
||||
///
|
||||
/// Deliberately coarse for additions and updates: raw notifications arrive
|
||||
/// mid-scan when backends disagree about what an id even means (Plex emits
|
||||
/// per-state timeline floods, MediaBrowser batches server-side), and every
|
||||
/// consumer responds by refetching its own views anyway. The one exact
|
||||
/// carve-out is [removedItemIds]: a removal names an item the app may be
|
||||
/// displaying right now, so those ids ride along for in-place drops through
|
||||
/// `DeletionNotifier` — the same split Plex Web uses (deletions patch stores
|
||||
/// directly; additions go through container refetch).
|
||||
class LibraryChangeEvent {
|
||||
/// Server whose library changed.
|
||||
final ServerId serverId;
|
||||
|
||||
/// Backend-native ids of the affected libraries (Plex section ids,
|
||||
/// MediaBrowser collection-folder ids). Empty when the backend did not say.
|
||||
final Set<String> libraryIds;
|
||||
|
||||
/// Backend-native ids of removed items, when the backend named them (Plex
|
||||
/// settled `type: -1` timeline entries, MediaBrowser `ItemsRemoved`).
|
||||
final Set<String> removedItemIds;
|
||||
|
||||
/// Advisory flags describing what the change contained. A backend that
|
||||
/// cannot distinguish (Plex settles adds and metadata updates identically)
|
||||
/// sets the closest superset.
|
||||
final bool itemsAdded;
|
||||
final bool itemsRemoved;
|
||||
final bool itemsUpdated;
|
||||
|
||||
const LibraryChangeEvent({
|
||||
required this.serverId,
|
||||
this.libraryIds = const {},
|
||||
this.removedItemIds = const {},
|
||||
this.itemsAdded = false,
|
||||
this.itemsRemoved = false,
|
||||
this.itemsUpdated = false,
|
||||
});
|
||||
|
||||
bool get hasChanges => itemsAdded || itemsRemoved || itemsUpdated;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'LibraryChangeEvent($serverId, libraries: $libraryIds, '
|
||||
'added: $itemsAdded, removed: $itemsRemoved (${removedItemIds.length} ids), updated: $itemsUpdated)';
|
||||
}
|
||||
|
||||
/// A live push subscription to one server's library-change notifications.
|
||||
///
|
||||
/// Implementations own the websocket, keepalive, parsing, per-backend
|
||||
/// debouncing, and reconnect backoff; [LibraryEventService] owns *which*
|
||||
/// channels run (server online/offline, app foreground/background). Connect
|
||||
/// failures degrade silently — the stale-refresh paths remain the fallback —
|
||||
/// so a reverse proxy without websocket upgrade support must never surface
|
||||
/// an error to the user.
|
||||
abstract class LibraryEventChannel {
|
||||
/// Coalesced change events. Broadcast; never errors.
|
||||
Stream<LibraryChangeEvent> get events;
|
||||
|
||||
/// Begin connecting (idempotent while running). Failures retry with
|
||||
/// bounded backoff, then go quiet until the next [start].
|
||||
void start();
|
||||
|
||||
/// Drop the connection and all retry state. Safe to call repeatedly;
|
||||
/// [start] may be called again afterwards.
|
||||
void stop();
|
||||
|
||||
/// [stop] plus closing [events]. The channel is unusable afterwards.
|
||||
void dispose();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_program.dart';
|
||||
import '../models/media_grab_operation.dart';
|
||||
import '../models/media_subscription.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
|
||||
/// Program info captured when a live session starts. Plex's tune response
|
||||
/// carries the airing program; Jellyfin streams the channel without a
|
||||
@@ -85,8 +86,11 @@ abstract class LiveTvPlaybackSession {
|
||||
|
||||
/// Re-establish playback after stream death. Plex re-tunes (the previous
|
||||
/// capture session expires while the player exhausts its reconnect
|
||||
/// attempts) applying the degradation flags; Jellyfin returns itself so
|
||||
/// its negotiated HLS URL is re-opened. Returns `null` on failure.
|
||||
/// attempts) applying the degradation flags. Jellyfin re-negotiates a
|
||||
/// forced transcode when a direct-play session is asked to drop
|
||||
/// [directStream] — releasing the direct session's live stream — and
|
||||
/// otherwise returns itself so its negotiated HLS URL is re-opened.
|
||||
/// Returns `null` on failure.
|
||||
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio});
|
||||
}
|
||||
|
||||
@@ -160,9 +164,17 @@ abstract class LiveTvSupport {
|
||||
|
||||
/// Start a playback session for [channelKey] — the single entry the player
|
||||
/// uses for initial launch and channel switching. Plex requires [dvrKey]
|
||||
/// (tune + transcode-session setup); Jellyfin ignores it and negotiates an
|
||||
/// HLS transcode URL. Returns `null` when the channel can't be started.
|
||||
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey});
|
||||
/// (tune + transcode-session setup); Jellyfin ignores it. [quality] is the
|
||||
/// viewer's preset: on `original` Jellyfin asks the server for direct play
|
||||
/// with no bitrate ceiling and falls back to an uncapped transcode, while a
|
||||
/// capped preset forces a transcode at that ceiling. Plex does not consume
|
||||
/// [quality] yet — its live path still hardcodes a transcode (#2072).
|
||||
/// Returns `null` when the channel can't be started.
|
||||
Future<LiveTvPlaybackSession?> startPlayback(
|
||||
String channelKey, {
|
||||
String? dvrKey,
|
||||
TranscodeQualityPreset quality = TranscodeQualityPreset.original,
|
||||
});
|
||||
|
||||
/// Source URI to stamp into [FavoriteChannel] entries. Plex uses
|
||||
/// `server://{machineId}/{providerId}` so its cloud-synced favorites are
|
||||
|
||||
@@ -76,6 +76,22 @@ enum MediaBrowserDialect {
|
||||
MediaBrowserDialect.emby => const [8920, 8096],
|
||||
};
|
||||
|
||||
/// Path of the realtime notification websocket. Same protocol on both
|
||||
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
|
||||
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
|
||||
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
|
||||
String get webSocketPath => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => '/socket',
|
||||
MediaBrowserDialect.emby => '/embywebsocket',
|
||||
};
|
||||
|
||||
/// Emby only routes `LibraryChanged` frames to sessions that registered
|
||||
/// device capabilities. Measured on Emby 4.9.5: a websocket authenticated
|
||||
/// with `api_key` received `RefreshProgress` but no `LibraryChanged` until
|
||||
/// the device POSTed `/Sessions/Capabilities/Full`; Jellyfin 10.11 pushes
|
||||
/// to every authenticated socket without it.
|
||||
bool get requiresSessionCapabilitiesForLibraryEvents => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// `/QuickConnect/*` plus `POST /Users/AuthenticateWithQuickConnect`.
|
||||
bool get supportsQuickConnect => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'artist_discography.dart';
|
||||
import 'download_resolution.dart';
|
||||
import 'ids.dart';
|
||||
import 'library_filter_result.dart';
|
||||
import 'library_change_event.dart';
|
||||
import 'library_first_character.dart';
|
||||
import 'library_query.dart';
|
||||
import 'live_tv_support.dart';
|
||||
@@ -116,6 +117,13 @@ abstract class MediaServerClient {
|
||||
/// Release HTTP resources and any other long-lived state. Idempotent.
|
||||
void close();
|
||||
|
||||
/// Open a fresh push channel for this server's library-change
|
||||
/// notifications, or `null` when the backend has none wired
|
||||
/// ([ServerCapabilities.libraryChangeEvents]). The caller owns the returned
|
||||
/// channel's start/stop/dispose lifecycle — `LibraryEventService` in
|
||||
/// production.
|
||||
LibraryEventChannel? createLibraryEventChannel() => null;
|
||||
|
||||
/// Probe the server with a lightweight auth-required round-trip and
|
||||
/// classify the outcome. Implementations must surface 401/403 as
|
||||
/// [HealthStatus.authError] so the manager can flag a revoked token
|
||||
|
||||
@@ -61,6 +61,14 @@ class ServerCapabilities {
|
||||
/// (`POST /playQueues?type=audio&uri=...station...`).
|
||||
final bool instantMix;
|
||||
|
||||
/// Server pushes library-content change notifications over a websocket the
|
||||
/// app can subscribe to (#1646). Plex: `/:/websockets/notifications`
|
||||
/// timeline entries; Jellyfin/Emby: `LibraryChanged` on the session socket.
|
||||
/// Whether a *specific* server's socket is reachable (reverse proxies may
|
||||
/// not upgrade) is a runtime concern handled by [LibraryEventService]'s
|
||||
/// silent degradation.
|
||||
final bool libraryChangeEvents;
|
||||
|
||||
const ServerCapabilities({
|
||||
this.liveTv = false,
|
||||
this.liveTvDvr = false,
|
||||
@@ -74,6 +82,7 @@ class ServerCapabilities {
|
||||
this.scrubThumbnails = false,
|
||||
this.folderGrouping = false,
|
||||
this.instantMix = false,
|
||||
this.libraryChangeEvents = false,
|
||||
});
|
||||
|
||||
/// Defaults for a fully-featured Plex server.
|
||||
@@ -90,6 +99,7 @@ class ServerCapabilities {
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
instantMix: true,
|
||||
libraryChangeEvents: true,
|
||||
);
|
||||
|
||||
/// Defaults for a Jellyfin server.
|
||||
@@ -113,6 +123,7 @@ class ServerCapabilities {
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
instantMix: true,
|
||||
libraryChangeEvents: true,
|
||||
);
|
||||
|
||||
/// Defaults for an Emby server.
|
||||
@@ -142,6 +153,7 @@ class ServerCapabilities {
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
instantMix: true,
|
||||
libraryChangeEvents: true,
|
||||
);
|
||||
|
||||
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||||
@@ -162,6 +174,7 @@ class ServerCapabilities {
|
||||
scrubThumbnails: scrubThumbnails,
|
||||
folderGrouping: folderGrouping,
|
||||
instantMix: instantMix,
|
||||
libraryChangeEvents: libraryChangeEvents,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,13 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// The results list both screens render: padded, without keep-alives or
|
||||
/// semantic indexes. One child per entry of [searchResults] unless the
|
||||
/// caller renders a filtered view and passes its own [childCount].
|
||||
Widget buildResultsSliver(NullableIndexedWidgetBuilder itemBuilder, {int? childCount}) {
|
||||
Widget buildResultsSliver(
|
||||
NullableIndexedWidgetBuilder itemBuilder, {
|
||||
int? childCount,
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.all(16),
|
||||
}) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: padding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
itemBuilder,
|
||||
|
||||
@@ -203,6 +203,88 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
totalSize = (totalSize - 1).clamp(0, totalSize);
|
||||
}
|
||||
|
||||
/// Refetch the loaded span in place — the sparse-grid equivalent of Plex
|
||||
/// Web's `repopulateRange`. The old items stay rendered while the fetch
|
||||
/// runs; on success the span is replaced wholesale and [totalSize] adopts
|
||||
/// the server's new count, so server-side additions materialize at their
|
||||
/// sorted positions and removals disappear with no clearing and no
|
||||
/// skeleton flash. On failure the old content stays untouched
|
||||
/// (best-effort background refresh; the staleness paths recover).
|
||||
///
|
||||
/// [anchorId] (resolved through [idOf]) reports where a caller-chosen item
|
||||
/// moved, so the caller can compensate the scroll offset and keep it
|
||||
/// visually stationary. A null result means nothing was applied.
|
||||
///
|
||||
/// [maxSpan] bounds the refetched request: a sparse map holding disjoint
|
||||
/// clusters (initial pages plus an alpha-jump target) would otherwise span
|
||||
/// nearly the whole library in one call. When the span exceeds it, entries
|
||||
/// outside a [maxSpan]-wide window centered on [windowCenter] are dropped
|
||||
/// first — they degrade to ordinary unloaded slots the scroll path
|
||||
/// refetches on demand. Callers that cache per-index state (focus nodes)
|
||||
/// evict theirs to the same window.
|
||||
Future<({int? anchorOldIndex, int? anchorNewIndex})?> repopulateLoadedRange({
|
||||
required String Function(T item) idOf,
|
||||
String? anchorId,
|
||||
int? maxSpan,
|
||||
int? windowCenter,
|
||||
}) async {
|
||||
if (!mounted || loadedItems.isEmpty || totalSize == 0) return null;
|
||||
var indices = loadedItems.keys.toList()..sort();
|
||||
if (maxSpan != null && indices.last - indices.first + 1 > maxSpan) {
|
||||
final center = (windowCenter ?? indices.first).clamp(indices.first, indices.last);
|
||||
final lo = center - maxSpan ~/ 2;
|
||||
loadedItems.removeWhere((index, _) => index < lo || index >= lo + maxSpan);
|
||||
if (loadedItems.isEmpty) return null;
|
||||
indices = loadedItems.keys.toList()..sort();
|
||||
}
|
||||
final start = indices.first;
|
||||
final size = indices.last - start + 1;
|
||||
int? anchorOldIndex;
|
||||
if (anchorId != null) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (idOf(entry.value) == anchorId) {
|
||||
anchorOldIndex = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supersede in-flight fetches: their merges would interleave stale pages
|
||||
// into the repopulated span.
|
||||
_requestId++;
|
||||
_cancelToken?.abort();
|
||||
_cancelToken = AbortController();
|
||||
_retryTimer?.cancel();
|
||||
_loadingRanges.clear();
|
||||
_scheduledRetry = null;
|
||||
final generation = _requestId;
|
||||
final LibraryPage<T> page;
|
||||
try {
|
||||
page = await fetchPage(start, size, _cancelToken);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (generation != _requestId || !mounted) return null;
|
||||
int? anchorNewIndex;
|
||||
if (anchorId != null) {
|
||||
for (var i = 0; i < page.items.length; i++) {
|
||||
if (idOf(page.items[i]) == anchorId) {
|
||||
anchorNewIndex = start + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
loadedItems.removeWhere((index, _) => index >= start && index < start + size);
|
||||
for (var i = 0; i < page.items.length; i++) {
|
||||
loadedItems[start + i] = page.items[i];
|
||||
}
|
||||
totalSize = page.totalCount;
|
||||
loadedItems.removeWhere((index, _) => index >= totalSize);
|
||||
});
|
||||
onPageLoaded(start, page.items);
|
||||
return (anchorOldIndex: anchorOldIndex, anchorNewIndex: anchorNewIndex);
|
||||
}
|
||||
|
||||
/// Discard the "fetch in flight" markers. In-flight network requests keep
|
||||
/// running but are no longer considered for dedupe — the next
|
||||
/// [ensureRangeLoaded] / [prefetchAhead] will re-scan the visible range.
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../providers/trackers_provider.dart';
|
||||
import '../providers/watch_state_store.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../screens/main_screen.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
import '../services/api_cache.dart';
|
||||
import '../services/catalog/catalog_library_matcher.dart';
|
||||
import '../services/music/music_playback_service.dart';
|
||||
@@ -232,6 +233,9 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
||||
// stays valid for as long as this provider does.
|
||||
watchStateStore: context.read<WatchStateStore>(),
|
||||
isProfileBinding: () => activeProfile.isBinding,
|
||||
// Defers push-triggered hub refetches while the player is
|
||||
// up — same playback-quiet policy as the resume gate.
|
||||
isRefreshBlocked: () => VideoPlayerScreenState.activeGlobalKey != null,
|
||||
profileId: activeId,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/library_change_event.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
@@ -18,6 +19,8 @@ import '../utils/media_event_keys.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/media_hub_ordering.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../utils/library_content_notifier.dart';
|
||||
import '../utils/refresh_pacer.dart';
|
||||
import 'hidden_libraries_provider.dart';
|
||||
import 'libraries_provider.dart';
|
||||
import 'multi_server_provider.dart';
|
||||
@@ -61,8 +64,11 @@ DiscoverRefreshOutcome _refreshOutcome({
|
||||
/// zero hub refetches), playback progress patches the visible row in place,
|
||||
/// deletions drop the item from every visible list in place and then refresh
|
||||
/// only Continue Watching, hidden-library changes trigger a full reload,
|
||||
/// library-order changes re-sort hubs in place without refetching, and the
|
||||
/// platform launcher shelf syncs from every on-deck update.
|
||||
/// library-order changes re-sort hubs in place without refetching, the
|
||||
/// platform launcher shelf syncs from every on-deck update, a tab-shown or
|
||||
/// app-resume older than [staleAfter] runs a full pass, and a server push
|
||||
/// event ([LibraryContentNotifier]) runs a debounced full pass so new
|
||||
/// server-side media surfaces while the app is open (#1646).
|
||||
///
|
||||
/// Lives inside the profile-keyed provider subtree, so a profile switch
|
||||
/// resets it by construction. The screen is a consumer: it renders this
|
||||
@@ -74,6 +80,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
static const int continueWatchingPreviewLimit = 20;
|
||||
static const int _continueWatchingProbeLimit = continueWatchingPreviewLimit + 1;
|
||||
|
||||
/// Home hubs refetch when the tab is shown or the app resumes after this
|
||||
/// long. New media added server-side is otherwise invisible until a restart,
|
||||
/// because nothing pushes library changes to the client (#1646).
|
||||
static const Duration staleAfter = Duration(minutes: 15);
|
||||
|
||||
DiscoverProvider(
|
||||
this._multiServer,
|
||||
this._hiddenLibraries,
|
||||
@@ -81,14 +92,28 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
required this.profileId,
|
||||
required this.isProfileBinding,
|
||||
WatchStateStore? watchStateStore,
|
||||
DateTime Function()? now,
|
||||
bool Function()? isRefreshBlocked,
|
||||
this.libraryEventDebounce = const Duration(seconds: 3),
|
||||
this.libraryEventBlockedRetry = const Duration(seconds: 15),
|
||||
this.libraryEventCooldown = const Duration(minutes: 2),
|
||||
Future<void> Function(String profileId, List<MediaItem>)? syncSystemShelf,
|
||||
Future<void> Function(String profileId, List<MediaServerClient> clients)? syncServerSources,
|
||||
// A private field cannot be a named initializing formal callers can pass.
|
||||
// ignore: prefer_initializing_formals
|
||||
}) : _watchStateStore = watchStateStore,
|
||||
_now = now ?? DateTime.now,
|
||||
_isRefreshBlocked = isRefreshBlocked ?? _neverBlocked,
|
||||
_syncSystemShelfOverride = syncSystemShelf,
|
||||
_syncServerSourcesOverride = syncServerSources {
|
||||
_loadCoordinator = CoalescedLoadCoordinator<String>(onFull: _loadOnce, onDelta: _loadDeltaOnce);
|
||||
_libraryEventPacer = RefreshPacer(
|
||||
debounce: libraryEventDebounce,
|
||||
cooldown: libraryEventCooldown,
|
||||
blockedRetry: libraryEventBlockedRetry,
|
||||
isBlocked: _isRefreshBlocked,
|
||||
runPass: _runLibraryEventRefresh,
|
||||
);
|
||||
// Late server connects (reconnect after outage, slow wave) refresh
|
||||
// discover the same way they refresh libraries. Removed in [dispose] so a
|
||||
// profile switch can't leave a stale listener on the app-global provider.
|
||||
@@ -112,12 +137,38 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
itemIds: () => _deletionIds,
|
||||
onEvent: _onDeletion,
|
||||
);
|
||||
_libraryEventSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged);
|
||||
}
|
||||
|
||||
final MultiServerProvider _multiServer;
|
||||
final HiddenLibrariesProvider _hiddenLibraries;
|
||||
final LibrariesProvider _libraries;
|
||||
|
||||
/// Injectable clock for the [staleAfter] check; tests advance it instead of
|
||||
/// sleeping — same seam as `ContentRefreshResumeGate`.
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// Server push arrived (#1646): defers while [_isRefreshBlocked] (active
|
||||
/// video playback — the playback path must stay quiet) and merges bursts
|
||||
/// from several servers into one coalesced full pass.
|
||||
final bool Function() _isRefreshBlocked;
|
||||
|
||||
/// Debounce for merging push bursts; blocked-retry interval while playback
|
||||
/// holds the refresh; cooldown bounding pass frequency while a server keeps
|
||||
/// emitting (a bulk import batches `LibraryChanged` every ~30 s for its
|
||||
/// whole duration — without the cooldown that is a full fan-out per batch).
|
||||
/// All injectable so tests drive them with short real waits. A committed
|
||||
/// pull pass credits the cooldown through [RefreshPacer.notePass], so a
|
||||
/// push landing just after a stale-resume refresh defers instead of
|
||||
/// fanning out an identical load.
|
||||
final Duration libraryEventDebounce;
|
||||
final Duration libraryEventBlockedRetry;
|
||||
final Duration libraryEventCooldown;
|
||||
|
||||
static bool _neverBlocked() => false;
|
||||
StreamSubscription<LibraryChangeEvent>? _libraryEventSubscription;
|
||||
late final RefreshPacer _libraryEventPacer;
|
||||
|
||||
/// Authoritative fetches tell the store which items the server re-observed,
|
||||
/// so a stale local watch patch stops overriding fresh server state (#1829).
|
||||
/// Optional: tests and isolated subtrees may have no store.
|
||||
@@ -194,6 +245,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// Online servers whose home-hub legs all succeeded in the current hub list.
|
||||
Set<String> _loadedHubServerIds = {};
|
||||
|
||||
/// When the last full pass committed a fresh hub list. Left untouched by
|
||||
/// kept-previous-hubs failures, rollbacks, and delta merges, so a pass that
|
||||
/// did not actually replace the hubs stays stale and is retried.
|
||||
DateTime? _hubsLoadedAt;
|
||||
|
||||
Set<String> get _fullyLoadedServerIds => _loadedOnDeckServerIds.intersection(_loadedHubServerIds);
|
||||
|
||||
late final CoalescedLoadCoordinator<String> _loadCoordinator;
|
||||
@@ -213,9 +269,12 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
bool get areHubsLoading => _hubsState == DiscoverLoadState.initial || _hubsState == DiscoverLoadState.loading;
|
||||
|
||||
/// Bumped each time a [load] pass replaces the on-deck list. The screen
|
||||
/// uses this to distinguish "full reload — reset the hero carousel" from
|
||||
/// a background Continue Watching refresh (clamp only).
|
||||
/// Bumped when a full pass lands *first* content — the initial load, or a
|
||||
/// recovery from an empty/error state. The screen resets the hero carousel
|
||||
/// and takes initial focus only on a bump; every other committed pass
|
||||
/// (background full pass, delta merge, Continue Watching refresh) swaps the
|
||||
/// data in place and the hero clamps instead of resetting, so a server push
|
||||
/// or stale-resume refetch never yanks the viewer's position (#1646).
|
||||
int get loadGeneration => _loadGeneration;
|
||||
|
||||
/// Refresh when a server comes online *mid-session* (reconnect, late wave) —
|
||||
@@ -258,6 +317,43 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// started in `initState`.
|
||||
bool get isLoadInFlight => _loadCoordinator.isBusy;
|
||||
|
||||
/// Starts a full reload when the committed hub list is older than
|
||||
/// [staleAfter]. Returns true while a full pass is running, queued, or was
|
||||
/// started here, so callers can skip a Continue Watching-only refresh the
|
||||
/// full pass already covers. A busy *delta* pass (online-server merge) does
|
||||
/// not count: it never refetches already-loaded servers' hubs, so stale
|
||||
/// hubs still queue the full pass behind it. A never-committed hub list is
|
||||
/// not stale: initial load, error retry, and reconnect are owned by
|
||||
/// `initState`, `primeRefresh`, and the online-servers listener.
|
||||
bool refreshIfStale() {
|
||||
if (isDisposed) return false;
|
||||
if (_loadCoordinator.isFullActive) return true;
|
||||
final loadedAt = _hubsLoadedAt;
|
||||
if (loadedAt == null || _now().difference(loadedAt) <= staleAfter) return false;
|
||||
unawaited(load());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A server pushed a library-content change (#1646). Pacing lives in
|
||||
/// [_libraryEventPacer]: bursts — several servers scanning at once, or a
|
||||
/// channel re-emitting — collapse into one debounced full pass; an active
|
||||
/// video playback defers the pass entirely (retried on a timer) so the
|
||||
/// playback path stays quiet; and passes run at most once per
|
||||
/// [libraryEventCooldown] so a long import updates the home screen
|
||||
/// periodically instead of continuously. The first event after a quiet
|
||||
/// period always refreshes promptly.
|
||||
void _onLibraryContentChanged(LibraryChangeEvent event) {
|
||||
if (isDisposed || !event.hasChanges) return;
|
||||
_libraryEventPacer.schedule();
|
||||
}
|
||||
|
||||
bool _runLibraryEventRefresh() {
|
||||
if (isDisposed) return false;
|
||||
appLogger.d('DiscoverProvider: refreshing after server library change');
|
||||
unawaited(load());
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _loadOnce() async {
|
||||
var outcome = DiscoverRefreshOutcome.cancelled;
|
||||
var passClearedExceptionBoundary = false;
|
||||
@@ -297,7 +393,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
cancelledServerIds: cancelledServerIds,
|
||||
cancelled: isProfileBinding(),
|
||||
);
|
||||
if (replacedOnDeck && outcome != DiscoverRefreshOutcome.failed && outcome != DiscoverRefreshOutcome.cancelled) {
|
||||
if (replacedOnDeck &&
|
||||
previousOnDeck.isEmpty &&
|
||||
_onDeck.isNotEmpty &&
|
||||
outcome != DiscoverRefreshOutcome.failed &&
|
||||
outcome != DiscoverRefreshOutcome.cancelled) {
|
||||
++_loadGeneration;
|
||||
}
|
||||
passClearedExceptionBoundary = true;
|
||||
@@ -431,6 +531,10 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
fetchedHubs.failedServerIds,
|
||||
fetchedHubs.cancelledServerIds,
|
||||
);
|
||||
_hubsLoadedAt = _now();
|
||||
// Credit this committed pass so a push event landing moments later
|
||||
// defers to the cooldown's trailing edge instead of refetching.
|
||||
_libraryEventPacer.notePass();
|
||||
settlePassOutcome();
|
||||
safeNotifyListeners();
|
||||
} catch (e) {
|
||||
@@ -856,8 +960,12 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// server item in place, so it must not evict anything here.
|
||||
if (event.isDownloadOnly) return;
|
||||
|
||||
// Scoped to the emitting server: backend-native ids (Plex rating keys are
|
||||
// small sequential integers) routinely collide across servers, and push
|
||||
// removals made this an automated event (#1646).
|
||||
bool affected(MediaItem item) =>
|
||||
item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId;
|
||||
item.serverId == event.serverId.value &&
|
||||
(item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId);
|
||||
|
||||
var changed = false;
|
||||
final remainingOnDeck = _onDeck.where((item) => !affected(item)).toList();
|
||||
@@ -985,6 +1093,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_watchStateSubscription = null;
|
||||
_deletionSubscription?.cancel();
|
||||
_deletionSubscription = null;
|
||||
_libraryEventSubscription?.cancel();
|
||||
_libraryEventSubscription = null;
|
||||
_libraryEventPacer.dispose();
|
||||
_loadCoordinator.dispose();
|
||||
_pendingSystemShelfItems = null;
|
||||
super.dispose();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../media/library_change_event.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/library_content_notifier.dart';
|
||||
import '../utils/coalesced_load_coordinator.dart';
|
||||
import 'multi_server_provider.dart';
|
||||
|
||||
@@ -25,6 +29,9 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
// or restart. Removed in [dispose] so a profile switch can't leave a
|
||||
// stale listener on the app-global provider.
|
||||
_multiServer?.addOnlineServersListener(syncToOnlineServers);
|
||||
// Server push events mark affected libraries stale; tabs consume the
|
||||
// epoch when they are next shown. Cancelled in [dispose].
|
||||
_libraryEventSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged);
|
||||
}
|
||||
|
||||
static bool _neverBinding() => false;
|
||||
@@ -74,6 +81,46 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
/// Whether libraries are available
|
||||
bool get hasLibraries => _libraries.isNotEmpty;
|
||||
|
||||
/// Per-library content epochs, bumped when a server push reports that a
|
||||
/// library's content changed (#1646). A tab records the epoch it loaded
|
||||
/// under and reloads when it is next shown with a newer one — staleness is
|
||||
/// consumed lazily, so a push never live-reloads a grid the user is
|
||||
/// scrolled into. Deliberately does not notify listeners: bumping is
|
||||
/// bookkeeping, not a UI change.
|
||||
final Map<String, int> _contentEpochByGlobalKey = {};
|
||||
StreamSubscription<LibraryChangeEvent>? _libraryEventSubscription;
|
||||
|
||||
/// The current content epoch for the library with [globalKey]; 0 until a
|
||||
/// push event first marks it.
|
||||
int libraryContentEpoch(String globalKey) => _contentEpochByGlobalKey[globalKey] ?? 0;
|
||||
|
||||
void _onLibraryContentChanged(LibraryChangeEvent event) {
|
||||
if (isDisposed || !event.hasChanges) return;
|
||||
for (final library in _libraries) {
|
||||
if (!eventTargetsLibrary(event, library)) continue;
|
||||
_contentEpochByGlobalKey[library.globalKey] = (_contentEpochByGlobalKey[library.globalKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Single matcher for push events, shared with the visible tab's live pass
|
||||
/// so epoch marking and live refreshes always agree (#1646). An event with
|
||||
/// no library ids targets the whole server; ids that match no loaded
|
||||
/// library on that server (a brand-new library, or a backend id the event
|
||||
/// names differently) fall back to the whole server rather than nothing.
|
||||
bool eventTargetsLibrary(LibraryChangeEvent event, MediaLibrary library) {
|
||||
if (library.serverId == null || library.serverId != event.serverId.value) return false;
|
||||
if (event.libraryIds.isEmpty) return true;
|
||||
if (event.libraryIds.contains(library.id)) return true;
|
||||
return !_serverHasLibraryIn(event.serverId.value, event.libraryIds);
|
||||
}
|
||||
|
||||
bool _serverHasLibraryIn(String serverId, Set<String> libraryIds) {
|
||||
for (final library in _libraries) {
|
||||
if (library.serverId == serverId && libraryIds.contains(library.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Derived lookups, keyed on the identity of [_libraries]: every mutation
|
||||
/// reassigns the list, so an identical source means the maps are current.
|
||||
List<MediaLibrary>? _lookupSource;
|
||||
@@ -366,6 +413,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
@override
|
||||
void dispose() {
|
||||
_multiServer?.removeOnlineServersListener(syncToOnlineServers);
|
||||
_libraryEventSubscription?.cancel();
|
||||
_libraryEventSubscription = null;
|
||||
_loadCoordinator.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/tone_mapped_logo_image.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'libraries/content_state_builder.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
@@ -442,9 +443,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Restart auto-scroll only if discover tab is visible
|
||||
if (_isTabVisible && !_isAutoScrollPaused) _startAutoScroll();
|
||||
// Stale hubs refetch on every resume — cheap timestamp check, and a
|
||||
// desktop window-focus gain after hours away should refresh too (#1646).
|
||||
final startedFullPass = _discover.refreshIfStale();
|
||||
// Refresh continue watching on mobile only
|
||||
// (on desktop, "resumed" fires on every window focus gain)
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
if (!startedFullPass && (Platform.isIOS || Platform.isAndroid)) {
|
||||
unawaited(_discover.refreshContinueWatching());
|
||||
}
|
||||
} else if (state == AppLifecycleState.inactive || state == AppLifecycleState.hidden) {
|
||||
@@ -536,6 +540,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
@override
|
||||
void onTabShown() {
|
||||
_isTabVisible = true;
|
||||
_discover.refreshIfStale();
|
||||
if (!_isAutoScrollPaused) {
|
||||
_startAutoScroll();
|
||||
}
|
||||
@@ -588,7 +593,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Public method to refresh content (for normal navigation)
|
||||
@override
|
||||
void refresh() {
|
||||
// Only refresh Continue Watching in background, not full screen reload
|
||||
// A stale-resume refresh must also refetch the home hubs; otherwise new
|
||||
// server-side media never appears until a restart (#1646). When fresh,
|
||||
// only Continue Watching refetches — one on-deck call, zero hub calls.
|
||||
if (_discover.refreshIfStale()) return;
|
||||
unawaited(_discover.refreshContinueWatching());
|
||||
}
|
||||
|
||||
@@ -1386,6 +1394,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
width: heroLogoWidth,
|
||||
height: heroLogoHeight,
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
// The hero scrim washes artwork toward the scaffold
|
||||
// background; light themes recolor light-toned logos.
|
||||
logoToneTarget: logoToneTargetFor(
|
||||
surface: theme.scaffoldBackgroundColor,
|
||||
foreground: colorScheme.onSurface,
|
||||
),
|
||||
fallbackBuilder: (context) => FittingTitleText(
|
||||
showName,
|
||||
style: heroTitleStyle,
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../focus/focusable_action_bar.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../mixins/tab_navigation_mixin.dart';
|
||||
import '../../mixins/tab_visibility_aware.dart';
|
||||
import '../../media/ids.dart';
|
||||
import '../../media/media_library.dart';
|
||||
import '../../providers/hidden_libraries_provider.dart';
|
||||
@@ -32,6 +33,7 @@ import 'tabs/library_browse_tab.dart';
|
||||
import 'tabs/library_recommended_tab.dart';
|
||||
import 'tabs/library_collections_tab.dart';
|
||||
import 'tabs/library_playlists_tab.dart';
|
||||
import 'tabs/base_library_tab.dart';
|
||||
|
||||
enum LibraryTabType { recommended, browse, collections, playlists }
|
||||
|
||||
@@ -51,7 +53,14 @@ class LibrariesScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, TickerProviderStateMixin, TabNavigationMixin {
|
||||
with
|
||||
Refreshable,
|
||||
FullRefreshable,
|
||||
FocusableTab,
|
||||
LibraryLoadable,
|
||||
TickerProviderStateMixin,
|
||||
TabNavigationMixin,
|
||||
TabVisibilityAware {
|
||||
final _recommendedTabKey = GlobalKey();
|
||||
final _browseTabKey = GlobalKey();
|
||||
final _collectionsTabKey = GlobalKey();
|
||||
@@ -485,6 +494,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
_refreshSelectedLibraryTabs();
|
||||
}
|
||||
|
||||
/// Returning to the Libraries tab consumes push-marked staleness (#1646)
|
||||
/// for the visible library tab only; hidden tabs consume on their own
|
||||
/// activation, and a library switch reloads unconditionally anyway.
|
||||
@override
|
||||
void onTabShown() {
|
||||
if (_selectedLibraryGlobalKey == null) return;
|
||||
final Object? tabState = _getTabState(tabController.index);
|
||||
if (tabState is BaseLibraryTabState) tabState.refreshIfLibraryContentStale();
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: no-empty-block - visibility mixin contract; nothing to pause.
|
||||
void onTabHidden() {}
|
||||
|
||||
// Refresh every loaded tab for the selected library.
|
||||
void _refreshSelectedLibraryTabs() {
|
||||
for (var i = 0; i < _visibleTabs.length; i++) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../focus/input_mode_tracker.dart';
|
||||
import '../../../media/library_change_event.dart';
|
||||
import '../../../media/media_library.dart';
|
||||
import '../../../utils/error_message_utils.dart';
|
||||
import '../../../mixins/library_tab_state.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/library_content_notifier.dart';
|
||||
import '../../../utils/refresh_pacer.dart';
|
||||
import '../../../providers/libraries_provider.dart';
|
||||
import '../../../mixins/refreshable.dart';
|
||||
import '../../video_player_screen.dart';
|
||||
import '../content_state_builder.dart';
|
||||
|
||||
/// Base class for library tab screens that provides common state management
|
||||
@@ -82,6 +89,17 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
String? _errorMessage;
|
||||
StreamSubscription<void>? _refreshSubscription;
|
||||
int _loadGeneration = 0;
|
||||
StreamSubscription<LibraryChangeEvent>? _liveLibrarySubscription;
|
||||
late final RefreshPacer _livePacer;
|
||||
bool _inPlaceReload = false;
|
||||
|
||||
/// The library content epoch (see [LibrariesProvider.libraryContentEpoch])
|
||||
/// this tab's data was loaded under.
|
||||
int _loadedLibraryContentEpoch = 0;
|
||||
|
||||
/// The provider epoch captured when the current load started, so a push
|
||||
/// landing mid-fetch is not marked consumed by data that predates it.
|
||||
int _libraryContentEpochAtLoadStart = 0;
|
||||
|
||||
// Focus management
|
||||
bool _hasLoadedData = false;
|
||||
@@ -109,7 +127,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
int get libraryLoadGeneration => _loadGeneration;
|
||||
|
||||
@protected
|
||||
int beginLibraryLoad() => ++_loadGeneration;
|
||||
int beginLibraryLoad() {
|
||||
snapshotLibraryContentEpoch();
|
||||
return ++_loadGeneration;
|
||||
}
|
||||
|
||||
@protected
|
||||
void invalidateLibraryLoad() {
|
||||
@@ -135,12 +156,26 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Live push refresh (#1646): a server library change targeting this
|
||||
// library swaps the visible tab's data in place. Hidden tabs skip it —
|
||||
// their activation staleness check owns the reload.
|
||||
_livePacer = RefreshPacer(
|
||||
debounce: liveRefreshDebounce,
|
||||
cooldown: liveRefreshCooldown,
|
||||
blockedRetry: liveRefreshBlockedRetry,
|
||||
isBlocked: () => isLiveRefreshBlocked || VideoPlayerScreenState.activeGlobalKey != null,
|
||||
runPass: _runLiveRefresh,
|
||||
);
|
||||
_liveLibrarySubscription = LibraryContentNotifier().stream.listen(_onLiveLibraryChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
invalidateLibraryLoad();
|
||||
_refreshSubscription?.cancel();
|
||||
_liveLibrarySubscription?.cancel();
|
||||
_livePacer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -159,6 +194,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
loadItems();
|
||||
} else if (widget.isActive && !oldWidget.isActive) {
|
||||
// Became the visible tab again: consume push-marked staleness (#1646).
|
||||
// The library-changed branch above already reloads unconditionally.
|
||||
refreshIfLibraryContentStale();
|
||||
}
|
||||
|
||||
// Check if we should focus (became active after data loaded)
|
||||
@@ -167,6 +206,115 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests and isolated subtrees may pump a tab without a [LibrariesProvider].
|
||||
LibrariesProvider? _librariesProviderOrNull() {
|
||||
try {
|
||||
return context.read<LibrariesProvider>();
|
||||
} on ProviderNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the provider's current epoch at load start. [beginLibraryLoad]
|
||||
/// does this for transaction-based tabs; paginated pipelines that bypass it
|
||||
/// call this at the top of their load instead.
|
||||
@protected
|
||||
void snapshotLibraryContentEpoch() {
|
||||
final provider = _librariesProviderOrNull();
|
||||
if (provider != null) {
|
||||
_libraryContentEpochAtLoadStart = provider.libraryContentEpoch(widget.library.globalKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the load-start epoch snapshot consumed alongside a successful
|
||||
/// commit, so [refreshIfLibraryContentStale] can tell whether a server push
|
||||
/// has marked the library since. Deliberately the snapshot, not the current
|
||||
/// epoch: a push that landed while the fetch was in flight stays stale.
|
||||
/// Subclasses with their own load pipeline call this after committing
|
||||
/// fresh data.
|
||||
@protected
|
||||
void recordLibraryContentEpoch() {
|
||||
_loadedLibraryContentEpoch = _libraryContentEpochAtLoadStart;
|
||||
// A committed load is as good as a live pass: defer a push event landing
|
||||
// moments later to the cooldown's trailing edge.
|
||||
_livePacer.notePass();
|
||||
}
|
||||
|
||||
/// Reload once when a server push marked this library's content stale since
|
||||
/// the last load (#1646). Called when the tab is next shown — never live —
|
||||
/// so scroll position and focus are undisturbed while the user is on it.
|
||||
void refreshIfLibraryContentStale() {
|
||||
if (!mounted) return;
|
||||
final provider = _librariesProviderOrNull();
|
||||
if (provider == null) return;
|
||||
final epoch = provider.libraryContentEpoch(widget.library.globalKey);
|
||||
if (epoch == _loadedLibraryContentEpoch) return;
|
||||
_loadedLibraryContentEpoch = epoch;
|
||||
_libraryContentEpochAtLoadStart = epoch;
|
||||
loadItems();
|
||||
}
|
||||
|
||||
/// Pacing for push-driven live refreshes, mirroring [DiscoverProvider]:
|
||||
/// merge event bursts, bound the pass frequency during bulk imports, and
|
||||
/// retry while [isLiveRefreshBlocked]. Overridable so tests use short
|
||||
/// real waits.
|
||||
@protected
|
||||
Duration get liveRefreshDebounce => const Duration(seconds: 3);
|
||||
@protected
|
||||
Duration get liveRefreshCooldown => const Duration(minutes: 2);
|
||||
@protected
|
||||
Duration get liveRefreshBlockedRetry => const Duration(seconds: 15);
|
||||
|
||||
/// True while a live in-place refresh would disturb the user (subclasses
|
||||
/// add scrolling/jump state). Playback is checked separately.
|
||||
@protected
|
||||
bool get isLiveRefreshBlocked => false;
|
||||
|
||||
/// One matcher with the provider's epoch marking so live passes and
|
||||
/// activation staleness always agree; the local fallback only serves
|
||||
/// harnesses pumped without a [LibrariesProvider].
|
||||
bool _eventTargetsLibrary(LibraryChangeEvent event) {
|
||||
final provider = _librariesProviderOrNull();
|
||||
if (provider != null) return provider.eventTargetsLibrary(event, widget.library);
|
||||
final serverId = widget.library.serverId;
|
||||
if (serverId == null || event.serverId != serverId) return false;
|
||||
return event.libraryIds.isEmpty || event.libraryIds.contains(widget.library.id);
|
||||
}
|
||||
|
||||
void _onLiveLibraryChange(LibraryChangeEvent event) {
|
||||
if (!mounted || !event.hasChanges || !_eventTargetsLibrary(event)) return;
|
||||
// Only the active tab live-updates; hidden siblings reload on activation
|
||||
// via [refreshIfLibraryContentStale] instead of fetching invisibly.
|
||||
if (!_isLiveSurfaceVisible) return;
|
||||
_livePacer.schedule();
|
||||
}
|
||||
|
||||
/// Active within the Libraries screen *and* the screen itself is the
|
||||
/// visible main tab: [TickerMode] is how MainScreen mutes hidden tab
|
||||
/// subtrees, so a grid behind Discover neither fetches nor jumps its
|
||||
/// scroll offset invisibly. No ancestor (tests, other hosts) means
|
||||
/// visible.
|
||||
bool get _isLiveSurfaceVisible => widget.isActive && TickerMode.getValuesNotifier(context).value.enabled;
|
||||
|
||||
bool _runLiveRefresh() {
|
||||
if (!mounted || !_isLiveSurfaceVisible) return false;
|
||||
unawaited(performLiveLibraryRefresh());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Swap this tab's data in place after a server push. The default reloads
|
||||
/// through [runLoadTransaction] without clearing, so the old content stays
|
||||
/// on screen until the fresh list lands (no spinner, no scroll reset, no
|
||||
/// focus churn); a failure keeps the old content silently. Only valid for
|
||||
/// tabs whose [loadItems] delegates to [runLoadTransaction] — paginated
|
||||
/// pipelines (browse, card grids) override with a sparse repopulate,
|
||||
/// because their clearing load would blank the visible grid.
|
||||
@protected
|
||||
Future<void> performLiveLibraryRefresh() {
|
||||
_inPlaceReload = true;
|
||||
return loadItems();
|
||||
}
|
||||
|
||||
/// Load the tab's items. Typically delegates to [runLoadTransaction].
|
||||
Future<void> loadItems();
|
||||
|
||||
@@ -228,6 +376,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
@protected
|
||||
void markItemsLoaded() {
|
||||
_hasLoadedData = true;
|
||||
recordLibraryContentEpoch();
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -261,17 +410,25 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
|
||||
/// Shared load transaction: generation tracking, localized error mapping,
|
||||
/// and post-frame [BaseLibraryTab.onDataLoaded] notification.
|
||||
///
|
||||
/// When entered through [performLiveLibraryRefresh] the pass is in-place:
|
||||
/// the old items stay rendered while the fetch runs (no spinner), and a
|
||||
/// failure keeps them silently instead of swapping in an error state.
|
||||
@protected
|
||||
Future<void> runLoadTransaction(Future<List<T>> Function() fetch) async {
|
||||
if (!mounted) return;
|
||||
final inPlace = _inPlaceReload && _items.isNotEmpty;
|
||||
_inPlaceReload = false;
|
||||
final loadGeneration = beginLibraryLoad();
|
||||
final libraryGlobalKey = widget.library.globalKey;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_items = []; // Clear items to prevent showing stale data during load
|
||||
});
|
||||
if (!inPlace) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_items = []; // Clear items to prevent showing stale data during load
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final loadedItems = await fetch();
|
||||
@@ -282,7 +439,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
_isLoading = false;
|
||||
_hasLoadedData = true;
|
||||
});
|
||||
|
||||
recordLibraryContentEpoch();
|
||||
tryFocus();
|
||||
|
||||
final onDataLoaded = widget.onDataLoaded;
|
||||
@@ -295,6 +452,11 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
||||
if (inPlace) {
|
||||
// Best-effort background refresh: keep the visible content.
|
||||
appLogger.d('Live library refresh failed for ${widget.library.globalKey}', error: e);
|
||||
return;
|
||||
}
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
|
||||
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
||||
|
||||
|
||||
@@ -198,6 +198,64 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
/// Live in-place repopulation while a scroll/jump is running would fight
|
||||
/// the user; the blocked-retry timer picks it up once the grid is idle.
|
||||
@override
|
||||
bool get isLiveRefreshBlocked => _isJumpScrolling || (_innerPosition?.isScrollingNotifier.value ?? false);
|
||||
|
||||
/// Server push while this grid is visible: refetch the loaded span in
|
||||
/// place (Plex Web's `repopulateRange`) so new items materialize at their
|
||||
/// sorted positions and metadata updates land, then keep the first visible
|
||||
/// item stationary by compensating the scroll offset for any index shift
|
||||
/// the merge caused. Folder grouping browses a tree, not the flat index
|
||||
/// space — the activation staleness path owns it there. An error or empty
|
||||
/// grid falls back to the clearing reload: nothing visible to preserve,
|
||||
/// and it is the only way a first item can appear live.
|
||||
@override
|
||||
Future<void> performLiveLibraryRefresh() async {
|
||||
if (!mounted || _selectedGrouping == 'folders' || isLoading) return;
|
||||
if (!hasLoadedData || loadedItems.isEmpty || totalSize == 0) return loadItems();
|
||||
final anchorIndex = _computeVisibleRange()?.firstIndex;
|
||||
// The first visible slot may be an unloaded skeleton after a fast jump;
|
||||
// anchor on it only when its item is known.
|
||||
final anchorId = anchorIndex == null ? null : loadedItems[anchorIndex]?.id;
|
||||
snapshotLibraryContentEpoch();
|
||||
// Bound the refetch: after an alpha jump the map holds disjoint clusters
|
||||
// whose naive span is nearly the whole library. Keep per-index caches in
|
||||
// lockstep with the dropped entries.
|
||||
const maxSpan = 600;
|
||||
if (anchorIndex != null) {
|
||||
evictDistantFocusNodes(anchorIndex, keepCount: _focusNodeKeepCount);
|
||||
_cardMemo.removeOutsideRange(anchorIndex, halfWindow: _focusNodeKeepCount ~/ 2);
|
||||
}
|
||||
final result = await repopulateLoadedRange(
|
||||
idOf: (item) => item.id,
|
||||
anchorId: anchorId,
|
||||
maxSpan: maxSpan,
|
||||
windowCenter: anchorIndex,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
recordLibraryContentEpoch();
|
||||
// Alpha-bar bucket counts shifted with the content; refresh is cheap and
|
||||
// best-effort.
|
||||
unawaited(_loadFirstCharacters());
|
||||
|
||||
final oldIndex = result.anchorOldIndex;
|
||||
final newIndex = result.anchorNewIndex;
|
||||
final pos = _innerPosition;
|
||||
if (oldIndex == null || newIndex == null || pos == null || !_scrollMetrics.isUsable) return;
|
||||
// A drag or fling that began during the fetch owns the offset now; a
|
||||
// jumpTo would kill its activity and yank the grid. Skip the correction
|
||||
// and accept the one-time shift.
|
||||
if (isLiveRefreshBlocked) return;
|
||||
final rowDelta = (newIndex ~/ _scrollMetrics.columnCount) - (oldIndex ~/ _scrollMetrics.columnCount);
|
||||
if (rowDelta == 0) return;
|
||||
// Same frame as the merge's setState, so layout happens once at the
|
||||
// corrected offset — the anchor item never visibly moves. Uniform grid
|
||||
// extents make the arithmetic exact.
|
||||
pos.jumpTo((pos.pixels + rowDelta * _scrollMetrics.rowHeight).clamp(0.0, pos.maxScrollExtent));
|
||||
}
|
||||
|
||||
// Browse-specific state (not in base class)
|
||||
List<MediaFilter> _filters = [];
|
||||
List<MediaSort> _sortOptions = [];
|
||||
@@ -716,6 +774,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
|
||||
hasLoadedData = true;
|
||||
// Consume the load-start epoch snapshot (and credit the live pacer) so
|
||||
// a fresh full load isn't re-marked stale on the next activation.
|
||||
recordLibraryContentEpoch();
|
||||
if (!preserveFocus) {
|
||||
tryFocus();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
|
||||
@override
|
||||
Future<void> loadItems() {
|
||||
// This pipeline bypasses [beginLibraryLoad]; capture the epoch here so
|
||||
// [markItemsLoaded]'s record marks load-start data, not a mid-fetch push.
|
||||
snapshotLibraryContentEpoch();
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: pageSize,
|
||||
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||
@@ -60,6 +63,22 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
|
||||
);
|
||||
}
|
||||
|
||||
/// Server push while this grid is visible (#1646): refetch the loaded span
|
||||
/// in place so the old cards stay rendered — no spinner, no scroll reset,
|
||||
/// no focus churn. The clearing [loadItems] path is reserved for surfaces
|
||||
/// with nothing visible to preserve (error or empty states, where it is
|
||||
/// also the only way a first item can appear live).
|
||||
@override
|
||||
Future<void> performLiveLibraryRefresh() async {
|
||||
if (isLoading) return;
|
||||
if (!hasLoadedData || loadedItems.isEmpty || totalSize == 0) return loadItems();
|
||||
snapshotLibraryContentEpoch();
|
||||
final result = await repopulateLoadedRange(idOf: idOf);
|
||||
if (result == null || !mounted) return;
|
||||
recordLibraryContentEpoch();
|
||||
setState(() => items = loadedItems.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<T> items) {
|
||||
return SettingsBuilder(
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../services/companion_remote/companion_remote_receiver.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/live_tv_matching.dart';
|
||||
import '../../utils/tone_mapped_logo_image.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/bottom_sheet_header.dart';
|
||||
import '../../widgets/focusable_list_tile.dart';
|
||||
@@ -294,7 +295,17 @@ class _GuideSearchSheetState extends State<GuideSearchSheet> with ControllerDisp
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: channel.thumb != null && client != null
|
||||
? OptimizedMediaImage.thumb(client: client, imagePath: channel.thumb, width: 40, height: 40, fit: .contain)
|
||||
? OptimizedMediaImage.thumb(
|
||||
client: client,
|
||||
imagePath: channel.thumb,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: .contain,
|
||||
logoToneTarget: logoToneTargetFor(
|
||||
surface: Theme.of(context).colorScheme.surface,
|
||||
foreground: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/bottom_sheet_header.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
import '../../widgets/optimized_media_image.dart';
|
||||
import '../../utils/tone_mapped_logo_image.dart';
|
||||
|
||||
class ReorderFavoritesSheet extends StatefulWidget {
|
||||
final List<FavoriteChannel> favorites;
|
||||
@@ -190,6 +191,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet>
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.contain,
|
||||
logoToneTarget: logoToneTargetFor(surface: colorScheme.surface, foreground: colorScheme.onSurface),
|
||||
)
|
||||
: Center(child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ import '../../../utils/formatters.dart';
|
||||
import '../../../utils/live_tv_grouping.dart';
|
||||
import '../../../utils/live_tv_matching.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../utils/tone_mapped_logo_image.dart';
|
||||
import '../../../widgets/app_icon.dart';
|
||||
import '../../../widgets/app_menu.dart';
|
||||
import '../../../widgets/clickable_cursor.dart';
|
||||
@@ -1876,6 +1877,12 @@ class _ChannelCellState extends State<_ChannelCell> {
|
||||
width: widget.channelColumnWidth - 16,
|
||||
height: widget.rowHeight - 16,
|
||||
fit: BoxFit.contain,
|
||||
logoToneTarget: logoToneTargetFor(
|
||||
surface: widget.isFocused ? theme.colorScheme.primary : tk.surface,
|
||||
foreground: widget.isFocused
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
: widget.fallbackBuilder(),
|
||||
),
|
||||
|
||||
@@ -1154,10 +1154,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
/// without this the tabs keep showing the in-memory content from the previous
|
||||
/// session — the Libraries grid could sit hours stale until the user switched
|
||||
/// libraries (#2043). Goes through [Refreshable.refresh], each screen's
|
||||
/// non-destructive refetch: Discover refreshes Continue Watching in place,
|
||||
/// Libraries refetches the selected library's loaded tabs, Search re-runs a
|
||||
/// non-empty query. Skipped while playback is up — nothing content-stale is
|
||||
/// on screen and the playback path must stay quiet.
|
||||
/// non-destructive refetch: Discover refreshes Continue Watching in place
|
||||
/// (plus a full hub pass when the hub list has gone stale, #1646), Libraries
|
||||
/// refetches the selected library's loaded tabs, Search re-runs a non-empty
|
||||
/// query. Skipped while playback is up — nothing content-stale is on screen
|
||||
/// and the playback path must stay quiet.
|
||||
void _refreshContentAfterStaleResume() {
|
||||
if (_isOffline || !_startupServicesPrimed || !mounted) return;
|
||||
if (VideoPlayerScreenState.activeGlobalKey != null) return;
|
||||
|
||||
@@ -87,6 +87,7 @@ import '../mixins/server_bound_media_mixin.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/tone_mapped_logo_image.dart';
|
||||
import '../widgets/episode_card.dart';
|
||||
import '../widgets/fitting_title_text.dart';
|
||||
import 'actor_media_screen.dart';
|
||||
@@ -877,6 +878,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
/// Build title text widget for clear logo fallback.
|
||||
///
|
||||
/// The hero scrim washes artwork toward the scaffold background, so the
|
||||
/// default is the theme foreground with a background-side shadow — a
|
||||
/// hard-coded white title disappears into the light theme's near-white
|
||||
/// wash on bright covers.
|
||||
Widget _buildDetailTitle(
|
||||
BuildContext context,
|
||||
String title, {
|
||||
@@ -886,11 +892,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
Color? color,
|
||||
Color? shadowColor,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith(
|
||||
color: color ?? Colors.white,
|
||||
color: color ?? colorScheme.onSurface,
|
||||
fontWeight: fontWeight,
|
||||
fontSize: fontSize,
|
||||
shadows: [Shadow(color: shadowColor ?? Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)],
|
||||
shadows: [Shadow(color: shadowColor ?? _detailTitleShadowColor(context), blurRadius: shadowBlur)],
|
||||
);
|
||||
|
||||
return FittingTitleText(title, style: baseStyle);
|
||||
@@ -1227,6 +1234,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
required BoxFit fit,
|
||||
required ImageType imageType,
|
||||
Alignment alignment = Alignment.center,
|
||||
Color? logoToneTarget,
|
||||
bool logoToneRemapMixed = true,
|
||||
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
||||
Widget Function(BuildContext, String)? placeholder,
|
||||
}) {
|
||||
@@ -1238,6 +1247,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
return OptimizedMediaImage(
|
||||
client: null,
|
||||
logoToneTarget: logoToneTarget,
|
||||
logoToneRemapMixed: logoToneRemapMixed,
|
||||
imagePath: null,
|
||||
localFilePath: localPath,
|
||||
cacheMissingLocalFile: true,
|
||||
@@ -3616,8 +3627,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
fontSize: 56 * scale,
|
||||
fontWeight: .w800,
|
||||
shadowBlur: 12,
|
||||
color: foregroundColor,
|
||||
shadowColor: _tvDetailTitleShadowColor(context),
|
||||
),
|
||||
),
|
||||
SizedBox(height: logoMetadataGap),
|
||||
@@ -3782,7 +3791,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
Color _tvDetailForegroundColor(BuildContext context) => Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
Color _tvDetailTitleShadowColor(BuildContext context) {
|
||||
/// Background-side halo behind the hero title: dark themes shadow with
|
||||
/// black, light themes with white, so the title separates from artwork the
|
||||
/// scrim has not fully washed out.
|
||||
Color _detailTitleShadowColor(BuildContext context) {
|
||||
final brightness = Theme.of(context).colorScheme.brightness;
|
||||
return brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.55);
|
||||
}
|
||||
@@ -3795,6 +3807,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
required Widget Function(BuildContext context, String title) titleBuilder,
|
||||
}) {
|
||||
Widget titleFallback(BuildContext context) => titleBuilder(context, metadata.displayTitle);
|
||||
// The hero scrim washes the backdrop toward the scaffold background, so a
|
||||
// light theme needs light-toned clear logos recolored to stay visible.
|
||||
final theme = Theme.of(context);
|
||||
final logoToneTarget = logoToneTargetFor(
|
||||
surface: theme.scaffoldBackgroundColor,
|
||||
foreground: theme.colorScheme.onSurface,
|
||||
);
|
||||
|
||||
if (metadata.clearLogoPath == null) {
|
||||
return SizedBox(width: width, height: height, child: titleFallback(context));
|
||||
@@ -3811,6 +3830,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
fit: BoxFit.contain,
|
||||
alignment: .centerLeft,
|
||||
imageType: ImageType.heroLogo,
|
||||
logoToneTarget: logoToneTarget,
|
||||
logoToneRemapMixed: false,
|
||||
placeholder: (context, url) => titleFallback(context),
|
||||
errorWidget: (context, url, error) => titleFallback(context),
|
||||
);
|
||||
@@ -3821,6 +3842,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
logoPath: metadata.clearLogoPath,
|
||||
width: width,
|
||||
height: height,
|
||||
logoToneTarget: logoToneTarget,
|
||||
fallbackBuilder: titleFallback,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -395,7 +395,10 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
final kinds = <MediaKind?>[null, ..._candidateKinds];
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
// The search field's own bottom padding provides the gap above; the
|
||||
// results sliver below shrinks its top padding to match (16/16 visual
|
||||
// rhythm around the strip instead of the default 24/24).
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TabChipStrip(
|
||||
children: [
|
||||
for (final (index, kind) in kinds.indexed) ...[
|
||||
@@ -425,22 +428,28 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
final libraries = context.watch<LibrariesProvider>();
|
||||
final showServerName = multiServer.totalServerCount > 1;
|
||||
final visible = _visibleResults;
|
||||
return buildResultsSliver(childCount: visible.length, (context, index) {
|
||||
final item = visible[index];
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.globalKey),
|
||||
item: item,
|
||||
forceListMode: true,
|
||||
disableScale: true,
|
||||
focusNode: index == 0 ? firstResultFocusNode : null,
|
||||
onRefresh: updateItem,
|
||||
onListRefresh: refresh,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateUp: index == 0 ? (_showKindChips ? _focusKindChips : focusSearchInput) : null,
|
||||
showServerName: showServerName,
|
||||
libraryName: libraries.libraryLabelFor(item),
|
||||
);
|
||||
});
|
||||
return buildResultsSliver(
|
||||
childCount: visible.length,
|
||||
// Half the default top padding when the chip strip sits directly above:
|
||||
// the strip already separates results from the search field.
|
||||
padding: _showKindChips ? const EdgeInsets.fromLTRB(16, 8, 16, 16) : const EdgeInsets.all(16),
|
||||
(context, index) {
|
||||
final item = visible[index];
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.globalKey),
|
||||
item: item,
|
||||
forceListMode: true,
|
||||
disableScale: true,
|
||||
focusNode: index == 0 ? firstResultFocusNode : null,
|
||||
onRefresh: updateItem,
|
||||
onListRefresh: refresh,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateUp: index == 0 ? (_showKindChips ? _focusKindChips : focusSearchInput) : null,
|
||||
showServerName: showServerName,
|
||||
libraryName: libraries.libraryLabelFor(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -122,6 +122,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
// mirroring cellularQualityPreset's nullable "same as default"
|
||||
// pattern; needs local/remote connection detection in the
|
||||
// failover client.
|
||||
_directPlayCoveredQualityTile(),
|
||||
_musicQualityTile(),
|
||||
],
|
||||
),
|
||||
@@ -383,6 +384,13 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
title: t.settings.gestureBrightnessSwipe,
|
||||
subtitle: t.settings.gestureBrightnessSwipeDescription,
|
||||
),
|
||||
// Remember the last swiped level between playbacks (#2178).
|
||||
SettingSwitchTile(
|
||||
pref: SettingsService.rememberBrightnessLevel,
|
||||
icon: Symbols.settings_brightness_rounded,
|
||||
title: t.settings.rememberBrightnessLevel,
|
||||
subtitle: t.settings.rememberBrightnessLevelDescription,
|
||||
),
|
||||
SettingSwitchTile(
|
||||
pref: SettingsService.gestureVolumeSwipe,
|
||||
icon: Symbols.volume_up_rounded,
|
||||
@@ -598,6 +606,15 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
],
|
||||
);
|
||||
|
||||
// Plex-only effect: MediaBrowser servers make the equivalent
|
||||
// direct-play-vs-transcode call server-side (#2152, #2193).
|
||||
Widget _directPlayCoveredQualityTile() => SettingSwitchTile(
|
||||
pref: SettingsService.directPlayCoveredQuality,
|
||||
icon: Symbols.bolt_rounded,
|
||||
title: t.settings.directPlayCoveredQuality,
|
||||
subtitle: t.settings.directPlayCoveredQualityDescription,
|
||||
);
|
||||
|
||||
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset>(
|
||||
pref: SettingsService.musicQualityPreset,
|
||||
icon: Symbols.music_note_rounded,
|
||||
|
||||
@@ -140,7 +140,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
appLogger.w('Live TV server ${serverInfo.serverId} is not connected');
|
||||
return null;
|
||||
}
|
||||
return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey);
|
||||
return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey, quality: _selectedQualityPreset);
|
||||
}
|
||||
|
||||
/// Retry the live stream with degraded direct-stream settings.
|
||||
@@ -410,7 +410,13 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
// a failed zap does not tell the server to reclaim the still-playing
|
||||
// tuner/transcode session.
|
||||
session = await _startLiveSession(channel);
|
||||
if (session == null) return;
|
||||
if (session == null) {
|
||||
// Jellyfin's negotiation returns null instead of throwing, so this
|
||||
// is not covered by the catch below; without feedback a failed zap
|
||||
// looks like a dead remote (#2198).
|
||||
if (mounted) showErrorSnackBar(context, t.liveTv.failedToStartChannel);
|
||||
return;
|
||||
}
|
||||
if (!isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
|
||||
@@ -1238,8 +1238,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// the context (providers), which is still safe to touch here because no
|
||||
// async gaps invalidate it between the guard after the settings await
|
||||
// and the reads below.
|
||||
// Skipped for live TV (has its own tune path) and offline (its own
|
||||
// branch in _startPlayback).
|
||||
// Skipped for live TV (has its own tune path — only the quality preset
|
||||
// is resolved below) and offline (its own branch in _startPlayback).
|
||||
if (!widget.isLive && !_offlineLibraryMode) {
|
||||
// Backend-neutral lookup so Jellyfin items also flow through here.
|
||||
// Plex-specific transcoder caching is gated on capabilities below;
|
||||
@@ -1287,6 +1287,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// unhandled async error. The later `await` still receives the error.
|
||||
_playbackDataFuture!.ignore();
|
||||
}
|
||||
if (widget.isLive) {
|
||||
// Live TV skips the resolver but honors the same saved quality
|
||||
// default: on Original the backend may direct-play the channel, on a
|
||||
// capped preset it transcodes at that ceiling (#2198). Both live
|
||||
// backends can transcode, so the capability gate is moot here.
|
||||
_selectedQualityPreset =
|
||||
widget.selectedQualityPreset ??
|
||||
TranscodeQualityPreset.resolveStartupDefault(
|
||||
serverSupportsTranscoding: true,
|
||||
onCellularOnly: context.read<OfflineModeProvider>().isCellularOnly,
|
||||
cellularDefault: settingsService.read(SettingsService.cellularQualityPreset),
|
||||
generalDefault: settingsService.read(SettingsService.defaultQualityPreset),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
initPhase = 'syncing display mode';
|
||||
|
||||
@@ -118,6 +118,18 @@ class DeviceAdjustmentService {
|
||||
onResume?.call();
|
||||
}
|
||||
|
||||
/// Reset queue and restore state between tests. A queue future chained in
|
||||
/// one `testWidgets` fake-async zone never completes inside the next test's
|
||||
/// zone, which would stall every later brightness operation on the shared
|
||||
/// [instance].
|
||||
@visibleForTesting
|
||||
void resetForTesting() {
|
||||
dispose();
|
||||
_brightnessChanged = false;
|
||||
_brightnessGeneration = 0;
|
||||
_brightnessQueue = Future<void>.value();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lifecycleListener?.dispose();
|
||||
_lifecycleListener = null;
|
||||
|
||||
@@ -22,6 +22,8 @@ import '../media/live_tv_support.dart';
|
||||
import '../media/lyrics.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../media/library_change_event.dart';
|
||||
import 'library_events/media_browser_library_event_socket.dart';
|
||||
import '../media/media_file_info.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
@@ -42,6 +44,7 @@ import '../models/livetv_program.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/media_grab_operation.dart';
|
||||
import '../models/media_subscription.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../media/media_sort.dart';
|
||||
import '../media/media_version.dart';
|
||||
@@ -447,6 +450,40 @@ class JellyfinClient
|
||||
MediaBrowserDialect.emby => ServerCapabilities.emby,
|
||||
};
|
||||
|
||||
/// Realtime library-change push on the dialect's session socket. Reads the
|
||||
/// base URL live so endpoint failover lands on the channel's next
|
||||
/// reconnect; Emby only routes `LibraryChanged` to sessions that registered
|
||||
/// capabilities, so that dialect registers before each connect.
|
||||
/// [LibraryEventService] owns the returned channel's lifecycle.
|
||||
@override
|
||||
LibraryEventChannel? createLibraryEventChannel() {
|
||||
return MediaBrowserLibraryEventSocket(
|
||||
serverId: serverId,
|
||||
dialect: dialect,
|
||||
baseUrl: () => _http.baseUrl,
|
||||
accessToken: connection.accessToken,
|
||||
deviceId: connection.deviceId,
|
||||
registerCapabilities: dialect.requiresSessionCapabilitiesForLibraryEvents
|
||||
? _registerSessionCapabilitiesForEvents
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /Sessions/Capabilities/Full` with a minimal payload (verified 204
|
||||
/// on Emby 4.9.5, after which the socket receives `LibraryChanged`).
|
||||
Future<void> _registerSessionCapabilitiesForEvents() async {
|
||||
final response = await _http.post(
|
||||
'/Sessions/Capabilities/Full',
|
||||
body: {
|
||||
'PlayableMediaTypes': ['Video', 'Audio'],
|
||||
'SupportedCommands': <String>[],
|
||||
'SupportsMediaControl': false,
|
||||
'SupportsSync': false,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
/// Neither dialect exposes a per-server played-threshold pref, so we mirror
|
||||
/// Plex's default of 90%.
|
||||
@override
|
||||
|
||||
@@ -143,6 +143,21 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||
);
|
||||
}
|
||||
|
||||
/// Release a live stream that the PlaybackInfo negotiation opened
|
||||
/// (`AutoOpenLiveStream`) but no playback session will ever stop-report.
|
||||
/// Without it the server's consumer count never drops and the tuner slot
|
||||
/// leaks until an idle timeout (#2198). The server wants `liveStreamId` in
|
||||
/// the query string (400 when in the body) and answers 204. Best-effort:
|
||||
/// a failure only defers to the server's own reclaim.
|
||||
Future<void> _closeLiveStream(String liveStreamId) async {
|
||||
try {
|
||||
final response = await _http.post('/LiveStreams/Close', queryParameters: {'liveStreamId': liveStreamId});
|
||||
throwIfHttpError(response);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('Failed to close a ${dialect.productName} live stream', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient);
|
||||
}
|
||||
@@ -167,15 +182,35 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to));
|
||||
}
|
||||
|
||||
/// Negotiate the HLS transcode URL + session identity for [channelKey].
|
||||
/// Negotiate a stream URL + session identity for [channelKey].
|
||||
/// Jellyfin-only: Plex live URLs are only valid after a tune, so the shared
|
||||
/// entry point is [startPlayback].
|
||||
Future<LiveTvStreamResolution?> _resolveStreamUrl(String channelKey) async {
|
||||
///
|
||||
/// The server yields one of two real outcomes — HTTP direct *stream* is
|
||||
/// hard-disabled server-side, so `SupportsDirectStream` never comes back
|
||||
/// without `SupportsDirectPlay`:
|
||||
///
|
||||
/// - **DirectPlay**: no `TranscodingUrl`; the client streams the source
|
||||
/// through `/Videos/{id}/stream.{container}?Static=true`. Granted only
|
||||
/// when [quality] is `original` (the server treats an unknown live
|
||||
/// bitrate as 40 Mbps, so any real `MaxStreamingBitrate` cap would deny
|
||||
/// it anyway) and the source matches a `DirectPlayProfiles` entry.
|
||||
/// - **Transcode**: an HLS `TranscodingUrl`, capped by the preset's
|
||||
/// bitrate when one is set.
|
||||
Future<LiveTvStreamResolution?> _resolveStreamUrl(
|
||||
String channelKey, {
|
||||
required TranscodeQualityPreset quality,
|
||||
bool forceTranscode = false,
|
||||
}) async {
|
||||
final wantsDirect = quality.isOriginal && !forceTranscode;
|
||||
final info = await _client.getPlaybackInfo(
|
||||
channelKey,
|
||||
// Original sends no ceiling, mirroring the VOD path: a cap below the
|
||||
// assumed 40 Mbps live bitrate silently forbids direct play.
|
||||
maxStreamingBitrate: quality.isOriginal ? null : (quality.videoBitrateKbps ?? 100_000) * 1000,
|
||||
autoOpenLiveStream: true,
|
||||
enableDirectPlay: false,
|
||||
enableDirectStream: false,
|
||||
enableDirectPlay: wantsDirect,
|
||||
enableDirectStream: wantsDirect,
|
||||
enableTranscoding: true,
|
||||
allowVideoStreamCopy: true,
|
||||
allowAudioStreamCopy: true,
|
||||
@@ -196,14 +231,40 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
var playSessionId = nonEmptyString(info['PlaySessionId']);
|
||||
var mediaSourceId = nonEmptyString(source['Id']);
|
||||
var liveStreamId = nonEmptyString(source['LiveStreamId']);
|
||||
final rawUrl = nonEmptyString(source['TranscodingUrl']);
|
||||
if (rawUrl == null) {
|
||||
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
||||
return null;
|
||||
|
||||
final container = nonEmptyString(source['Container']);
|
||||
if (wantsDirect && source['SupportsDirectPlay'] == true && container != null) {
|
||||
// The server-proxied direct URL jellyfin-web builds (raw tuner `Path`
|
||||
// needs client-side reachability probing, so it is deliberately not
|
||||
// used). No PlaySessionId in the URL — it travels in the heartbeats.
|
||||
final query = <String, String>{
|
||||
'Static': 'true',
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
'DeviceId': _client.connection.deviceId,
|
||||
};
|
||||
final directPath = Uri(
|
||||
path: '/Videos/${_segment(channelKey)}/stream.$container',
|
||||
queryParameters: query,
|
||||
).toString();
|
||||
return LiveTvStreamResolution(
|
||||
url: _client._withApiKey(directPath),
|
||||
playSessionId: playSessionId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
liveStreamId: liveStreamId,
|
||||
playMethod: 'DirectPlay',
|
||||
);
|
||||
}
|
||||
final rawUri = Uri.tryParse(rawUrl);
|
||||
if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) {
|
||||
|
||||
final rawUrl = nonEmptyString(source['TranscodingUrl']);
|
||||
final rawUri = rawUrl == null ? null : Uri.tryParse(rawUrl);
|
||||
if (rawUrl == null || rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) {
|
||||
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
||||
// AutoOpenLiveStream already opened the tuner; bailing without a
|
||||
// session means no stop report will ever release it.
|
||||
if (liveStreamId != null) {
|
||||
unawaited(_client._closeLiveStream(liveStreamId));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final url = _client._withApiKey(rawUrl);
|
||||
@@ -221,10 +282,14 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) async {
|
||||
final resolution = await _resolveStreamUrl(channelKey);
|
||||
Future<LiveTvPlaybackSession?> startPlayback(
|
||||
String channelKey, {
|
||||
String? dvrKey,
|
||||
TranscodeQualityPreset quality = TranscodeQualityPreset.original,
|
||||
}) async {
|
||||
final resolution = await _resolveStreamUrl(channelKey, quality: quality);
|
||||
if (resolution == null) return null;
|
||||
return _JellyfinLiveTvPlaybackSession(_client, channelKey, resolution);
|
||||
return _JellyfinLiveTvPlaybackSession(_client, channelKey, quality, resolution);
|
||||
}
|
||||
|
||||
/// SharedPreferences key for the locally-persisted favorite-channel list.
|
||||
@@ -302,18 +367,22 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// A MediaBrowser live playback session: one negotiated HLS transcode URL plus
|
||||
/// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No
|
||||
/// program-scoped session and no time-shift — [recover] re-opens the same
|
||||
/// negotiated URL.
|
||||
/// A MediaBrowser live playback session: one negotiated stream URL — direct
|
||||
/// play or HLS transcode — plus `/Sessions/Playing*` heartbeats via
|
||||
/// [JellyfinLiveSessionTracker]. No program-scoped session and no time-shift.
|
||||
class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
|
||||
final JellyfinClient _client;
|
||||
final String _channelKey;
|
||||
final TranscodeQualityPreset _quality;
|
||||
final String _url;
|
||||
final String? _playMethod;
|
||||
final String? _liveStreamId;
|
||||
final JellyfinLiveSessionTracker _tracker;
|
||||
|
||||
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
|
||||
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, this._quality, LiveTvStreamResolution resolution)
|
||||
: _url = resolution.url,
|
||||
_playMethod = resolution.playMethod,
|
||||
_liveStreamId = resolution.liveStreamId,
|
||||
_tracker = JellyfinLiveSessionTracker(
|
||||
playSessionId: resolution.playSessionId,
|
||||
mediaSourceId: resolution.mediaSourceId,
|
||||
@@ -360,6 +429,26 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A transcode session returns itself so its negotiated HLS URL is
|
||||
/// re-opened — the server rebuilds the transcode job for the same
|
||||
/// PlaySessionId. A direct-play session asked to drop [directStream]
|
||||
/// re-negotiates a forced transcode instead: that negotiation opens its own
|
||||
/// live stream, and the player adopts the replacement without ever
|
||||
/// stop-reporting this session, so the old stream is released here. On a
|
||||
/// failed re-negotiation this session stays current and is stop-reported by
|
||||
/// the normal teardown, which also closes its stream. [directStreamAudio]
|
||||
/// has no server-side lever beyond the transcode fallback and is ignored.
|
||||
@override
|
||||
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
|
||||
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async {
|
||||
if (_playMethod != 'DirectPlay' || directStream) return this;
|
||||
final replacement = await _JellyfinLiveTvSupport(
|
||||
_client,
|
||||
)._resolveStreamUrl(_channelKey, quality: _quality, forceTranscode: true);
|
||||
if (replacement == null) return null;
|
||||
final liveStreamId = _liveStreamId;
|
||||
if (liveStreamId != null) {
|
||||
unawaited(_client._closeLiveStream(liveStreamId));
|
||||
}
|
||||
return _JellyfinLiveTvPlaybackSession(_client, _channelKey, _quality, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ String _jellyfinTranscodeVideoCodecs() => [
|
||||
'h264',
|
||||
].join(',');
|
||||
|
||||
/// Transcode output codecs for the MPEG-TS fallback profile. A strict subset
|
||||
/// of [_jellyfinTranscodeVideoCodecs]: AV1 is absent because a TS segment
|
||||
/// cannot carry it — that gap is why the fMP4 profile exists (#2131).
|
||||
String _jellyfinTranscodeVideoCodecsTs() => [if (VideoDecodeCapabilities.supportsHevc) 'hevc', 'h264'].join(',');
|
||||
|
||||
mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
// Implemented by _JellyfinBrowseMethods (cross-part call, same pattern as
|
||||
// _JellyfinImageDownloadMethods' redeclarations).
|
||||
@@ -806,6 +811,23 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
// cannot carry.
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,dts,truehd',
|
||||
},
|
||||
// MPEG-TS fallback, listed second (#2198): Jellyfin drops every
|
||||
// non-ts transcoding profile for a live source with
|
||||
// `UseMostCompatibleTranscodingProfile` — hardcoded true for
|
||||
// HDHomeRun tuners, default true for M3U tuners — so with fMP4
|
||||
// alone Live TV negotiates no HLS URL at all. Both codec lists
|
||||
// are strict subsets of the fMP4 entry's, and the server ranks
|
||||
// profiles with a stable sort, so ts can only win when the fMP4
|
||||
// entry has been filtered out: VOD keeps negotiating fMP4
|
||||
// (jellyfin-web ships the same mp4-then-ts pair). flac and
|
||||
// truehd are omitted because TS cannot carry them.
|
||||
{
|
||||
'Type': 'Video',
|
||||
'Container': 'ts',
|
||||
'Protocol': 'hls',
|
||||
'VideoCodec': _jellyfinTranscodeVideoCodecsTs(),
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,opus,dts',
|
||||
},
|
||||
// Track playback transcode target: stereo mp3 over plain http.
|
||||
// Appended after the video profile so the first-entry-wins
|
||||
// ordering for video output codecs is untouched.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../media/library_change_event.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/deletion_notifier.dart';
|
||||
import '../../utils/library_content_notifier.dart';
|
||||
import '../multi_server_manager.dart';
|
||||
|
||||
/// Runs one [LibraryEventChannel] per online server and fans their events out
|
||||
/// through [LibraryContentNotifier] (#1646).
|
||||
///
|
||||
/// App-global: constructed next to [MultiServerManager] in `main.dart` and
|
||||
/// disposed with it. Which channels run follows two inputs:
|
||||
/// - the manager's [MultiServerManager.statusStream] (server online/offline,
|
||||
/// client replaced on profile switch);
|
||||
/// - app lifecycle via [suspend]/[resume] — sockets are foreground-only on
|
||||
/// mobile, mirroring the companion remote's backoff pause. [resume] also
|
||||
/// re-arms channels that exhausted their reconnect attempts.
|
||||
///
|
||||
/// Everything here degrades silently; the stale-refresh paths remain the
|
||||
/// fallback when a socket cannot be established.
|
||||
class LibraryEventService {
|
||||
LibraryEventService(this._serverManager, {LibraryContentNotifier? notifier})
|
||||
: _notifier = notifier ?? LibraryContentNotifier() {
|
||||
_statusSubscription = _serverManager.statusStream.listen((_) => sync());
|
||||
}
|
||||
|
||||
final MultiServerManager _serverManager;
|
||||
final LibraryContentNotifier _notifier;
|
||||
|
||||
StreamSubscription<Map<String, bool>>? _statusSubscription;
|
||||
final Map<String, _ManagedChannel> _channels = {};
|
||||
bool _suspended = false;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Server ids with a managed channel right now (running or backing off).
|
||||
Set<String> get activeServerIds => Set.unmodifiable(_channels.keys);
|
||||
|
||||
/// Reconcile the managed channels against the manager's current online
|
||||
/// clients. Runs on every status emission; call after [resume] or a
|
||||
/// registration change that has no status emission of its own.
|
||||
void sync() {
|
||||
if (_disposed || _suspended) return;
|
||||
final online = _serverManager.onlineClients;
|
||||
|
||||
// Tear down channels whose server went offline or whose client was
|
||||
// replaced (profile switch rebinds a new client under the same id — the
|
||||
// old channel holds callbacks into the dead client).
|
||||
final stale = <String>[];
|
||||
_channels.forEach((serverId, managed) {
|
||||
if (!identical(online[serverId], managed.client)) stale.add(serverId);
|
||||
});
|
||||
for (final serverId in stale) {
|
||||
_stopChannel(serverId);
|
||||
}
|
||||
|
||||
for (final entry in online.entries) {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
final retained = _channels[serverId];
|
||||
if (retained != null) {
|
||||
// Re-arm a channel whose reconnect attempts were exhausted (a
|
||||
// transient outage longer than the backoff budget); start() is a
|
||||
// no-op while the channel is running or backing off.
|
||||
retained.channel.start();
|
||||
continue;
|
||||
}
|
||||
if (!client.capabilities.libraryChangeEvents) continue;
|
||||
final channel = client.createLibraryEventChannel();
|
||||
if (channel == null) continue;
|
||||
// Cancelled in [_stopChannel]; tracked through [_ManagedChannel].
|
||||
// ignore: cancel_subscriptions
|
||||
final subscription = channel.events.listen(_handleEvent);
|
||||
_channels[serverId] = _ManagedChannel(client, channel, subscription);
|
||||
appLogger.d('LibraryEventService: starting push channel for $serverId');
|
||||
channel.start();
|
||||
}
|
||||
}
|
||||
|
||||
/// A bulk removal (a whole library or show deleted) can name hundreds of
|
||||
/// items; past this the per-item drops stop paying for themselves and the
|
||||
/// coarse refetch owns the update.
|
||||
static const int _maxPerItemRemovals = 25;
|
||||
|
||||
void _handleEvent(LibraryChangeEvent event) {
|
||||
// Exact carve-out (Plex Web parity): removals name items the app may be
|
||||
// displaying, so drop them in place everywhere via the existing deletion
|
||||
// bus before the coarse event schedules its debounced refetch. The kind
|
||||
// and parent chain are unknown at this boundary — direct id matches and
|
||||
// consumers' own parent lookups still apply; cascades settle with the
|
||||
// refetch.
|
||||
if (event.removedItemIds.isNotEmpty && event.removedItemIds.length <= _maxPerItemRemovals) {
|
||||
for (final itemId in event.removedItemIds) {
|
||||
DeletionNotifier().notify(
|
||||
DeletionEvent(
|
||||
itemId: itemId,
|
||||
serverId: event.serverId,
|
||||
parentChain: const [],
|
||||
mediaType: MediaKind.unknown.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_notifier.notifyChanged(event);
|
||||
}
|
||||
|
||||
/// Stop every channel but remember nothing was torn down permanently;
|
||||
/// [resume] rebuilds from the manager's live state.
|
||||
void suspend() {
|
||||
if (_disposed || _suspended) return;
|
||||
_suspended = true;
|
||||
for (final serverId in _channels.keys.toList()) {
|
||||
_stopChannel(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
void resume() {
|
||||
if (_disposed || !_suspended) return;
|
||||
_suspended = false;
|
||||
sync();
|
||||
}
|
||||
|
||||
void _stopChannel(String serverId) {
|
||||
final managed = _channels.remove(serverId);
|
||||
if (managed == null) return;
|
||||
unawaited(managed.subscription.cancel());
|
||||
managed.channel.dispose();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_suspended = true;
|
||||
for (final serverId in _channels.keys.toList()) {
|
||||
_stopChannel(serverId);
|
||||
}
|
||||
unawaited(_statusSubscription?.cancel());
|
||||
_statusSubscription = null;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class _ManagedChannel {
|
||||
_ManagedChannel(this.client, this.channel, this.subscription);
|
||||
|
||||
final MediaServerClient client;
|
||||
final LibraryEventChannel channel;
|
||||
final StreamSubscription<LibraryChangeEvent> subscription;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../media/ids.dart';
|
||||
import '../../media/library_change_event.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// Builds a live channel for [uri]. Injectable so tests can supply a fake or
|
||||
/// point at an ephemeral loopback server.
|
||||
typedef LibraryEventChannelFactory = WebSocketChannel Function(Uri uri);
|
||||
|
||||
WebSocketChannel _defaultChannelFactory(Uri uri) => IOWebSocketChannel.connect(
|
||||
uri,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
// Transport-level pings keep NAT mappings alive and surface a dead peer as
|
||||
// a close event; app-level keepalive (MediaBrowser) rides on top.
|
||||
pingInterval: const Duration(seconds: 30),
|
||||
);
|
||||
|
||||
/// Reconnecting websocket base for one server's library-change push channel.
|
||||
///
|
||||
/// Owns the connect loop, bounded retry backoff, trailing-edge event
|
||||
/// debouncing, and silent degradation; subclasses own the wire protocol:
|
||||
/// [buildUri] (read the *live* base URL so an endpoint failover lands on the
|
||||
/// next attempt), [prepareConnection], [onFrame], and keepalive timers hooked
|
||||
/// on [onConnected]/[onDisconnected].
|
||||
///
|
||||
/// Failure policy: every connect error or unexpected close schedules a retry
|
||||
/// with exponential backoff until [maxConnectAttempts] is exhausted, then the
|
||||
/// channel goes quiet until the next [start] (server status change or app
|
||||
/// resume re-arms it through `LibraryEventService`). Nothing here surfaces to
|
||||
/// the UI — the stale-refresh paths are the fallback.
|
||||
abstract class LibraryEventSocket implements LibraryEventChannel {
|
||||
LibraryEventSocket({
|
||||
required this.serverId,
|
||||
required this.debounce,
|
||||
LibraryEventChannelFactory? channelFactory,
|
||||
this.retryBaseDelay = const Duration(seconds: 5),
|
||||
this.maxConnectAttempts = 5,
|
||||
}) : _channelFactory = channelFactory ?? _defaultChannelFactory;
|
||||
|
||||
final ServerId serverId;
|
||||
final LibraryEventChannelFactory _channelFactory;
|
||||
|
||||
/// Minimum interval between emitted events — a leading-edge throttle
|
||||
/// modeled on Plex Web's `repopulateWithThrottle` (5 s per section): the
|
||||
/// first change after a quiet period emits immediately, and changes inside
|
||||
/// the window coalesce into exactly one trailing event at the window's
|
||||
/// end. The trailing timer is never reset by new changes, so a sustained
|
||||
/// stream (bulk import) emits once per window instead of starving until
|
||||
/// the scan's first quiet gap.
|
||||
final Duration debounce;
|
||||
final Duration retryBaseDelay;
|
||||
final int maxConnectAttempts;
|
||||
|
||||
final _events = StreamController<LibraryChangeEvent>.broadcast();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _frames;
|
||||
|
||||
/// web_socket_channel 3.x never completes `sink.close()` on a channel whose
|
||||
/// connection did not establish (see the companion-remote peer service), so
|
||||
/// closes are gated on this flag.
|
||||
bool _connected = false;
|
||||
|
||||
/// Invalidates callbacks from a superseded connection: every [stop] and
|
||||
/// every new connect attempt bumps it, and async continuations compare
|
||||
/// against their captured value before acting.
|
||||
int _generation = 0;
|
||||
|
||||
int _attempts = 0;
|
||||
bool _running = false;
|
||||
bool _disposed = false;
|
||||
Timer? _retryTimer;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Pending coalesced change, merged across frames until the debounce fires.
|
||||
bool _pendingAdded = false;
|
||||
bool _pendingRemoved = false;
|
||||
bool _pendingUpdated = false;
|
||||
final Set<String> _pendingLibraryIds = {};
|
||||
final Set<String> _pendingRemovedItemIds = {};
|
||||
|
||||
/// When the last event was emitted, for the leading-edge/min-interval check.
|
||||
DateTime? _lastEmitAt;
|
||||
|
||||
@override
|
||||
Stream<LibraryChangeEvent> get events => _events.stream;
|
||||
|
||||
/// Whether the socket is currently wanted (started and not stopped). The
|
||||
/// underlying connection may still be establishing or backing off.
|
||||
bool get isRunning => _running;
|
||||
|
||||
@override
|
||||
void start() {
|
||||
if (_disposed || _running) return;
|
||||
_running = true;
|
||||
_attempts = 0;
|
||||
unawaited(_connect());
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
// Timers and pending state are cleaned even after the retry path gave up
|
||||
// (_running already false, channel torn down): a debounce timer armed by
|
||||
// the last live frame may still be pending.
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_clearPending();
|
||||
if (!_running && _channel == null) return;
|
||||
_running = false;
|
||||
++_generation;
|
||||
_teardownChannel();
|
||||
onDisconnected();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
stop();
|
||||
_disposed = true;
|
||||
_events.close();
|
||||
}
|
||||
|
||||
/// The websocket endpoint for the *current* server connection. Called per
|
||||
/// attempt so failover-driven base-URL changes take effect on reconnect.
|
||||
Uri buildUri();
|
||||
|
||||
/// Runs before each connect attempt (e.g. Emby's capabilities
|
||||
/// registration). A throw fails the attempt into the normal retry path.
|
||||
Future<void> prepareConnection() async {}
|
||||
|
||||
/// One raw websocket frame (usually a JSON string). Implementations parse
|
||||
/// and call [scheduleLibraryChange] / [sendFrame]; a throw is caught and
|
||||
/// logged by the caller so one malformed frame cannot kill the channel.
|
||||
void onFrame(dynamic frame);
|
||||
|
||||
/// The connection just established (before any frame).
|
||||
void onConnected() {}
|
||||
|
||||
/// The connection is gone (stop, error, or remote close). Cancel any
|
||||
/// protocol timers here; may be called without a prior [onConnected].
|
||||
void onDisconnected() {}
|
||||
|
||||
/// Send a raw text frame if connected; silently drops otherwise.
|
||||
void sendFrame(String text) {
|
||||
if (!_connected) return;
|
||||
_channel?.sink.add(text);
|
||||
}
|
||||
|
||||
/// Merge a change into the pending event and emit under the [debounce]
|
||||
/// min-interval throttle. Library scans emit floods (156 frames measured
|
||||
/// for one Plex scan); at most one event per window is the contract.
|
||||
void scheduleLibraryChange({
|
||||
Iterable<String> libraryIds = const [],
|
||||
Iterable<String> removedItemIds = const [],
|
||||
bool added = false,
|
||||
bool removed = false,
|
||||
bool updated = false,
|
||||
}) {
|
||||
if (_disposed || !(added || removed || updated)) return;
|
||||
_pendingAdded |= added;
|
||||
_pendingRemoved |= removed;
|
||||
_pendingUpdated |= updated;
|
||||
_pendingLibraryIds.addAll(libraryIds);
|
||||
_pendingRemovedItemIds.addAll(removedItemIds);
|
||||
final now = DateTime.now();
|
||||
final lastEmitAt = _lastEmitAt;
|
||||
if (lastEmitAt == null || now.difference(lastEmitAt) >= debounce) {
|
||||
// Leading edge: quiet period over — surface the change immediately.
|
||||
_flushPending();
|
||||
return;
|
||||
}
|
||||
if (_debounceTimer?.isActive ?? false) return; // trailing flush already due
|
||||
_debounceTimer = Timer(debounce - now.difference(lastEmitAt), _flushPending);
|
||||
}
|
||||
|
||||
void _flushPending() {
|
||||
if (_disposed) return;
|
||||
final event = LibraryChangeEvent(
|
||||
serverId: serverId,
|
||||
libraryIds: Set.unmodifiable(_pendingLibraryIds),
|
||||
removedItemIds: Set.unmodifiable(_pendingRemovedItemIds),
|
||||
itemsAdded: _pendingAdded,
|
||||
itemsRemoved: _pendingRemoved,
|
||||
itemsUpdated: _pendingUpdated,
|
||||
);
|
||||
_clearPending();
|
||||
if (!event.hasChanges) return;
|
||||
_lastEmitAt = DateTime.now();
|
||||
appLogger.d('LibraryEventSocket[$serverId]: $event');
|
||||
_events.add(event);
|
||||
}
|
||||
|
||||
void _clearPending() {
|
||||
_pendingAdded = false;
|
||||
_pendingRemoved = false;
|
||||
_pendingUpdated = false;
|
||||
_pendingLibraryIds.clear();
|
||||
_pendingRemovedItemIds.clear();
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (!_running || _disposed) return;
|
||||
final generation = ++_generation;
|
||||
try {
|
||||
await prepareConnection();
|
||||
if (generation != _generation) return;
|
||||
final uri = buildUri();
|
||||
final channel = _channelFactory(uri);
|
||||
_channel = channel;
|
||||
_connected = false;
|
||||
await channel.ready;
|
||||
if (generation != _generation) {
|
||||
// A stop/newer attempt superseded this connect while it was pending.
|
||||
unawaited(channel.sink.close());
|
||||
return;
|
||||
}
|
||||
_connected = true;
|
||||
_attempts = 0;
|
||||
appLogger.d('LibraryEventSocket[$serverId]: connected');
|
||||
onConnected();
|
||||
_frames = channel.stream.listen(
|
||||
(frame) {
|
||||
if (generation != _generation) return;
|
||||
try {
|
||||
onFrame(frame);
|
||||
} catch (e) {
|
||||
appLogger.d('LibraryEventSocket[$serverId]: dropped malformed frame', error: e);
|
||||
}
|
||||
},
|
||||
onError: (Object e) => _handleConnectionLost(generation, e),
|
||||
onDone: () => _handleConnectionLost(generation, null),
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
if (generation != _generation) return;
|
||||
_teardownChannel();
|
||||
_scheduleRetry(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionLost(int generation, Object? error) {
|
||||
if (generation != _generation || !_running || _disposed) return;
|
||||
_teardownChannel();
|
||||
onDisconnected();
|
||||
_scheduleRetry(error);
|
||||
}
|
||||
|
||||
void _scheduleRetry(Object? error) {
|
||||
if (!_running || _disposed) return;
|
||||
++_attempts;
|
||||
if (_attempts > maxConnectAttempts) {
|
||||
// Give up quietly; the next start() (status change / app resume)
|
||||
// re-arms, and stale-refresh covers the gap meanwhile.
|
||||
appLogger.d('LibraryEventSocket[$serverId]: giving up after $maxConnectAttempts attempts', error: error);
|
||||
_running = false;
|
||||
return;
|
||||
}
|
||||
final delay = retryBaseDelay * (1 << (_attempts - 1));
|
||||
final capped = delay > const Duration(minutes: 2) ? const Duration(minutes: 2) : delay;
|
||||
appLogger.d('LibraryEventSocket[$serverId]: reconnecting in $capped (attempt $_attempts)', error: error);
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(capped, () => unawaited(_connect()));
|
||||
}
|
||||
|
||||
void _teardownChannel() {
|
||||
final channel = _channel;
|
||||
_channel = null;
|
||||
unawaited(_frames?.cancel());
|
||||
_frames = null;
|
||||
if (channel != null && _connected) {
|
||||
unawaited(channel.sink.close());
|
||||
}
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap an http(s) base URL to its ws(s) twin.
|
||||
Uri webSocketBase(String baseUrl) {
|
||||
final base = Uri.parse(baseUrl);
|
||||
return base.replace(scheme: base.scheme == 'https' ? 'wss' : 'ws');
|
||||
}
|
||||
|
||||
/// Decode a websocket text frame into a JSON object, or `null` for anything
|
||||
/// else (binary frames, non-object payloads, malformed JSON).
|
||||
Map<String, dynamic>? tryDecodeJsonFrame(dynamic frame) {
|
||||
if (frame is! String) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(frame);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../media/media_browser_dialect.dart';
|
||||
import 'library_event_socket.dart';
|
||||
|
||||
/// MediaBrowser (Jellyfin/Emby) session socket:
|
||||
/// `ws(s)://<server><dialect.webSocketPath>?api_key=<token>&deviceId=<id>`.
|
||||
///
|
||||
/// Protocol (verified against Jellyfin 10.11 and Emby 4.9.5):
|
||||
/// - The server opens with `ForceKeepAlive` whose `Data` is the timeout in
|
||||
/// seconds; the client must send `{"MessageType": "KeepAlive"}` within it
|
||||
/// or the session is dropped. jellyfin-web pings at half the timeout.
|
||||
/// - `LibraryChanged` carries the batched change sets — the server already
|
||||
/// coalesces a scan into one frame (~30 s trailing), delivered to admin
|
||||
/// and non-admin sessions alike:
|
||||
///
|
||||
/// ```json
|
||||
/// {"MessageType": "LibraryChanged", "Data": {
|
||||
/// "FoldersAddedTo": [...], "FoldersRemovedFrom": [...],
|
||||
/// "ItemsAdded": [...], "ItemsRemoved": [...], "ItemsUpdated": [...],
|
||||
/// "CollectionFolders": ["f137a2dd..."], "IsEmpty": false}}
|
||||
/// ```
|
||||
///
|
||||
/// Emby additionally requires the session to have registered device
|
||||
/// capabilities before it routes `LibraryChanged` frames — see
|
||||
/// [MediaBrowserDialect.requiresSessionCapabilitiesForLibraryEvents]; the
|
||||
/// client supplies that call through [registerCapabilities].
|
||||
class MediaBrowserLibraryEventSocket extends LibraryEventSocket {
|
||||
MediaBrowserLibraryEventSocket({
|
||||
required super.serverId,
|
||||
required this.dialect,
|
||||
required this.baseUrl,
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.registerCapabilities,
|
||||
super.channelFactory,
|
||||
super.debounce = const Duration(seconds: 2),
|
||||
super.retryBaseDelay,
|
||||
super.maxConnectAttempts,
|
||||
});
|
||||
|
||||
final MediaBrowserDialect dialect;
|
||||
|
||||
/// Read live so an endpoint failover lands on the next connect attempt.
|
||||
final String Function() baseUrl;
|
||||
final String accessToken;
|
||||
final String deviceId;
|
||||
|
||||
/// Runs before each connect on dialects that need it (Emby's
|
||||
/// `POST /Sessions/Capabilities/Full`).
|
||||
final Future<void> Function()? registerCapabilities;
|
||||
|
||||
Timer? _keepAliveTimer;
|
||||
|
||||
@override
|
||||
Uri buildUri() {
|
||||
final base = webSocketBase(baseUrl());
|
||||
return base.replace(
|
||||
path: '${base.path}${dialect.webSocketPath}',
|
||||
queryParameters: {'api_key': accessToken, 'deviceId': deviceId},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> prepareConnection() async {
|
||||
if (!dialect.requiresSessionCapabilitiesForLibraryEvents) return;
|
||||
// Without this Emby accepts the socket but never routes LibraryChanged
|
||||
// to it. A failure fails the attempt into the normal retry path.
|
||||
await registerCapabilities?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
void onFrame(dynamic frame) {
|
||||
final message = tryDecodeJsonFrame(frame);
|
||||
if (message == null) return;
|
||||
switch (message['MessageType']) {
|
||||
case 'ForceKeepAlive':
|
||||
_startKeepAlive(message['Data']);
|
||||
case 'LibraryChanged':
|
||||
_handleLibraryChanged(message['Data']);
|
||||
default:
|
||||
// KeepAlive acks, RefreshProgress, UserDataChanged (future watch-state
|
||||
// sync seam), session traffic — not library content.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLibraryChanged(dynamic data) {
|
||||
if (data is! Map) return;
|
||||
bool nonEmpty(dynamic value) => value is List && value.isNotEmpty;
|
||||
final collectionFolders = data['CollectionFolders'];
|
||||
final itemsRemoved = data['ItemsRemoved'];
|
||||
scheduleLibraryChange(
|
||||
libraryIds: [
|
||||
if (collectionFolders is List)
|
||||
for (final id in collectionFolders)
|
||||
if (id != null) id.toString(),
|
||||
],
|
||||
removedItemIds: [
|
||||
if (itemsRemoved is List)
|
||||
for (final id in itemsRemoved)
|
||||
if (id != null) id.toString(),
|
||||
],
|
||||
added: nonEmpty(data['ItemsAdded']) || nonEmpty(data['FoldersAddedTo']),
|
||||
removed: nonEmpty(itemsRemoved) || nonEmpty(data['FoldersRemovedFrom']),
|
||||
updated: nonEmpty(data['ItemsUpdated']),
|
||||
);
|
||||
}
|
||||
|
||||
void _startKeepAlive(dynamic timeoutSeconds) {
|
||||
final seconds = timeoutSeconds is num ? timeoutSeconds.toInt() : 60;
|
||||
// Half the advertised timeout, floored so a bogus tiny value cannot spin.
|
||||
final interval = Duration(seconds: (seconds ~/ 2).clamp(10, 300));
|
||||
_keepAliveTimer?.cancel();
|
||||
_keepAliveTimer = Timer.periodic(interval, (_) => _sendKeepAlive());
|
||||
_sendKeepAlive();
|
||||
}
|
||||
|
||||
void _sendKeepAlive() => sendFrame(jsonEncode(const {'MessageType': 'KeepAlive'}));
|
||||
|
||||
@override
|
||||
void onDisconnected() {
|
||||
_keepAliveTimer?.cancel();
|
||||
_keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'library_event_socket.dart';
|
||||
|
||||
/// Plex realtime notifications: `ws(s)://<server>/:/websockets/notifications`.
|
||||
///
|
||||
/// Every frame is a `NotificationContainer`. One library scan floods the
|
||||
/// socket (156 frames measured on Plex 1.43 for a single new movie: 80
|
||||
/// `activity`, 52 `progress`, 14 `timeline`, plus unrelated `playing`
|
||||
/// session traffic), so only settled `timeline` entries feed the debounce:
|
||||
///
|
||||
/// ```json
|
||||
/// {"NotificationContainer": {"type": "timeline", "TimelineEntry": [
|
||||
/// {"identifier": "com.plexapp.plugins.library", "sectionID": "1",
|
||||
/// "itemID": "10141", "type": 1, "state": 5, "metadataState": "created"}]}}
|
||||
/// ```
|
||||
///
|
||||
/// `state` walks 0→5 while an item is scanned/matched/analyzed; `state == 5`
|
||||
/// is the settled commit. A deletion emits the same entry with `type: -1`
|
||||
/// (measured; the kind field is replaced, there is no separate deleted state).
|
||||
/// Adds and metadata updates are indistinguishable at settle time, so both
|
||||
/// advisory flags are set for non-deletions.
|
||||
class PlexLibraryEventSocket extends LibraryEventSocket {
|
||||
PlexLibraryEventSocket({
|
||||
required super.serverId,
|
||||
required this.baseUrl,
|
||||
required this.token,
|
||||
super.channelFactory,
|
||||
super.debounce = const Duration(seconds: 10),
|
||||
super.retryBaseDelay,
|
||||
super.maxConnectAttempts,
|
||||
});
|
||||
|
||||
/// Read live so an endpoint failover or token refresh lands on the next
|
||||
/// connect attempt.
|
||||
final String Function() baseUrl;
|
||||
final String? Function() token;
|
||||
|
||||
/// Only timeline entries from the library plugin describe library content;
|
||||
/// other identifiers carry playlist/butler/provider timelines.
|
||||
static const _libraryIdentifier = 'com.plexapp.plugins.library';
|
||||
|
||||
/// Timeline `state` for a settled (fully committed) entry.
|
||||
static const _stateSettled = 5;
|
||||
|
||||
/// Timeline `type` marking a deleted item.
|
||||
static const _typeDeleted = -1;
|
||||
|
||||
@override
|
||||
Uri buildUri() {
|
||||
// Keep any reverse-proxy path prefix from the base URL, like the
|
||||
// MediaBrowser socket does.
|
||||
final base = webSocketBase(baseUrl());
|
||||
return base.replace(path: '${base.path}/:/websockets/notifications', queryParameters: {'X-Plex-Token': ?token()});
|
||||
}
|
||||
|
||||
@override
|
||||
void onFrame(dynamic frame) {
|
||||
final decoded = tryDecodeJsonFrame(frame);
|
||||
final container = decoded?['NotificationContainer'];
|
||||
if (container is! Map) return;
|
||||
if (container['type'] != 'timeline') return;
|
||||
final entries = container['TimelineEntry'];
|
||||
if (entries is! List) return;
|
||||
for (final entry in entries) {
|
||||
if (entry is! Map) continue;
|
||||
if (entry['identifier'] != _libraryIdentifier) continue;
|
||||
if (entry['state'] != _stateSettled) continue;
|
||||
final sectionId = entry['sectionID']?.toString();
|
||||
final itemId = entry['itemID']?.toString();
|
||||
final removed = entry['type'] == _typeDeleted;
|
||||
scheduleLibraryChange(
|
||||
libraryIds: [if (sectionId != null && sectionId.isNotEmpty) sectionId],
|
||||
removedItemIds: [if (removed && itemId != null && itemId.isNotEmpty) itemId],
|
||||
added: !removed,
|
||||
updated: !removed,
|
||||
removed: removed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,11 @@ class MultiServerManager {
|
||||
_emitStatus();
|
||||
}
|
||||
|
||||
/// Re-publish the current status snapshot so tests can drive
|
||||
/// [statusStream]-reactive services after `debugRegister*ForTesting`.
|
||||
@visibleForTesting
|
||||
void debugEmitStatusForTesting() => _emitStatus();
|
||||
|
||||
/// Mark every cached Plex server on [connection] as auth-rejected without
|
||||
/// requiring a live client. Startup auth failures happen before a client can
|
||||
/// exist, but the UI still needs a server id/name for the re-auth banner.
|
||||
|
||||
@@ -23,6 +23,8 @@ import '../media/ids.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../media/playback_report_metadata.dart';
|
||||
import '../media/server_capabilities.dart';
|
||||
import '../media/library_change_event.dart';
|
||||
import 'library_events/plex_library_event_socket.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
import 'bif_thumbnail_service.dart';
|
||||
import 'file_info_parser.dart';
|
||||
@@ -3372,6 +3374,14 @@ class PlexClient
|
||||
videoTranscoding: serverSupportsVideoTranscodingCached,
|
||||
);
|
||||
|
||||
/// Realtime library-change push (`/:/websockets/notifications`). Reads the
|
||||
/// base URL and token live so endpoint failover lands on the channel's next
|
||||
/// reconnect. [LibraryEventService] owns the returned channel's lifecycle.
|
||||
@override
|
||||
LibraryEventChannel? createLibraryEventChannel() {
|
||||
return PlexLibraryEventSocket(serverId: serverId, baseUrl: () => _http.baseUrl, token: () => config.token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaLibrary>> fetchLibraries() async {
|
||||
final libraries = await _getLibraries();
|
||||
@@ -3773,7 +3783,10 @@ class PlexClient
|
||||
/// Whether [preset] asks for anything the selected version does not already
|
||||
/// deliver. A preset is a ceiling, so a version that fits under it is served
|
||||
/// by the file itself and playback direct plays instead of paying for an
|
||||
/// encode that can only cost more (issue #2152).
|
||||
/// encode that can only cost more (issue #2152). Turning
|
||||
/// [SettingsService.directPlayCoveredQuality] off disables that shortcut, so
|
||||
/// a non-original preset always transcodes for users who deliberately want
|
||||
/// the server's encode over the source (issue #2193).
|
||||
///
|
||||
/// The comparison cannot be left to the server: PMS answers `directPlay=1`
|
||||
/// with "Direct play OK" whatever bitrate cap the request carries (measured
|
||||
@@ -3782,6 +3795,8 @@ class PlexClient
|
||||
/// too, folding the preset's bitrate into its own direct-play profile.
|
||||
bool _presetNeedsTranscode(TranscodeQualityPreset preset, PlexVideoPlaybackData data) {
|
||||
if (preset.isOriginal) return false;
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && !settings.read(SettingsService.directPlayCoveredQuality)) return true;
|
||||
final version = data.selectedMediaIndex < data.availableVersions.length
|
||||
? data.availableVersions[data.selectedMediaIndex]
|
||||
: null;
|
||||
|
||||
@@ -854,8 +854,14 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
|
||||
return getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to));
|
||||
}
|
||||
|
||||
/// [quality] is intentionally not consumed yet: the Plex live path still
|
||||
/// hardcodes `directPlay=0` and sends no bitrate ceiling (#2072).
|
||||
@override
|
||||
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) {
|
||||
Future<LiveTvPlaybackSession?> startPlayback(
|
||||
String channelKey, {
|
||||
String? dvrKey,
|
||||
TranscodeQualityPreset quality = TranscodeQualityPreset.original,
|
||||
}) {
|
||||
if (dvrKey == null) {
|
||||
appLogger.w('Plex live playback requires a dvrKey to tune $channelKey');
|
||||
return Future.value(null);
|
||||
|
||||
@@ -529,6 +529,14 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
'cellular_quality_preset',
|
||||
values: TranscodeQualityPreset.values,
|
||||
);
|
||||
|
||||
/// Serve a source that already fits under the selected quality preset by
|
||||
/// direct playing the file instead of transcoding it (#2152). Off restores
|
||||
/// the pre-#2152 behavior — any non-original preset always transcodes — for
|
||||
/// users who deliberately request a server encode to sidestep a decoder
|
||||
/// limitation (#2193). Plex-only by design: MediaBrowser servers make the
|
||||
/// equivalent direct-play-vs-transcode call server-side.
|
||||
static const directPlayCoveredQuality = BoolPref('direct_play_covered_quality', defaultValue: true);
|
||||
static const musicQualityPreset = EnumPref<AudioQualityPreset>(
|
||||
'music_quality_preset',
|
||||
values: AudioQualityPreset.values,
|
||||
@@ -556,6 +564,16 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const gestureVolumeSwipe = BoolPref('gesture_volume_swipe', defaultValue: true);
|
||||
static const gesturePinchToZoom = BoolPref('gesture_pinch_to_zoom', defaultValue: true);
|
||||
|
||||
/// Remember the brightness level set by the swipe gesture (#2178). When on,
|
||||
/// playback starts at [rememberedBrightnessLevel] instead of the system
|
||||
/// level; the player exit still restores the pre-playback brightness.
|
||||
static const rememberBrightnessLevel = BoolPref('remember_brightness_level');
|
||||
|
||||
/// Last brightness the swipe gesture settled on while
|
||||
/// [rememberBrightnessLevel] was enabled. Negative means "never set";
|
||||
/// device-local runtime state, so reset-only in the registry.
|
||||
static const rememberedBrightnessLevel = DoublePref('remembered_brightness_level', defaultValue: -1.0);
|
||||
|
||||
/// Deinterlace interlaced video via mpv's `deinterlace=auto` (#2149).
|
||||
/// mpv-only by design: ExoPlayer has no filter chain.
|
||||
static const deinterlace = BoolPref('deinterlace');
|
||||
@@ -1128,6 +1146,8 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
gestureBrightnessSwipe,
|
||||
gestureVolumeSwipe,
|
||||
gesturePinchToZoom,
|
||||
rememberBrightnessLevel,
|
||||
directPlayCoveredQuality,
|
||||
deinterlace,
|
||||
playerAlwaysOnTop,
|
||||
specialsOrdering,
|
||||
@@ -1230,6 +1250,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
customExternalPlayers,
|
||||
customRelayUrl,
|
||||
companionRemoteLastHostAddress,
|
||||
rememberedBrightnessLevel,
|
||||
];
|
||||
|
||||
/// Settings that "Reset All Settings" actually resets.
|
||||
|
||||
@@ -19,6 +19,7 @@ final class CoalescedLoadCoordinator<T> {
|
||||
final Set<T> _pendingDelta = {};
|
||||
Future<void>? _inFlight;
|
||||
bool _pendingFull = false;
|
||||
bool _runningFull = false;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Whether a pass is running (or queued behind the running one). Lets a
|
||||
@@ -26,6 +27,11 @@ final class CoalescedLoadCoordinator<T> {
|
||||
/// without changing the drain's trailing-replay contract.
|
||||
bool get isBusy => _inFlight != null;
|
||||
|
||||
/// Whether the busy work includes a full pass (running or queued). A busy
|
||||
/// coordinator draining only deltas does not cover a caller that needs the
|
||||
/// whole surface refetched.
|
||||
bool get isFullActive => _pendingFull || _runningFull;
|
||||
|
||||
Future<void> requestFull() {
|
||||
if (_disposed) return Future<void>.value();
|
||||
_pendingFull = true;
|
||||
@@ -80,7 +86,12 @@ final class CoalescedLoadCoordinator<T> {
|
||||
if (_pendingFull) {
|
||||
_pendingFull = false;
|
||||
_pendingDelta.clear();
|
||||
await _onFull();
|
||||
_runningFull = true;
|
||||
try {
|
||||
await _onFull();
|
||||
} finally {
|
||||
_runningFull = false;
|
||||
}
|
||||
} else {
|
||||
final values = Set<T>.of(_pendingDelta);
|
||||
_pendingDelta.clear();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../media/library_change_event.dart';
|
||||
import 'base_notifier.dart';
|
||||
|
||||
/// App-global fan-out for server push notifications about library content
|
||||
/// (#1646). [LibraryEventService] publishes one coalesced event per server
|
||||
/// change burst; profile-scoped consumers ([DiscoverProvider] today) subscribe
|
||||
/// through [stream] and refetch their own views.
|
||||
///
|
||||
/// Same singleton shape as [LibraryRefreshNotifier] / [WatchStateNotifier].
|
||||
class LibraryContentNotifier extends BaseNotifier<LibraryChangeEvent> {
|
||||
static final LibraryContentNotifier _instance = LibraryContentNotifier._internal();
|
||||
|
||||
factory LibraryContentNotifier() => _instance;
|
||||
|
||||
LibraryContentNotifier._internal();
|
||||
|
||||
void notifyChanged(LibraryChangeEvent event) => notify(event);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Classification of a channel-logo frame for theme-adaptive rendering.
|
||||
///
|
||||
/// Broadcast channel logos (Gracenote and most Jellyfin/Emby lineups) are
|
||||
/// designed for dark UIs: white or near-white marks on transparency. On the
|
||||
/// light theme's white cards they are invisible (issue #2197). The tone drives
|
||||
/// whether [remapLightNeutral] may recolor a logo for a light surface.
|
||||
enum LogoTone {
|
||||
/// Too few opaque pixels to judge — leave untouched.
|
||||
unknown,
|
||||
|
||||
/// Near-white, low-saturation mark (CBS/FOX-style wordmarks).
|
||||
lightMonochrome,
|
||||
|
||||
/// Light-dominant mark with only incidental color (≤15% saturated pixels):
|
||||
/// a white wordmark with a small colored accent. Safe to remap everywhere —
|
||||
/// the accent keeps its pixels and the mark stays recognizable.
|
||||
lightAccented,
|
||||
|
||||
/// Light content beside *significant* color (an NBC peacock, a red-outline
|
||||
/// wordmark with white fill). Remapping is legibility-correct but changes
|
||||
/// the mark's character, so hero surfaces leave these untouched while the
|
||||
/// guide's tiny channel cells still remap them.
|
||||
lightMixed,
|
||||
|
||||
/// Dark or self-backed artwork (abc's black disc, PBS KIDS' blue chip):
|
||||
/// legible on light surfaces as-is. Also the gate that keeps
|
||||
/// [remapLightNeutral] away from light text sitting inside an opaque dark
|
||||
/// shape, which a per-pixel remap alone would erase.
|
||||
dark,
|
||||
}
|
||||
|
||||
/// Single-pass tone analysis over a **straight-alpha** RGBA frame
|
||||
/// (`ImageByteFormat.rawStraightRgba`). Premultiplied input reads antialiased
|
||||
/// and semi-transparent pixels darker than they are and must not be used.
|
||||
///
|
||||
/// [stride] samples every Nth pixel on both axes; 1 visits every pixel.
|
||||
/// Cost is O(w·h / stride²) with no allocation.
|
||||
LogoTone analyzeLogoTone(ByteData rgba, int width, int height, {int stride = 1}) {
|
||||
final bytes = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
|
||||
var opaque = 0;
|
||||
var light = 0;
|
||||
var lightLowSat = 0;
|
||||
var colored = 0;
|
||||
|
||||
for (var y = 0; y < height; y += stride) {
|
||||
var i = y * width * 4;
|
||||
final rowEnd = i + width * 4;
|
||||
for (; i < rowEnd; i += 4 * stride) {
|
||||
final a = bytes[i + 3];
|
||||
if (a < 32) continue;
|
||||
opaque++;
|
||||
final r = bytes[i];
|
||||
final g = bytes[i + 1];
|
||||
final b = bytes[i + 2];
|
||||
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
|
||||
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
|
||||
// Relative saturation ≥0.35: the same boundary at which
|
||||
// [remapLightNeutral]'s weight reaches zero.
|
||||
if (maxC > 0 && 255 * (maxC - minC) ~/ maxC >= 89) colored++;
|
||||
// Rec.601 integer luma.
|
||||
final luma = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
if (luma < 176) continue;
|
||||
light++;
|
||||
if (maxC - minC <= 48) lightLowSat++;
|
||||
}
|
||||
}
|
||||
|
||||
final sampled = ((height + stride - 1) ~/ stride) * ((width + stride - 1) ~/ stride);
|
||||
if (sampled == 0 || opaque * 50 < sampled) return LogoTone.unknown; // <2% coverage
|
||||
final lightFrac = light / opaque;
|
||||
if (lightFrac >= 0.85 && lightLowSat / opaque >= 0.80) return LogoTone.lightMonochrome;
|
||||
if (lightFrac >= 0.30) {
|
||||
// Measured on real clear-logo sets: remap-friendly marks (white wordmark,
|
||||
// small accent) sit at ≤0.11 colored, identity-colored marks at ≥0.28 —
|
||||
// 0.15 splits them with margin on both sides.
|
||||
return colored * 100 <= opaque * 15 ? LogoTone.lightAccented : LogoTone.lightMixed;
|
||||
}
|
||||
return LogoTone.dark;
|
||||
}
|
||||
|
||||
/// Bakes a light-surface-adapted copy of a light-toned logo frame.
|
||||
///
|
||||
/// Input is **straight-alpha** RGBA (`ImageByteFormat.rawStraightRgba`);
|
||||
/// output is **premultiplied** RGBA, ready for
|
||||
/// `ImageDescriptor.raw(..., PixelFormat.rgba8888)`. Reading the widely used
|
||||
/// `rawRgba` instead silently premultiplies, which drags antialiased glyph
|
||||
/// edges below the luma ramp — they escape the remap and render as a light
|
||||
/// fringe around the recolored mark.
|
||||
///
|
||||
/// Lerps each pixel toward [targetArgb] weighted by how light *and* neutral it
|
||||
/// is: full weight above ~0.85 luma at ~zero saturation, zero weight below
|
||||
/// ~0.6 luma or above ~0.35 saturation, smooth ramp between. Colored content
|
||||
/// (an NBC peacock) keeps its pixels; white wordmarks become the target;
|
||||
/// antialiased boundary pixels land proportionally in between. Alpha is
|
||||
/// preserved. Only call for [LogoTone.lightMonochrome] /
|
||||
/// [LogoTone.lightMixed]; the classification is the gate that protects
|
||||
/// self-backed logos (white text inside an opaque dark disc).
|
||||
Uint8List remapLightNeutral(ByteData rgba, {required int targetArgb}) {
|
||||
final src = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
|
||||
final out = Uint8List.fromList(src);
|
||||
final tr = (targetArgb >> 16) & 0xFF, tg = (targetArgb >> 8) & 0xFF, tb = targetArgb & 0xFF;
|
||||
|
||||
for (var i = 0; i < out.length; i += 4) {
|
||||
final a = out[i + 3];
|
||||
if (a == 0) {
|
||||
out[i] = 0;
|
||||
out[i + 1] = 0;
|
||||
out[i + 2] = 0;
|
||||
continue;
|
||||
}
|
||||
var r = out[i], g = out[i + 1], b = out[i + 2];
|
||||
final luma = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
|
||||
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
|
||||
final sat = maxC == 0 ? 0 : 255 * (maxC - minC) ~/ maxC;
|
||||
// Luma ramp 153..217 (0.60..0.85), saturation ramp 89..38 (0.35..0.15).
|
||||
final lw = ((luma - 153) * 4).clamp(0, 255);
|
||||
final sw = ((89 - sat) * 5).clamp(0, 255);
|
||||
final w = lw * sw ~/ 255;
|
||||
if (w != 0) {
|
||||
r += (tr - r) * w ~/ 255;
|
||||
g += (tg - g) * w ~/ 255;
|
||||
b += (tb - b) * w ~/ 255;
|
||||
}
|
||||
// Premultiply for PixelFormat.rgba8888 consumption.
|
||||
out[i] = r * a ~/ 255;
|
||||
out[i + 1] = g * a ~/ 255;
|
||||
out[i + 2] = b * a ~/ 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../services/device_performance.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../services/settings_service.dart' show EpisodePosterMode;
|
||||
import 'platform_detector.dart';
|
||||
import 'tone_mapped_logo_image.dart';
|
||||
|
||||
/// Image types for different transcoding strategies
|
||||
enum ImageType {
|
||||
@@ -302,10 +303,19 @@ class MediaImageHelper {
|
||||
/// The disk key deliberately depends only on the fully bucketed URL. Decode
|
||||
/// dimensions belong to Flutter's memory-cache key and must not fragment the
|
||||
/// shared disk cache during small layout changes.
|
||||
///
|
||||
/// [logoToneTarget] wraps the decode in a [ToneMappedLogoImage] that
|
||||
/// recolors light-toned channel logos toward the given theme foreground so
|
||||
/// they stay legible on light surfaces. It participates only in the memory
|
||||
/// cache key; the disk cache keeps serving the original bytes.
|
||||
/// [logoToneRemapMixed] forwards the [ToneMappedLogoImage.remapMixed]
|
||||
/// policy.
|
||||
static ImageProvider serverArtworkProvider({
|
||||
required String imageUrl,
|
||||
required int memWidth,
|
||||
required int memHeight,
|
||||
Color? logoToneTarget,
|
||||
bool logoToneRemapMixed = true,
|
||||
}) {
|
||||
final provider = CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
@@ -313,7 +323,9 @@ class MediaImageHelper {
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
headers: const {'User-Agent': 'Plezy'},
|
||||
);
|
||||
return boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
|
||||
final bounded = boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
|
||||
if (logoToneTarget == null) return bounded;
|
||||
return ToneMappedLogoImage(bounded, target: logoToneTarget, remapMixed: logoToneRemapMixed);
|
||||
}
|
||||
|
||||
static final _serverArtworkCacheKeys = <String, String>{};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
|
||||
/// Paces push-triggered refreshes behind three windows: a trailing [debounce]
|
||||
/// that merges event bursts, a [blockedRetry] loop that waits out a surface
|
||||
/// the refresh would disturb, and a [cooldown] that bounds pass frequency
|
||||
/// during sustained streams (bulk imports) while deferring the pending pass
|
||||
/// to the window's trailing edge so the final change always lands.
|
||||
///
|
||||
/// The debounce arms once per burst and is never reset by later events, so a
|
||||
/// sustained sub-[debounce] event stream (several servers scanning at once)
|
||||
/// cannot starve the refresh — the pass runs at most [debounce] after the
|
||||
/// burst's first event. Time is read through `package:clock`, so fake-async
|
||||
/// and widget tests drive it deterministically, and a timer is pending only
|
||||
/// while a pass is actually owed — an idle pacer holds none.
|
||||
///
|
||||
/// [runPass] returns whether a pass actually started; a dropped pass (owner
|
||||
/// unmounted, surface hidden) does not spend the cooldown. [notePass]
|
||||
/// credits an equivalent refresh committed outside the pacer (a pull pass) so
|
||||
/// a push event landing just after it defers to the cooldown's trailing edge
|
||||
/// instead of fanning out an identical load.
|
||||
class RefreshPacer {
|
||||
RefreshPacer({
|
||||
required this.debounce,
|
||||
required this.cooldown,
|
||||
required this.blockedRetry,
|
||||
required bool Function() isBlocked,
|
||||
required bool Function() runPass,
|
||||
// A private field cannot be a named initializing formal callers can pass.
|
||||
// ignore: prefer_initializing_formals
|
||||
}) : _isBlocked = isBlocked,
|
||||
// ignore: prefer_initializing_formals
|
||||
_runPass = runPass;
|
||||
|
||||
final Duration debounce;
|
||||
final Duration cooldown;
|
||||
final Duration blockedRetry;
|
||||
final bool Function() _isBlocked;
|
||||
final bool Function() _runPass;
|
||||
|
||||
/// Debounce, blocked-retry, or trailing-edge timer; any of them means a
|
||||
/// pass is already owed, which is why [schedule] arms at most once.
|
||||
Timer? _timer;
|
||||
DateTime? _lastPassAt;
|
||||
bool _disposed = false;
|
||||
|
||||
/// An event arrived. No-op while a pass is already scheduled.
|
||||
void schedule() {
|
||||
if (_disposed) return;
|
||||
if (_timer?.isActive ?? false) return;
|
||||
_timer = Timer(debounce, _run);
|
||||
}
|
||||
|
||||
/// An equivalent refresh committed outside the pacer: restart the cooldown
|
||||
/// window from now. A pass already owed defers to the new window's
|
||||
/// trailing edge rather than being dropped — the pull fetch may have
|
||||
/// raced the change it reported.
|
||||
void notePass() {
|
||||
if (_disposed) return;
|
||||
_lastPassAt = clock.now();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void _run() {
|
||||
if (_disposed) return;
|
||||
if (_isBlocked()) {
|
||||
_timer = Timer(blockedRetry, _run);
|
||||
return;
|
||||
}
|
||||
final last = _lastPassAt;
|
||||
if (last != null) {
|
||||
final remaining = cooldown - clock.now().difference(last);
|
||||
if (remaining > Duration.zero) {
|
||||
// Defer to the trailing edge; re-checks the window on fire in case a
|
||||
// [notePass] moved it meanwhile.
|
||||
_timer = Timer(remaining, _run);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_runPass()) _lastPassAt = clock.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
import 'app_logger.dart';
|
||||
import 'logo_tone.dart';
|
||||
|
||||
/// Remap target for logo artwork rendered over [surface].
|
||||
///
|
||||
/// Channel logos and clear logos are usually white-on-transparent marks
|
||||
/// designed for dark UIs; on a light backdrop they vanish (issue #2197), so
|
||||
/// light backdrops recolor light-toned logos toward [foreground]. Dark
|
||||
/// backdrops — dark theme cards and scrims, or the light theme's inverted
|
||||
/// focus card — render the original artwork. Pass the result to
|
||||
/// `OptimizedMediaImage.logoToneTarget` / `ClearLogoImage.logoToneTarget`.
|
||||
Color? logoToneTargetFor({required Color surface, required Color foreground}) {
|
||||
return surface.computeLuminance() > 0.5 ? foreground : null;
|
||||
}
|
||||
|
||||
/// Cache key for [ToneMappedLogoImage]: the wrapped provider's key plus the
|
||||
/// remap target and policy, so plain, light-adapted, and
|
||||
/// differently-configured variants of the same artwork occupy distinct
|
||||
/// [ImageCache] entries.
|
||||
@immutable
|
||||
class ToneMappedLogoKey {
|
||||
const ToneMappedLogoKey._(this._providerCacheKey, this._targetArgb, this._remapMixed);
|
||||
|
||||
final Object _providerCacheKey;
|
||||
final int _targetArgb;
|
||||
final bool _remapMixed;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ToneMappedLogoKey &&
|
||||
other._providerCacheKey == _providerCacheKey &&
|
||||
other._targetArgb == _targetArgb &&
|
||||
other._remapMixed == _remapMixed;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(_providerCacheKey, _targetArgb, _remapMixed);
|
||||
}
|
||||
|
||||
/// Recolors light-toned channel logos for legibility on light surfaces.
|
||||
///
|
||||
/// Broadcast channel logos are white-on-transparent marks designed for dark
|
||||
/// UIs; on the light theme's white cards they vanish (issue #2197). This
|
||||
/// provider decodes through [imageProvider] (normally the bounded
|
||||
/// [ResizeImage] built by `MediaImageHelper.serverArtworkProvider`), then —
|
||||
/// once per [ImageCache] entry — reads the single decoded frame back,
|
||||
/// classifies it with [analyzeLogoTone], and for light-toned frames bakes a
|
||||
/// [remapLightNeutral] copy targeting [target]. Dark or self-backed logos,
|
||||
/// animated images, and anything that fails to analyze decode unchanged.
|
||||
///
|
||||
/// The readback and remap are a one-time per-entry cost at logo decode sizes
|
||||
/// (≈65k pixels bounded by the guide cell budget); nothing runs per frame.
|
||||
@immutable
|
||||
class ToneMappedLogoImage extends ImageProvider<ToneMappedLogoKey> {
|
||||
const ToneMappedLogoImage(this.imageProvider, {required this.target, this.remapMixed = true});
|
||||
|
||||
/// The provider performing the actual fetch and bounded decode.
|
||||
final ImageProvider imageProvider;
|
||||
|
||||
/// The theme foreground color light-toned pixels are remapped toward.
|
||||
final Color target;
|
||||
|
||||
/// Whether [LogoTone.lightMixed] frames — light content beside significant
|
||||
/// color — are remapped too. The guide's tiny channel cells want maximum
|
||||
/// legibility and keep this on; hero surfaces pass false so a mark whose
|
||||
/// color is part of its identity (a red-outline wordmark, a colored badge)
|
||||
/// renders untouched. [LogoTone.lightMonochrome] and
|
||||
/// [LogoTone.lightAccented] always remap.
|
||||
final bool remapMixed;
|
||||
|
||||
/// Frames larger than this decode unchanged: channel logos are bounded far
|
||||
/// below it, so hitting the cap means the provider was applied to full-size
|
||||
/// artwork by mistake, and a multi-megapixel readback is not worth it.
|
||||
static const int _maxRemapPixels = 1 << 20;
|
||||
|
||||
@override
|
||||
Future<ToneMappedLogoKey> obtainKey(ImageConfiguration configuration) {
|
||||
Completer<ToneMappedLogoKey>? completer;
|
||||
// If the inner obtainKey completes synchronously, result is filled in
|
||||
// before the completer is created, preserving the synchronous path the
|
||||
// ImageCache relies on to avoid flicker (same dance as ResizeImage).
|
||||
SynchronousFuture<ToneMappedLogoKey>? result;
|
||||
imageProvider.obtainKey(configuration).then((Object key) {
|
||||
final mappedKey = ToneMappedLogoKey._(key, target.toARGB32(), remapMixed);
|
||||
if (completer == null) {
|
||||
result = SynchronousFuture<ToneMappedLogoKey>(mappedKey);
|
||||
} else {
|
||||
completer.complete(mappedKey);
|
||||
}
|
||||
});
|
||||
if (result != null) {
|
||||
return result!;
|
||||
}
|
||||
completer = Completer<ToneMappedLogoKey>();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ToneMappedLogoKey key, ImageDecoderCallback decode) {
|
||||
Future<ui.Codec> decodeToneMapped(ui.ImmutableBuffer buffer, {ui.TargetImageSizeCallback? getTargetSize}) async {
|
||||
final codec = await decode(buffer, getTargetSize: getTargetSize);
|
||||
if (codec.frameCount != 1) return codec;
|
||||
return _toneMapCodec(codec);
|
||||
}
|
||||
|
||||
final completer = imageProvider.loadImage(key._providerCacheKey, decodeToneMapped);
|
||||
if (!kReleaseMode) {
|
||||
completer.debugLabel = '${completer.debugLabel} - ToneMapped(${target.toARGB32().toRadixString(16)})';
|
||||
}
|
||||
return completer;
|
||||
}
|
||||
|
||||
/// Replaces a single-frame [codec] with a codec serving either the remapped
|
||||
/// frame or, when the frame should render unchanged or fails to analyze,
|
||||
/// the original frame. The engine codec's sole frame is consumed here, so
|
||||
/// the original codec is never handed back once decoding succeeded; a
|
||||
/// failing [ui.Codec.getNextFrame] propagates with the codec untouched and
|
||||
/// surfaces through the stream completer like any decode failure.
|
||||
Future<ui.Codec> _toneMapCodec(ui.Codec codec) async {
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
try {
|
||||
ui.Image? remapped;
|
||||
try {
|
||||
remapped = await _remapImage(image);
|
||||
} catch (error, stackTrace) {
|
||||
// Best effort: a failed remap falls back to the original artwork,
|
||||
// which is exactly what renders today.
|
||||
appLogger.w('Logo tone remap failed', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
final result = remapped ?? image.clone();
|
||||
codec.dispose();
|
||||
return _SingleFrameCodec(result);
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the remapped frame, or null when the image should render
|
||||
/// unchanged. The raw-pixel descriptor decodes lazily, so the frame is
|
||||
/// pulled while the pixel buffer is still alive — the same lifetime dance
|
||||
/// as the engine's `decodeImageFromPixels`.
|
||||
Future<ui.Image?> _remapImage(ui.Image image) async {
|
||||
if (image.width * image.height > _maxRemapPixels) return null;
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
if (data == null) return null;
|
||||
final tone = analyzeLogoTone(data, image.width, image.height, stride: 2);
|
||||
final remappable =
|
||||
tone == LogoTone.lightMonochrome ||
|
||||
tone == LogoTone.lightAccented ||
|
||||
(remapMixed && tone == LogoTone.lightMixed);
|
||||
if (!remappable) return null;
|
||||
|
||||
final mapped = remapLightNeutral(data, targetArgb: target.toARGB32());
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(mapped);
|
||||
final descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
rowBytes: image.width * 4,
|
||||
pixelFormat: ui.PixelFormat.rgba8888,
|
||||
);
|
||||
final remappedCodec = await descriptor.instantiateCodec();
|
||||
try {
|
||||
final frame = await remappedCodec.getNextFrame();
|
||||
return frame.image;
|
||||
} finally {
|
||||
remappedCodec.dispose();
|
||||
descriptor.dispose();
|
||||
buffer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ToneMappedLogoImage &&
|
||||
other.imageProvider == imageProvider &&
|
||||
other.target == target &&
|
||||
other.remapMixed == remapMixed;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(imageProvider, target, remapMixed);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'ToneMappedLogoImage')}($imageProvider, target: $target)';
|
||||
}
|
||||
|
||||
/// Serves one already-decoded frame, cloning it per pull so every caller owns
|
||||
/// its copy. Replaces the engine codec after its lazy single frame has been
|
||||
/// consumed for analysis; the engine's raw-pixel codecs decode lazily from
|
||||
/// their descriptor, so they cannot outlive the disposal of their buffer the
|
||||
/// way this wrapper can.
|
||||
class _SingleFrameCodec implements ui.Codec {
|
||||
_SingleFrameCodec(this._image);
|
||||
|
||||
final ui.Image _image;
|
||||
|
||||
@override
|
||||
int get frameCount => 1;
|
||||
|
||||
@override
|
||||
int get repetitionCount => 0;
|
||||
|
||||
@override
|
||||
Future<ui.FrameInfo> getNextFrame() async => _SingleFrameInfo(_image.clone());
|
||||
|
||||
@override
|
||||
void dispose() => _image.dispose();
|
||||
}
|
||||
|
||||
class _SingleFrameInfo implements ui.FrameInfo {
|
||||
const _SingleFrameInfo(this.image);
|
||||
|
||||
@override
|
||||
final ui.Image image;
|
||||
|
||||
@override
|
||||
Duration get duration => Duration.zero;
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
bool _isWaitingForHostReconnect = false;
|
||||
bool _hostIntentionallyLeft = false;
|
||||
|
||||
// Display name of the guest a host transfer was requested for; names the
|
||||
// failure toast if the relay rejects the request.
|
||||
String? _pendingTransferTargetName;
|
||||
|
||||
// Debounce map for action events (peerId+type → last emission timestamp)
|
||||
final Map<String, int> _lastActionEventMs = {};
|
||||
|
||||
@@ -100,6 +104,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
StreamSubscription<PeerError>? _errorSubscription;
|
||||
StreamSubscription<void>? _sessionEndedSubscription;
|
||||
StreamSubscription<String>? _hostChangedSubscription;
|
||||
|
||||
// Getters
|
||||
bool get isInSession => _session != null;
|
||||
@@ -508,11 +513,13 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_observeSubscriptionCancellation(_messageSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_errorSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_sessionEndedSubscription?.cancel());
|
||||
_observeSubscriptionCancellation(_hostChangedSubscription?.cancel());
|
||||
_peerConnectedSubscription = null;
|
||||
_peerDisconnectedSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_errorSubscription = null;
|
||||
_sessionEndedSubscription = null;
|
||||
_hostChangedSubscription = null;
|
||||
|
||||
_hostReconnectTimer?.cancel();
|
||||
_hostReconnectTimer = null;
|
||||
@@ -529,6 +536,7 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
_playbackPhase = null;
|
||||
_playbackDispatcher.reset();
|
||||
_lastActionEventMs.clear();
|
||||
_pendingTransferTargetName = null;
|
||||
_hostIntentionallyLeft = false;
|
||||
|
||||
if (!_disposed) notifyListeners();
|
||||
@@ -689,6 +697,19 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
appLogger.d('WatchTogether: Declared host is not connected yet; keeping the retained-room join pending');
|
||||
return;
|
||||
}
|
||||
if (error.serverCode == RelayProtocol.notHostCode || error.serverCode == RelayProtocol.peerNotFoundCode) {
|
||||
// A rejected host transfer is not a session failure — surface a toast
|
||||
// and keep the room connected.
|
||||
appLogger.w('WatchTogether: Host transfer rejected: ${error.message}');
|
||||
final targetName = _pendingTransferTargetName;
|
||||
_pendingTransferTargetName = null;
|
||||
if (targetName != null) {
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: targetName, type: ParticipantEventType.hostTransferFailed),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
appLogger.e('WatchTogether: Peer error: ${error.message}');
|
||||
|
||||
// Only established transport stream errors are recoverable. Relay and
|
||||
@@ -713,6 +734,10 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
});
|
||||
_hostChangedSubscription = peerService.onHostChanged.listen((newHostPeerId) {
|
||||
if (_disposed || !identical(_peerService, peerService)) return;
|
||||
_handleHostChanged(newHostPeerId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle incoming sync messages for participant management
|
||||
@@ -878,6 +903,79 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether the current user (as host) may hand host authority to
|
||||
/// [participant]: connected guest speaking the current sync protocol.
|
||||
bool canTransferHostTo(Participant participant) {
|
||||
final peerService = _peerService;
|
||||
final controller = _controller;
|
||||
if (peerService == null || controller == null) return false;
|
||||
if (!isHost || !isConnected) return false;
|
||||
if (participant.isHost || participant.peerId == peerService.myPeerId) return false;
|
||||
if (!peerService.connectedPeers.contains(participant.peerId)) return false;
|
||||
return controller.isPeerCompatible(participant.peerId);
|
||||
}
|
||||
|
||||
/// Ask the relay to make [participant] the host (host only). Roles flip
|
||||
/// when the relay's `hostChanged` broadcast arrives; a rejection surfaces
|
||||
/// as a [ParticipantEventType.hostTransferFailed] event.
|
||||
void transferHost(Participant participant) {
|
||||
if (!canTransferHostTo(participant)) {
|
||||
appLogger.w('WatchTogether: Ignoring host transfer to ineligible peer ${participant.peerId}');
|
||||
return;
|
||||
}
|
||||
appLogger.d('WatchTogether: Requesting host transfer to ${participant.peerId}');
|
||||
_pendingTransferTargetName = participant.displayName;
|
||||
_peerService!.transferHost(participant.peerId);
|
||||
}
|
||||
|
||||
/// The relay reassigned host authority ([peerService] already flipped its
|
||||
/// own role state): rebuild session/participants and swap the controller's
|
||||
/// role engine in place.
|
||||
void _handleHostChanged(String newHostPeerId) {
|
||||
final session = _session;
|
||||
final peerService = _peerService;
|
||||
if (session == null || peerService == null) return;
|
||||
|
||||
final wasHost = session.isHost;
|
||||
final amHost = newHostPeerId == peerService.myPeerId;
|
||||
_pendingTransferTargetName = null;
|
||||
|
||||
// Any host-departure bookkeeping referred to the previous host.
|
||||
_cancelHostReconnectGracePeriod();
|
||||
_hostIntentionallyLeft = false;
|
||||
|
||||
final updated = session.copyWith(role: amHost ? SessionRole.host : SessionRole.guest, hostPeerId: newHostPeerId);
|
||||
_session = updated;
|
||||
|
||||
for (var i = 0; i < _participants.length; i++) {
|
||||
final isHostNow = _participants[i].peerId == newHostPeerId;
|
||||
if (_participants[i].isHost != isHostNow) {
|
||||
_participants[i] = _participants[i].copyWith(isHost: isHostNow);
|
||||
}
|
||||
}
|
||||
|
||||
_controller?.applyHostChange(updated);
|
||||
|
||||
if (amHost && !wasHost) {
|
||||
// Re-announce as host — the lobby-safe carrier that teaches guests the
|
||||
// room's control mode now comes from this peer.
|
||||
_controller?.announceJoin(_displayName);
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(displayName: _displayName, type: ParticipantEventType.becameHost),
|
||||
);
|
||||
} else if (!amHost) {
|
||||
_participantEventController.add(
|
||||
ParticipantEvent(
|
||||
displayName: _displayNameForPeer(newHostPeerId) ?? '?',
|
||||
type: ParticipantEventType.hostChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
appLogger.d('WatchTogether: Host changed to $newHostPeerId (self: $amHost)');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Notify guests that host is exiting the video player
|
||||
///
|
||||
/// Call this from video player dispose when host exits.
|
||||
@@ -961,7 +1059,19 @@ class WatchTogetherProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Type of participant event
|
||||
enum ParticipantEventType { joined, left, paused, resumed, seeked, buffering, needsUpdate, resumedWithout }
|
||||
enum ParticipantEventType {
|
||||
joined,
|
||||
left,
|
||||
paused,
|
||||
resumed,
|
||||
seeked,
|
||||
buffering,
|
||||
needsUpdate,
|
||||
resumedWithout,
|
||||
hostChanged,
|
||||
becameHost,
|
||||
hostTransferFailed,
|
||||
}
|
||||
|
||||
/// Event emitted when a participant joins or leaves
|
||||
class ParticipantEvent {
|
||||
|
||||
@@ -606,7 +606,13 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(participant.displayName, style: theme.textTheme.bodyMedium),
|
||||
Flexible(
|
||||
child: Text(
|
||||
participant.displayName,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (participant.isHost) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
@@ -629,6 +635,17 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
|
||||
),
|
||||
],
|
||||
if (watchTogether.canTransferHostTo(participant)) ...[
|
||||
const Spacer(),
|
||||
FocusableButton(
|
||||
useBackgroundFocus: true,
|
||||
onPressed: () => _confirmTransferHost(context, participant),
|
||||
child: TextButton(
|
||||
onPressed: () => _confirmTransferHost(context, participant),
|
||||
child: Text(t.watchTogether.makeHost),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -682,6 +699,17 @@ class _ActiveSessionContent extends StatelessWidget {
|
||||
appLogger.e('WatchTogether: Session leave failed', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmTransferHost(BuildContext context, Participant participant) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.watchTogether.makeHostQuestion,
|
||||
message: t.watchTogether.makeHostConfirm(name: participant.displayName),
|
||||
confirmText: t.watchTogether.transfer,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
watchTogether.transferHost(participant);
|
||||
}
|
||||
}
|
||||
|
||||
class _JoinCurrentPlaybackCard extends StatefulWidget {
|
||||
|
||||
@@ -83,6 +83,24 @@ class ClockSync {
|
||||
_pending.clear();
|
||||
}
|
||||
|
||||
/// Discard every sample and in-flight ping (the peer the offset was
|
||||
/// measured against changed — e.g. a host transfer) and re-converge
|
||||
/// against the current host with a fresh burst.
|
||||
void reset() {
|
||||
_samples.clear();
|
||||
if (_started) {
|
||||
_started = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_burstTimer?.cancel();
|
||||
_burstTimer = null;
|
||||
_pending.clear();
|
||||
start();
|
||||
} else {
|
||||
_pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _ping() {
|
||||
final now = _nowMs();
|
||||
_pending.removeWhere((_, sentAt) => now - sentAt > _pendingExpiryMs);
|
||||
|
||||
@@ -113,6 +113,11 @@ class GuestPlaybackReconciler {
|
||||
|
||||
PlaybackState? get latestState => _latestState;
|
||||
|
||||
/// Whether the attached player has rendered a frame — carried across a
|
||||
/// host-transfer engine swap so the promoted coordinator doesn't wait for
|
||||
/// a first frame that already happened.
|
||||
bool get firstFrameSeen => _firstFrameSeen;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -111,6 +111,11 @@ class HostPlaybackCoordinator {
|
||||
|
||||
PlaybackPhase get phase => _phase;
|
||||
|
||||
/// Whether the attached player has reported readiness — carried across a
|
||||
/// host-transfer engine swap so the demoted reconciler doesn't wait for a
|
||||
/// first frame that already happened.
|
||||
bool get localPlayerReady => _localReady;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public inputs
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -146,18 +151,25 @@ class HostPlaybackCoordinator {
|
||||
/// screen's first-frame snapshot (covers attaching to an already-rendering
|
||||
/// player); [startupHold] delays readiness past platform startup gates
|
||||
/// (e.g. the Android frame-rate switch).
|
||||
///
|
||||
/// [intendPlaying] seeds the room's play intent for the new epoch. The
|
||||
/// default (true) is the normal "opening media implies play" flow; a host
|
||||
/// promoted mid-session passes the room's last known intent so a paused
|
||||
/// room doesn't start playing just because its host changed.
|
||||
void attach(
|
||||
AttachedPlayer player, {
|
||||
required String ratingKey,
|
||||
required String serverId,
|
||||
String? mediaTitle,
|
||||
bool hasFirstFrame = false,
|
||||
bool intendPlaying = true,
|
||||
Future<void>? startupHold,
|
||||
}) {
|
||||
detachPlayer();
|
||||
final sameEpoch =
|
||||
hasActiveEpoch && PlaybackState.mediaKeyFor(ratingKey: ratingKey, serverId: serverId) == _mediaKey;
|
||||
setLocalMedia(ratingKey: ratingKey, serverId: serverId, mediaTitle: mediaTitle);
|
||||
_intendedPlaying = intendPlaying;
|
||||
|
||||
_player = player;
|
||||
_rate = player.rate;
|
||||
|
||||
@@ -11,6 +11,7 @@ abstract final class RelayProtocol {
|
||||
static const String ping = 'ping';
|
||||
static const String leave = 'leave';
|
||||
static const String endSession = 'endSession';
|
||||
static const String transferHost = 'transferHost';
|
||||
static const String created = 'created';
|
||||
static const String joined = 'joined';
|
||||
static const String peerJoined = 'peerJoined';
|
||||
@@ -20,6 +21,7 @@ abstract final class RelayProtocol {
|
||||
static const String pong = 'pong';
|
||||
static const String left = 'left';
|
||||
static const String ended = 'ended';
|
||||
static const String hostChanged = 'hostChanged';
|
||||
static const String rateLimitedCode = 'rate_limited';
|
||||
static const String invalidMessageCode = 'invalid_message';
|
||||
static const String roomExistsCode = 'room_exists';
|
||||
@@ -29,6 +31,8 @@ abstract final class RelayProtocol {
|
||||
static const String alreadyInRoomCode = 'already_in_room';
|
||||
static const String peerIdUnavailableCode = 'peer_id_unavailable';
|
||||
static const String protocolMismatchCode = 'protocol_mismatch';
|
||||
static const String notHostCode = 'not_host';
|
||||
static const String peerNotFoundCode = 'peer_not_found';
|
||||
|
||||
static const int maxRoomSize = 8;
|
||||
static const int maxMessageSize = 65536;
|
||||
|
||||
@@ -31,37 +31,45 @@ class WatchTogetherController {
|
||||
_session = session,
|
||||
_nowMs = nowMs ?? watchTogetherSystemNowMs {
|
||||
if (session.isHost) {
|
||||
_coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: peerService.myPeerId ?? '',
|
||||
controlMode: session.controlMode,
|
||||
sendState: _sendState,
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onResumedWithout: (peers) => onResumedWithout?.call(peers),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_createCoordinator();
|
||||
} else {
|
||||
_clockSync = ClockSync(sendPing: _sendClockPing, nowMs: _nowMs);
|
||||
_reconciler = GuestPlaybackReconciler(
|
||||
myPeerId: peerService.myPeerId ?? '',
|
||||
sendToHost: _sendToHost,
|
||||
clockSync: _clockSync!,
|
||||
onMediaSwitchNeeded: (ratingKey, serverId, title) => onMediaStateReceived?.call(ratingKey, serverId, title),
|
||||
onControlModeChanged: (mode) => onControlModeReceived?.call(mode),
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onCorrectingChanged: (correcting) => onCorrectingChanged?.call(correcting),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_clockSync!.start();
|
||||
_createReconciler();
|
||||
}
|
||||
|
||||
_subscriptions.add(peerService.onMessageReceived.listen(_enqueueMessage));
|
||||
_subscriptions.add(peerService.onPeerDisconnected.listen(_handlePeerDisconnected));
|
||||
}
|
||||
|
||||
void _createCoordinator() {
|
||||
_coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: _peerService.myPeerId ?? '',
|
||||
controlMode: _session.controlMode,
|
||||
sendState: _sendState,
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onResumedWithout: (peers) => onResumedWithout?.call(peers),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _createReconciler() {
|
||||
_clockSync = ClockSync(sendPing: _sendClockPing, nowMs: _nowMs);
|
||||
_reconciler = GuestPlaybackReconciler(
|
||||
myPeerId: _peerService.myPeerId ?? '',
|
||||
sendToHost: _sendToHost,
|
||||
clockSync: _clockSync!,
|
||||
onMediaSwitchNeeded: (ratingKey, serverId, title) => onMediaStateReceived?.call(ratingKey, serverId, title),
|
||||
onControlModeChanged: (mode) => onControlModeReceived?.call(mode),
|
||||
onPhaseChanged: (phase) => onPhaseChanged?.call(phase),
|
||||
onWaitingOnChanged: (peers) => onWaitingOnChanged?.call(peers),
|
||||
onCorrectingChanged: (correcting) => onCorrectingChanged?.call(correcting),
|
||||
onRemoteAction: (peer, hint) => onRemoteAction?.call(peer, hint),
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_clockSync!.start();
|
||||
}
|
||||
|
||||
final WatchTogetherPeerService _peerService;
|
||||
final int Function() _nowMs;
|
||||
WatchSession _session;
|
||||
@@ -71,6 +79,9 @@ class WatchTogetherController {
|
||||
ClockSync? _clockSync;
|
||||
|
||||
AttachedPlayer? _attachedPlayer;
|
||||
String? _attachedRatingKey;
|
||||
String? _attachedServerId;
|
||||
String? _attachedMediaTitle;
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
Future<void> _messageQueue = Future.value();
|
||||
bool _disposed = false;
|
||||
@@ -100,6 +111,78 @@ class WatchTogetherController {
|
||||
_coordinator?.updateControlMode(session.controlMode);
|
||||
}
|
||||
|
||||
/// Whether [peerId] speaks the current sync protocol (drives host-transfer
|
||||
/// eligibility — a peer on an old protocol can't take over the room).
|
||||
bool isPeerCompatible(String peerId) => (_peerVersions[peerId] ?? 1) == SyncMessage.protocolVersion;
|
||||
|
||||
/// The relay reassigned host authority: swap the role engine while keeping
|
||||
/// the session, message queue, peer knowledge, and player attachment.
|
||||
///
|
||||
/// [session] already carries the new role and host peer ID.
|
||||
void applyHostChange(WatchSession session) {
|
||||
final wasHost = _session.isHost;
|
||||
_session = session;
|
||||
|
||||
if (wasHost == session.isHost) {
|
||||
if (!session.isHost) {
|
||||
// Still a guest, but the authority (and its clock) moved: discard
|
||||
// offsets and sequence numbering learned from the old host and
|
||||
// re-converge against the new one.
|
||||
_clockSync?.reset();
|
||||
_reconciler?.resetSequence();
|
||||
requestState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final attached = _attachedPlayer;
|
||||
if (session.isHost) {
|
||||
// Promotion: adopt the room where the old host left it.
|
||||
final lastState = _reconciler?.latestState;
|
||||
final firstFrameSeen = _reconciler?.firstFrameSeen ?? false;
|
||||
_clockSync?.stop();
|
||||
_clockSync = null;
|
||||
_reconciler?.dispose();
|
||||
_reconciler = null;
|
||||
_createCoordinator();
|
||||
if (attached != null && _attachedRatingKey != null && _attachedServerId != null) {
|
||||
_coordinator!.attach(
|
||||
attached,
|
||||
ratingKey: _attachedRatingKey!,
|
||||
serverId: _attachedServerId!,
|
||||
mediaTitle: _attachedMediaTitle,
|
||||
hasFirstFrame: firstFrameSeen,
|
||||
// A paused room must not start playing just because its host
|
||||
// changed; anything else (playing, a stall, mid-load) carries the
|
||||
// intent to (re)start.
|
||||
intendPlaying: lastState == null || lastState.phase != PlaybackPhase.paused,
|
||||
);
|
||||
}
|
||||
// Seed the roster so the fresh epoch gates on the peers we already
|
||||
// know instead of solo-starting before their first status report.
|
||||
for (final entry in _peerVersions.entries) {
|
||||
if (entry.key == _peerService.myPeerId) continue;
|
||||
_coordinator!.onPeerJoined(entry.key, compatible: entry.value == SyncMessage.protocolVersion);
|
||||
}
|
||||
} else {
|
||||
// Demotion: hand the room to the new host and fall in line.
|
||||
final localReady = _coordinator?.localPlayerReady ?? false;
|
||||
_coordinator?.dispose();
|
||||
_coordinator = null;
|
||||
_createReconciler();
|
||||
if (attached != null && _attachedRatingKey != null && _attachedServerId != null) {
|
||||
_reconciler!.attach(
|
||||
attached,
|
||||
ratingKey: _attachedRatingKey!,
|
||||
serverId: _attachedServerId!,
|
||||
hasFirstFrame: localReady,
|
||||
);
|
||||
}
|
||||
requestState();
|
||||
}
|
||||
appLogger.d('WatchTogether: Host change applied (host: ${session.isHost})');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Player attachment
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -131,6 +214,9 @@ class WatchTogetherController {
|
||||
nowMs: _nowMs,
|
||||
);
|
||||
_attachedPlayer = attached;
|
||||
_attachedRatingKey = ratingKey;
|
||||
_attachedServerId = serverId;
|
||||
_attachedMediaTitle = mediaTitle;
|
||||
|
||||
if (_session.isHost) {
|
||||
_coordinator!.attach(
|
||||
@@ -159,6 +245,9 @@ class WatchTogetherController {
|
||||
final attached = _attachedPlayer;
|
||||
if (attached == null) return;
|
||||
_attachedPlayer = null;
|
||||
_attachedRatingKey = null;
|
||||
_attachedServerId = null;
|
||||
_attachedMediaTitle = null;
|
||||
_coordinator?.detachPlayer(exiting: exiting);
|
||||
_reconciler?.detachPlayer();
|
||||
unawaited(
|
||||
|
||||
@@ -82,6 +82,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<bool>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
final _hostChangedController = StreamController<String>.broadcast();
|
||||
|
||||
// Reconnection state
|
||||
int _reconnectAttempts = 0;
|
||||
@@ -124,6 +125,11 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
/// Emitted when the host has durably ended the relay room.
|
||||
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||
|
||||
/// Emitted with the new host's peer ID when the relay reassigns host
|
||||
/// authority ([transferHost]). [hostPeerId] and [isHost] are already
|
||||
/// updated when this fires.
|
||||
Stream<String> get onHostChanged => _hostChangedController.stream;
|
||||
|
||||
/// Current session ID (null if not in a session)
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
@@ -489,6 +495,20 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_failSetup(_invalidSetupResponse(RelayProtocol.ended));
|
||||
}
|
||||
|
||||
case RelayProtocol.hostChanged:
|
||||
final newHostPeerId = msg['hostPeerId'];
|
||||
if (msg['sessionId'] != _sessionId ||
|
||||
newHostPeerId is! String ||
|
||||
!RelayProtocol.isValidPeerId(newHostPeerId)) {
|
||||
appLogger.w('WatchTogether: Relay returned an invalid hostChanged message');
|
||||
break;
|
||||
}
|
||||
if (newHostPeerId == _hostPeerId) break; // Duplicate delivery.
|
||||
appLogger.d('WatchTogether: Host authority moved to $newHostPeerId');
|
||||
_hostPeerId = newHostPeerId;
|
||||
_isHost = newHostPeerId == _myPeerId;
|
||||
_safeAdd(_hostChangedController, newHostPeerId);
|
||||
|
||||
case RelayProtocol.error:
|
||||
final code = msg['code'] as String? ?? 'unknown';
|
||||
final message = msg['message'] as String? ?? t.common.unknown;
|
||||
@@ -773,6 +793,18 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload});
|
||||
}
|
||||
|
||||
/// Ask the relay to reassign host authority to [peerId] (host only).
|
||||
///
|
||||
/// The relay answers with a `hostChanged` broadcast on success or a
|
||||
/// `not_host`/`peer_not_found` error on the error stream; local role state
|
||||
/// only flips when the broadcast arrives.
|
||||
void transferHost(String peerId) {
|
||||
if (!RelayProtocol.isValidPeerId(peerId)) {
|
||||
throw ArgumentError.value(peerId, 'peerId', 'Must be 1–${RelayProtocol.maxPeerIdLength} letters, digits, _ or -');
|
||||
}
|
||||
_sendRaw({'type': RelayProtocol.transferHost, 'to': peerId, 'protocolVersion': _relayProtocolVersion});
|
||||
}
|
||||
|
||||
/// Explicitly release this peer's relay ownership. Hosts destroy the room;
|
||||
/// guests release their reserved reconnect identity. If transport was lost,
|
||||
/// authenticate a fresh connection first so an intentional exit is not
|
||||
@@ -881,6 +913,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
_disposed = true;
|
||||
unawaited(disconnect());
|
||||
|
||||
_hostChangedController.close();
|
||||
_peerConnectedController.close();
|
||||
_peerDisconnectedController.close();
|
||||
_messageReceivedController.close();
|
||||
|
||||
@@ -215,31 +215,25 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
Text(t.watchTogether.participants, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
for (final participant in provider.participants)
|
||||
ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: participant.isHost
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
child: AppIcon(
|
||||
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
if (provider.canTransferHostTo(participant))
|
||||
FocusableListTile(
|
||||
leading: _participantAvatar(theme, participant),
|
||||
title: Text(participant.displayName),
|
||||
trailing:
|
||||
_bufferingIndicator(participant) ??
|
||||
AppIcon(Symbols.star_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||
onTap: () => unawaited(_confirmTransferHost(context, participant)),
|
||||
contentPadding: .zero,
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: _participantAvatar(theme, participant),
|
||||
title: Text(participant.displayName),
|
||||
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
|
||||
trailing: _bufferingIndicator(participant),
|
||||
dense: true,
|
||||
contentPadding: .zero,
|
||||
),
|
||||
title: Text(participant.displayName),
|
||||
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
|
||||
trailing: participant.isBuffering
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: PlatformDetector.isTV()
|
||||
? const AppIcon(Symbols.hourglass_empty_rounded, size: 16)
|
||||
: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
dense: true,
|
||||
contentPadding: .zero,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
@@ -259,6 +253,40 @@ class _SessionMenuSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _participantAvatar(ThemeData theme, Participant participant) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: participant.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
|
||||
child: AppIcon(
|
||||
participant.isHost ? Symbols.star_rounded : Symbols.person_rounded,
|
||||
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _bufferingIndicator(Participant participant) {
|
||||
if (!participant.isBuffering) return null;
|
||||
return SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: PlatformDetector.isTV()
|
||||
? const AppIcon(Symbols.hourglass_empty_rounded, size: 16)
|
||||
: const CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmTransferHost(BuildContext context, Participant participant) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.watchTogether.makeHostQuestion,
|
||||
message: t.watchTogether.makeHostConfirm(name: participant.displayName),
|
||||
confirmText: t.watchTogether.transfer,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
provider.transferHost(participant);
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
}
|
||||
|
||||
void _copySessionCode(BuildContext context, String sessionId) {
|
||||
Clipboard.setData(ClipboardData(text: sessionId));
|
||||
showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied);
|
||||
@@ -351,6 +379,9 @@ class _ParticipantNotificationOverlayState extends State<ParticipantNotification
|
||||
ParticipantEventType.buffering => t.watchTogether.participantBuffering(name: n.event.displayName),
|
||||
ParticipantEventType.needsUpdate => t.watchTogether.participantNeedsUpdate(name: n.event.displayName),
|
||||
ParticipantEventType.resumedWithout => t.watchTogether.resumingWithout(name: n.event.displayName),
|
||||
ParticipantEventType.hostChanged => t.watchTogether.hostChangedTo(name: n.event.displayName),
|
||||
ParticipantEventType.becameHost => t.watchTogether.youAreNowHost,
|
||||
ParticipantEventType.hostTransferFailed => t.watchTogether.hostTransferFailed(name: n.event.displayName),
|
||||
};
|
||||
return Container(
|
||||
key: ValueKey(n.id),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../services/device_performance.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import '../utils/obfuscation_utils.dart';
|
||||
import '../utils/tone_mapped_logo_image.dart';
|
||||
|
||||
/// Tracks recent image load failures to log a periodic summary instead of
|
||||
/// spamming per-image. Resets after [_logInterval] so recurring issues
|
||||
@@ -71,6 +72,16 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
/// Black tint applied at image paint time without an opacity save layer.
|
||||
final Animation<double>? artworkDim;
|
||||
|
||||
/// Recolors light-toned logo artwork toward this theme foreground so it
|
||||
/// stays legible on light surfaces (see [ToneMappedLogoImage]). Applies to
|
||||
/// both the network and local-file decode paths.
|
||||
final Color? logoToneTarget;
|
||||
|
||||
/// Forwards [ToneMappedLogoImage.remapMixed]: heroes pass false so marks
|
||||
/// with significant color render untouched; the guide's channel cells keep
|
||||
/// the default and remap mixed marks too.
|
||||
final bool logoToneRemapMixed;
|
||||
|
||||
const OptimizedMediaImage._({
|
||||
super.key,
|
||||
this.client,
|
||||
@@ -87,6 +98,8 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
this.imageType = ImageType.poster,
|
||||
this.localFilePath,
|
||||
this.artworkDim,
|
||||
this.logoToneTarget,
|
||||
this.logoToneRemapMixed = true,
|
||||
this.cacheMissingLocalFile = false,
|
||||
});
|
||||
|
||||
@@ -107,6 +120,8 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
ImageType imageType,
|
||||
String? localFilePath,
|
||||
Animation<double>? artworkDim,
|
||||
Color? logoToneTarget,
|
||||
bool logoToneRemapMixed,
|
||||
bool cacheMissingLocalFile,
|
||||
}) = OptimizedMediaImage._;
|
||||
|
||||
@@ -159,6 +174,8 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
Alignment alignment = Alignment.center,
|
||||
IconData? fallbackIcon,
|
||||
String? localFilePath,
|
||||
Color? logoToneTarget,
|
||||
bool logoToneRemapMixed = true,
|
||||
Animation<double>? artworkDim,
|
||||
}) : this._(
|
||||
key: key,
|
||||
@@ -175,6 +192,8 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
fallbackIcon: fallbackIcon ?? Symbols.video_library_rounded,
|
||||
imageType: ImageType.thumb,
|
||||
localFilePath: localFilePath,
|
||||
logoToneTarget: logoToneTarget,
|
||||
logoToneRemapMixed: logoToneRemapMixed,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
|
||||
@@ -237,11 +256,15 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
|
||||
imageType: imageType,
|
||||
);
|
||||
final bounded = MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight);
|
||||
final provider = logoToneTarget == null
|
||||
? bounded
|
||||
: ToneMappedLogoImage(bounded, target: logoToneTarget!, remapMixed: logoToneRemapMixed);
|
||||
|
||||
return _withArtworkDim(
|
||||
artworkDim,
|
||||
(tint) => Image(
|
||||
image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight),
|
||||
image: provider,
|
||||
width: width,
|
||||
height: height,
|
||||
// Artwork is decorative: the enclosing card exposes one merged node
|
||||
@@ -310,6 +333,8 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
imageUrl: imageUrl,
|
||||
memWidth: memWidth,
|
||||
memHeight: memHeight,
|
||||
logoToneTarget: logoToneTarget,
|
||||
logoToneRemapMixed: logoToneRemapMixed,
|
||||
);
|
||||
|
||||
// Reduced tier: swap in directly, no fade machinery at all.
|
||||
@@ -450,6 +475,7 @@ class ClearLogoImage extends StatelessWidget {
|
||||
required this.fallbackBuilder,
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.fadeInDuration = const Duration(milliseconds: 300),
|
||||
this.logoToneTarget,
|
||||
});
|
||||
|
||||
final MediaServerClient? client;
|
||||
@@ -460,6 +486,10 @@ class ClearLogoImage extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final Duration fadeInDuration;
|
||||
|
||||
/// See [OptimizedMediaImage.logoToneTarget]; heroes pass a target when the
|
||||
/// backdrop behind the logo is scrimmed toward a light background.
|
||||
final Color? logoToneTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = logoPath;
|
||||
@@ -477,6 +507,10 @@ class ClearLogoImage extends StatelessWidget {
|
||||
alignment: alignment,
|
||||
imageType: ImageType.heroLogo,
|
||||
fadeInDuration: fadeInDuration,
|
||||
logoToneTarget: logoToneTarget,
|
||||
// Clear logos render on heroes where a mark's color is part of
|
||||
// its identity: mixed-tone marks stay untouched.
|
||||
logoToneRemapMixed: false,
|
||||
placeholder: (context, _) => const SizedBox.shrink(),
|
||||
errorWidget: (context, _, _) => fallbackBuilder(context),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../utils/formatters.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/tone_mapped_logo_image.dart';
|
||||
import 'cycling_media_backdrop.dart';
|
||||
import 'fitting_title_text.dart';
|
||||
import 'fitted_metadata_line.dart';
|
||||
@@ -224,6 +225,13 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) {
|
||||
final theme = Theme.of(context);
|
||||
// The spotlight scrim washes artwork toward the scaffold background, so
|
||||
// light themes recolor light-toned logos to stay visible.
|
||||
final logoToneTarget = logoToneTargetFor(
|
||||
surface: theme.scaffoldBackgroundColor,
|
||||
foreground: theme.colorScheme.onSurface,
|
||||
);
|
||||
final scale = _scale(context);
|
||||
final logoPath = media.clearLogoPath;
|
||||
final logoWidth = _logoWidth(scale);
|
||||
@@ -240,16 +248,19 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
|
||||
final localLogoPath = localArtworkPathResolver?.call(logoPath);
|
||||
if (localLogoPath != null && File(localLogoPath).existsSync()) {
|
||||
final bounded = MediaImageHelper.boundedDecode(
|
||||
FileImage(File(localLogoPath)),
|
||||
memWidth: logoMemWidth,
|
||||
memHeight: logoMemHeight,
|
||||
);
|
||||
return SizedBox(
|
||||
width: logoWidth,
|
||||
height: logoHeight,
|
||||
child: blurArtwork(
|
||||
Image(
|
||||
image: MediaImageHelper.boundedDecode(
|
||||
FileImage(File(localLogoPath)),
|
||||
memWidth: logoMemWidth,
|
||||
memHeight: logoMemHeight,
|
||||
),
|
||||
image: logoToneTarget == null
|
||||
? bounded
|
||||
: ToneMappedLogoImage(bounded, target: logoToneTarget, remapMixed: false),
|
||||
fit: BoxFit.contain,
|
||||
alignment: .centerLeft,
|
||||
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
|
||||
@@ -266,6 +277,7 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
width: logoWidth,
|
||||
height: logoHeight,
|
||||
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
|
||||
logoToneTarget: logoToneTarget,
|
||||
fallbackBuilder: (context) => _buildTitle(context, title),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,6 +373,34 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.right));
|
||||
}
|
||||
|
||||
/// Player entry and resume both funnel here: reapply the remembered swipe
|
||||
/// brightness (#2178) before re-reading the gesture baselines, so the next
|
||||
/// swipe starts from the level actually on screen.
|
||||
void _handleDeviceAdjustmentResume() => unawaited(_applyRememberedBrightnessThenRefresh());
|
||||
|
||||
Future<void> _applyRememberedBrightnessThenRefresh() async {
|
||||
final settings = SettingsService.instance;
|
||||
if (settings.read(SettingsService.rememberBrightnessLevel) &&
|
||||
settings.read(SettingsService.gestureBrightnessSwipe)) {
|
||||
final value = settings.read(SettingsService.rememberedBrightnessLevel);
|
||||
// Negative means "never set"; the write below only stores 0.0-1.0.
|
||||
if (value >= 0.0 && value <= 1.0) {
|
||||
// Await the queued set so the baseline read cannot race past it.
|
||||
await _deviceAdjustmentService.setBrightness(value);
|
||||
}
|
||||
}
|
||||
if (mounted) _refreshDeviceAdjustmentValues();
|
||||
}
|
||||
|
||||
/// Persist the level a finished brightness swipe settled on (#2178). Runs
|
||||
/// once per gesture, not per write, to spare SharedPreferences the drag spam.
|
||||
void _persistRememberedBrightness() {
|
||||
if (!SettingsService.instance.read(SettingsService.rememberBrightnessLevel)) return;
|
||||
final value = _lastKnownBrightness;
|
||||
if (value == null) return;
|
||||
unawaited(SettingsService.instance.write(SettingsService.rememberedBrightnessLevel, value));
|
||||
}
|
||||
|
||||
Future<double?> _readEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
|
||||
++_edgeAdjustmentBaselineGeneration;
|
||||
_edgeAdjustmentBaselineSide = side;
|
||||
@@ -496,6 +524,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
||||
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
||||
_edgeAdjustmentWasActive = true;
|
||||
_edgeAdjustmentActiveSide = side;
|
||||
_edgeAdjustmentStartValue = startValue;
|
||||
_lastEdgeAdjustmentWriteAt = null;
|
||||
_lastEdgeAdjustmentWriteValue = null;
|
||||
@@ -542,7 +571,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
|
||||
void _finishEdgeAdjustment({required bool suppressTap}) {
|
||||
if (suppressTap) _suppressTouchTaps();
|
||||
if (_edgeAdjustmentWasActive && _edgeAdjustmentActiveSide == MobileEdgeAdjustmentSide.left) {
|
||||
_persistRememberedBrightness();
|
||||
}
|
||||
_edgeAdjustmentWasActive = false;
|
||||
_edgeAdjustmentActiveSide = null;
|
||||
_edgeAdjustmentStartValue = null;
|
||||
_lastEdgeAdjustmentWriteAt = null;
|
||||
_lastEdgeAdjustmentWriteValue = null;
|
||||
|
||||
@@ -818,6 +818,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
));
|
||||
double? _edgeAdjustmentStartValue;
|
||||
bool _edgeAdjustmentWasActive = false;
|
||||
MobileEdgeAdjustmentSide? _edgeAdjustmentActiveSide;
|
||||
MobileEdgeAdjustmentSide? _pendingEdgeAdjustmentSide;
|
||||
double _pendingEdgeAdjustmentDelta = 0.0;
|
||||
int? _pendingEdgeAdjustmentGeneration;
|
||||
@@ -960,12 +961,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_listenToPlayingState();
|
||||
_listenToCompleted();
|
||||
_checkPipSupport();
|
||||
_deviceAdjustmentService.onResume = _refreshDeviceAdjustmentValues;
|
||||
_deviceAdjustmentService.onResume = _handleDeviceAdjustmentResume;
|
||||
_deviceAdjustmentService.setRestoreSuppressed(_pipService.isPipActive.value);
|
||||
_pipService.isPipActive.addListener(_onEdgeAdjustmentPipChanged);
|
||||
_edgeAdjustmentLifecycleListener = AppLifecycleListener(
|
||||
onResume: _refreshDeviceAdjustmentValues,
|
||||
onShow: _refreshDeviceAdjustmentValues,
|
||||
onResume: _handleDeviceAdjustmentResume,
|
||||
onShow: _handleDeviceAdjustmentResume,
|
||||
onHide: _cancelEdgeAdjustmentGesture,
|
||||
onPause: _cancelEdgeAdjustmentGesture,
|
||||
);
|
||||
@@ -1001,7 +1002,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// shortcut.
|
||||
if (!_focusPlayPauseIfKeyboardMode()) _claimPlayerSurfaceFocus();
|
||||
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
|
||||
_refreshDeviceAdjustmentValues();
|
||||
_handleDeviceAdjustmentResume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-3
@@ -8,7 +8,8 @@
|
||||
"sendTo": "sendTo",
|
||||
"ping": "ping",
|
||||
"leave": "leave",
|
||||
"endSession": "endSession"
|
||||
"endSession": "endSession",
|
||||
"transferHost": "transferHost"
|
||||
},
|
||||
"serverMessageTypes": {
|
||||
"created": "created",
|
||||
@@ -19,7 +20,8 @@
|
||||
"error": "error",
|
||||
"pong": "pong",
|
||||
"left": "left",
|
||||
"ended": "ended"
|
||||
"ended": "ended",
|
||||
"hostChanged": "hostChanged"
|
||||
},
|
||||
"errorCodes": {
|
||||
"rateLimited": "rate_limited",
|
||||
@@ -30,7 +32,9 @@
|
||||
"notInRoom": "not_in_room",
|
||||
"alreadyInRoom": "already_in_room",
|
||||
"peerIdUnavailable": "peer_id_unavailable",
|
||||
"protocolMismatch": "protocol_mismatch"
|
||||
"protocolMismatch": "protocol_mismatch",
|
||||
"notHost": "not_host",
|
||||
"peerNotFound": "peer_not_found"
|
||||
},
|
||||
"limits": {
|
||||
"maxRoomSize": 8,
|
||||
|
||||
@@ -2278,6 +2278,80 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
guest.close()
|
||||
}
|
||||
|
||||
case relayTypeTransferHost:
|
||||
if currentRoom == nil {
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotInRoom, Message: "Not in a room"})
|
||||
continue
|
||||
}
|
||||
room := currentRoom
|
||||
room.mu.Lock()
|
||||
authorized :=
|
||||
!room.closing &&
|
||||
room.Peers[currentPeerID] == client &&
|
||||
msg.ProtocolVersion == room.ProtocolVersion
|
||||
if !authorized {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerIdUnavailable, Message: "Unable to transfer host authority"})
|
||||
continue
|
||||
}
|
||||
if currentPeerID != room.HostPeerID {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotHost, Message: "Only the host can transfer host authority"})
|
||||
continue
|
||||
}
|
||||
if room.ProtocolVersion != relayProtocolVersion {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Host transfer requires current protocol"})
|
||||
continue
|
||||
}
|
||||
targetPeerID := msg.To
|
||||
targetConnected := false
|
||||
if validRelayID(targetPeerID, maxPeerIDLength) && targetPeerID != currentPeerID {
|
||||
_, targetConnected = room.Peers[targetPeerID]
|
||||
}
|
||||
if !targetConnected {
|
||||
room.mu.Unlock()
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerNotFound, Message: "Target peer not found"})
|
||||
continue
|
||||
}
|
||||
targetReservation, targetReserved := room.peerReservations[targetPeerID]
|
||||
if !targetReserved || targetReservation.releasePending {
|
||||
room.mu.Unlock()
|
||||
if !targetReserved {
|
||||
// Connected modern-room guests always hold a reservation.
|
||||
log.Printf("transferHost: connected guest missing reservation")
|
||||
}
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorPeerNotFound, Message: "Target peer not found"})
|
||||
continue
|
||||
}
|
||||
|
||||
// Swap authority. The host never holds a peer reservation (snapshot
|
||||
// loading rejects that), so the target's reservation becomes the
|
||||
// host verifier and the old host gains a connected reservation.
|
||||
// Reconnect tokens are untouched: both peers keep their own.
|
||||
oldHostPeerID := currentPeerID
|
||||
delete(room.peerReservations, targetPeerID)
|
||||
room.peerReservations[oldHostPeerID] = peerReservation{verifier: room.hostVerifier}
|
||||
room.hostVerifier = targetReservation.verifier
|
||||
room.HostPeerID = targetPeerID
|
||||
room.LastActivityAt = time.Now()
|
||||
recipients := make([]*Client, 0, len(room.Peers))
|
||||
for _, peerClient := range room.Peers {
|
||||
recipients = append(recipients, peerClient)
|
||||
}
|
||||
hostChanged := serverMsg{
|
||||
Type: relayTypeHostChanged,
|
||||
SessionID: room.SessionID,
|
||||
HostPeerID: targetPeerID,
|
||||
From: oldHostPeerID,
|
||||
}
|
||||
s.snap.recordMutation()
|
||||
room.mu.Unlock()
|
||||
|
||||
for _, peerClient := range recipients {
|
||||
peerClient.sendJSON(hostChanged)
|
||||
}
|
||||
|
||||
case relayTypeBroadcast:
|
||||
if currentRoom == nil {
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorNotInRoom, Message: "Not in a room"})
|
||||
|
||||
@@ -5294,6 +5294,271 @@ func TestAuthenticatedModernHostEndDeletesRoomAndIsRetrySafe(t *testing.T) {
|
||||
retry.expectError(relayErrorRoomNotFound)
|
||||
}
|
||||
|
||||
func TestTransferHostSwapsAuthorityAndBroadcastsHostChanged(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
hostToken, _ := mustReconnectToken(t)
|
||||
host := h.dial(t, "6.3.0.1")
|
||||
host.send(clientMsg{
|
||||
Type: relayTypeCreate,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
host.expectAuthority(relayTypeCreated, "H")
|
||||
|
||||
guestAToken, _ := mustReconnectToken(t)
|
||||
guestA := h.dial(t, "6.3.0.2")
|
||||
guestA.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "A",
|
||||
ReconnectToken: guestAToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
guestA.expectAuthority(relayTypeJoined, "H")
|
||||
host.expect(relayTypePeerJoined)
|
||||
|
||||
guestBToken, _ := mustReconnectToken(t)
|
||||
guestB := h.dial(t, "6.3.0.3")
|
||||
guestB.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_HAPPY",
|
||||
PeerID: "B",
|
||||
ReconnectToken: guestBToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
guestB.expectAuthority(relayTypeJoined, "H")
|
||||
host.expect(relayTypePeerJoined)
|
||||
guestA.expect(relayTypePeerJoined)
|
||||
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "A", ProtocolVersion: relayProtocolVersion})
|
||||
for _, peer := range []*testConn{host, guestA, guestB} {
|
||||
changed := peer.expect(relayTypeHostChanged)
|
||||
if changed.SessionID != "XFER_HAPPY" || changed.HostPeerID != "A" || changed.From != "H" {
|
||||
t.Fatalf("hostChanged=%+v, want sessionId=XFER_HAPPY hostPeerId=A from=H", changed)
|
||||
}
|
||||
}
|
||||
|
||||
// The old host lost end-session authority even with its valid token.
|
||||
host.send(clientMsg{
|
||||
Type: relayTypeEndSession,
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
host.expectError(relayErrorPeerIdUnavailable)
|
||||
|
||||
// The new host ends the room with its own unchanged reconnect token.
|
||||
guestA.send(clientMsg{
|
||||
Type: relayTypeEndSession,
|
||||
ReconnectToken: guestAToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
ended := guestA.expect(relayTypeEnded)
|
||||
if ended.SessionID != "XFER_HAPPY" || ended.ProtocolVersion != relayProtocolVersion {
|
||||
t.Fatalf("ended acknowledgement=%+v", ended)
|
||||
}
|
||||
for _, peer := range []*testConn{host, guestB} {
|
||||
messages, err := peer.recvUntilClosed(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("peer stayed connected after transfer-then-end: %v (frames=%v)", err, messages)
|
||||
}
|
||||
if len(messages) != 1 ||
|
||||
messages[0].Type != relayTypeEnded ||
|
||||
messages[0].SessionID != "XFER_HAPPY" {
|
||||
t.Fatalf("terminal frames=%+v, want one ended notification", messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsNonHostSender(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host, guest, hostToken, _ := createModernRoomWithGuest(t, h, "XFER_NOT_HOST", "6.3.1.1", "6.3.1.2")
|
||||
guest.send(clientMsg{Type: relayTypeTransferHost, To: "H", ProtocolVersion: relayProtocolVersion})
|
||||
guest.expectError(relayErrorNotHost)
|
||||
host.recvNothing(200 * time.Millisecond)
|
||||
|
||||
h.srv.mu.RLock()
|
||||
room := h.srv.rooms["XFER_NOT_HOST"]
|
||||
h.srv.mu.RUnlock()
|
||||
if room == nil {
|
||||
t.Fatal("room missing after rejected transfer")
|
||||
}
|
||||
presented, ok := reconnectVerifierFromToken(hostToken)
|
||||
if !ok {
|
||||
t.Fatal("host token has invalid shape")
|
||||
}
|
||||
room.mu.RLock()
|
||||
hostPeerID := room.HostPeerID
|
||||
hostAuthorityIntact := reconnectVerifierMatches(room.hostVerifier, presented)
|
||||
_, guestReserved := room.peerReservations["G"]
|
||||
room.mu.RUnlock()
|
||||
if hostPeerID != "H" {
|
||||
t.Fatalf("hostPeerId=%q after rejected transfer, want H", hostPeerID)
|
||||
}
|
||||
if !hostAuthorityIntact {
|
||||
t.Fatal("host verifier changed after rejected transfer")
|
||||
}
|
||||
if !guestReserved {
|
||||
t.Fatal("guest reservation lost after rejected transfer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsInvalidTargets(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
outsider := h.dial(t, "6.3.2.9")
|
||||
outsider.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
outsider.expectError(relayErrorNotInRoom)
|
||||
|
||||
host, guest, _, _ := createModernRoomWithGuest(t, h, "XFER_TARGETS", "6.3.2.1", "6.3.2.2")
|
||||
for _, target := range []string{"", "H", "UNKNOWN", "bad peer!"} {
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: target, ProtocolVersion: relayProtocolVersion})
|
||||
host.expectError(relayErrorPeerNotFound)
|
||||
}
|
||||
guest.recvNothing(200 * time.Millisecond)
|
||||
|
||||
// A reserved-but-disconnected guest is not a valid transfer target.
|
||||
if err := guest.conn.Close(); err != nil {
|
||||
t.Fatalf("close guest: %v", err)
|
||||
}
|
||||
left := host.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "G" {
|
||||
t.Fatalf("peerLeft peerId=%q, want G", left.PeerID)
|
||||
}
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expectError(relayErrorPeerNotFound)
|
||||
}
|
||||
|
||||
func TestTransferHostRejectsLegacyRoom(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host := h.dial(t, "6.3.3.1")
|
||||
host.send(clientMsg{Type: relayTypeCreate, SessionID: "XFER_LEGACY", PeerID: "H"})
|
||||
host.expect(relayTypeCreated)
|
||||
guest := h.dial(t, "6.3.3.2")
|
||||
guest.send(clientMsg{Type: relayTypeJoin, SessionID: "XFER_LEGACY", PeerID: "G"})
|
||||
guest.expect(relayTypeJoined)
|
||||
host.expect(relayTypePeerJoined)
|
||||
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G"})
|
||||
host.expectError(relayErrorInvalidMessage)
|
||||
guest.recvNothing(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestTransferHostPreservesReconnectAuthority(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
host, guest, hostToken, guestToken := createModernRoomWithGuest(t, h, "XFER_RECONNECT", "6.3.4.1", "6.3.4.2")
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expect(relayTypeHostChanged)
|
||||
guest.expect(relayTypeHostChanged)
|
||||
|
||||
// The old host drops and rejoins with its original token as a guest.
|
||||
if err := host.conn.Close(); err != nil {
|
||||
t.Fatalf("close old host: %v", err)
|
||||
}
|
||||
left := guest.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "H" {
|
||||
t.Fatalf("peerLeft peerId=%q, want H", left.PeerID)
|
||||
}
|
||||
oldHost := h.dial(t, "6.3.4.3")
|
||||
oldHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RECONNECT",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
rejoinedGuest := oldHost.expectAuthority(relayTypeJoined, "G")
|
||||
if rejoinedGuest.ReconnectToken != hostToken {
|
||||
t.Fatalf("old host reconnect token changed: %+v", rejoinedGuest)
|
||||
}
|
||||
guest.expect(relayTypePeerJoined)
|
||||
|
||||
// The new host drops and rejoins with its original token as the host.
|
||||
if err := guest.conn.Close(); err != nil {
|
||||
t.Fatalf("close new host: %v", err)
|
||||
}
|
||||
left = oldHost.expect(relayTypePeerLeft)
|
||||
if left.PeerID != "G" {
|
||||
t.Fatalf("peerLeft peerId=%q, want G", left.PeerID)
|
||||
}
|
||||
newHost := h.dial(t, "6.3.4.4")
|
||||
newHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RECONNECT",
|
||||
PeerID: "G",
|
||||
ReconnectToken: guestToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
rejoinedHost := newHost.expectAuthority(relayTypeJoined, "G")
|
||||
if rejoinedHost.ReconnectToken != guestToken {
|
||||
t.Fatalf("new host reconnect token changed: %+v", rejoinedHost)
|
||||
}
|
||||
oldHost.expect(relayTypePeerJoined)
|
||||
}
|
||||
|
||||
func TestTransferHostSurvivesSnapshotRestart(t *testing.T) {
|
||||
stateFile := filepath.Join(t.TempDir(), "rooms.json")
|
||||
hA := newRelayHarnessAt(t, t.TempDir(), stateFile)
|
||||
host, guest, hostToken, guestToken := createModernRoomWithGuest(t, hA, "XFER_RESTART", "6.3.5.1", "6.3.5.2")
|
||||
host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion})
|
||||
host.expect(relayTypeHostChanged)
|
||||
guest.expect(relayTypeHostChanged)
|
||||
|
||||
if err := hA.srv.snap.flushAndStop(2 * time.Second); err != nil {
|
||||
t.Fatalf("flush snapshot after transfer: %v", err)
|
||||
}
|
||||
|
||||
hB := newRelayHarnessAt(t, t.TempDir(), stateFile)
|
||||
hB.srv.mu.RLock()
|
||||
room := hB.srv.rooms["XFER_RESTART"]
|
||||
hB.srv.mu.RUnlock()
|
||||
if room == nil {
|
||||
t.Fatal("restored room missing")
|
||||
}
|
||||
newHostVerifier, ok := reconnectVerifierFromToken(guestToken)
|
||||
if !ok {
|
||||
t.Fatal("new host token has invalid shape")
|
||||
}
|
||||
room.mu.RLock()
|
||||
hostPeerID := room.HostPeerID
|
||||
hostAuthority := reconnectVerifierMatches(room.hostVerifier, newHostVerifier)
|
||||
_, oldHostReserved := room.peerReservations["H"]
|
||||
_, newHostReserved := room.peerReservations["G"]
|
||||
room.mu.RUnlock()
|
||||
if hostPeerID != "G" {
|
||||
t.Fatalf("restored hostPeerId=%q, want G", hostPeerID)
|
||||
}
|
||||
if !hostAuthority {
|
||||
t.Fatal("restored host verifier does not validate the new host's token")
|
||||
}
|
||||
if !oldHostReserved {
|
||||
t.Fatal("restored room lost the old host's guest reservation")
|
||||
}
|
||||
if newHostReserved {
|
||||
t.Fatal("restored room kept a guest reservation for the new host")
|
||||
}
|
||||
|
||||
newHost := hB.dial(t, "6.3.5.3")
|
||||
newHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RESTART",
|
||||
PeerID: "G",
|
||||
ReconnectToken: guestToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
newHost.expectAuthority(relayTypeJoined, "G")
|
||||
oldHost := hB.dial(t, "6.3.5.4")
|
||||
oldHost.send(clientMsg{
|
||||
Type: relayTypeJoin,
|
||||
SessionID: "XFER_RESTART",
|
||||
PeerID: "H",
|
||||
ReconnectToken: hostToken,
|
||||
ProtocolVersion: relayProtocolVersion,
|
||||
})
|
||||
oldHost.expectAuthority(relayTypeJoined, "G")
|
||||
newHost.expect(relayTypePeerJoined)
|
||||
}
|
||||
|
||||
func TestHostEndDeliversEndedAfterConcurrentGuestTraffic(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
endDeliveryReady := make(chan struct{})
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
relayTypePing = "ping"
|
||||
relayTypeLeave = "leave"
|
||||
relayTypeEndSession = "endSession"
|
||||
relayTypeTransferHost = "transferHost"
|
||||
relayTypeCreated = "created"
|
||||
relayTypeJoined = "joined"
|
||||
relayTypePeerJoined = "peerJoined"
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
relayTypePong = "pong"
|
||||
relayTypeLeft = "left"
|
||||
relayTypeEnded = "ended"
|
||||
relayTypeHostChanged = "hostChanged"
|
||||
relayErrorRateLimited = "rate_limited"
|
||||
relayErrorInvalidMessage = "invalid_message"
|
||||
relayErrorRoomExists = "room_exists"
|
||||
@@ -31,6 +33,8 @@ const (
|
||||
relayErrorAlreadyInRoom = "already_in_room"
|
||||
relayErrorPeerIdUnavailable = "peer_id_unavailable"
|
||||
relayErrorProtocolMismatch = "protocol_mismatch"
|
||||
relayErrorNotHost = "not_host"
|
||||
relayErrorPeerNotFound = "peer_not_found"
|
||||
|
||||
maxRoomSize = 8
|
||||
maxMessageSize = 65536
|
||||
|
||||
@@ -379,6 +379,64 @@ void main() {
|
||||
expect(state.loadedItems.containsKey(4), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('repopulateLoadedRange refetches the loaded span in place and reports the anchor', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
// The server inserts one item at the top after the initial load: every
|
||||
// id shifts up one slot on the repopulating fetch.
|
||||
var inserted = false;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) async => inserted
|
||||
? LibraryPage<MediaItem>(
|
||||
items: [_meta(99), ...List<MediaItem>.generate(size - 1, (i) => _meta(start + i))],
|
||||
totalCount: 41,
|
||||
offset: start,
|
||||
)
|
||||
: _result(start: start, size: size, totalSize: 40),
|
||||
),
|
||||
);
|
||||
await state.loadInitialPage(10);
|
||||
await tester.pump();
|
||||
|
||||
inserted = true;
|
||||
final result = await state.repopulateLoadedRange(idOf: (item) => item.id, anchorId: 'k3');
|
||||
expect(result, isNotNull);
|
||||
expect(result!.anchorOldIndex, 3);
|
||||
expect(result.anchorNewIndex, 4, reason: 'the anchor moved down one slot');
|
||||
expect(state.totalSize, 41, reason: 'the span adopts the server count');
|
||||
expect(state.loadedItems[0]?.id, 'k99');
|
||||
expect(state.loadedItems[1]?.id, 'k0');
|
||||
});
|
||||
|
||||
testWidgets('repopulateLoadedRange clamps a disjoint sparse span to maxSpan around the window center', (
|
||||
tester,
|
||||
) async {
|
||||
late _PaginatedProbeState state;
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) async => _result(start: start, size: size, totalSize: 10000),
|
||||
),
|
||||
);
|
||||
// Initial pages plus an alpha-jump target: clusters at 0..19 and
|
||||
// 9000..9019 whose naive span would be a 9020-item request.
|
||||
await state.loadInitialPage(20);
|
||||
await tester.pump();
|
||||
state.ensureIndexLoaded(9000, pageSize: 20);
|
||||
await tester.pumpAndSettle();
|
||||
expect(state.loadedItems.containsKey(0), isTrue);
|
||||
expect(state.loadedItems.containsKey(9000), isTrue);
|
||||
|
||||
state.fetchArgs.clear();
|
||||
final result = await state.repopulateLoadedRange(idOf: (item) => item.id, maxSpan: 100, windowCenter: 9010);
|
||||
expect(result, isNotNull);
|
||||
final request = state.fetchArgs.single;
|
||||
expect(request.size, lessThanOrEqualTo(100));
|
||||
expect(state.loadedItems.containsKey(0), isFalse, reason: 'entries outside the window degrade to unloaded slots');
|
||||
expect(state.loadedItems.containsKey(9010), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('removeLoadedItemAndShift decrements totalSize even for evicted indices', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/media/library_change_event.dart';
|
||||
import 'package:plezy/providers/discover_provider.dart';
|
||||
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
||||
import 'package:plezy/providers/libraries_provider.dart';
|
||||
@@ -18,6 +19,7 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/deletion_notifier.dart';
|
||||
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||
import 'package:plezy/utils/library_content_notifier.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
@@ -172,12 +174,14 @@ void main() {
|
||||
late DiscoverProvider provider;
|
||||
late List<(String, List<MediaItem>)> shelfSyncs;
|
||||
bool isBinding = false;
|
||||
late DateTime currentTime;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
isBinding = false;
|
||||
currentTime = DateTime(2026, 1, 1, 12);
|
||||
shelfSyncs = [];
|
||||
|
||||
client = _FakeClient();
|
||||
@@ -192,6 +196,7 @@ void main() {
|
||||
libraries,
|
||||
profileId: 'profile-a',
|
||||
isProfileBinding: () => isBinding,
|
||||
now: () => currentTime,
|
||||
syncSystemShelf: (owner, items) async => shelfSyncs.add((owner, List<MediaItem>.of(items))),
|
||||
);
|
||||
});
|
||||
@@ -572,6 +577,32 @@ void main() {
|
||||
expect(aggregation.hubCalls, hubCallsBefore);
|
||||
});
|
||||
|
||||
test('a deletion evicts only the emitting server\'s copy of a colliding id', () async {
|
||||
// Plex rating keys are small sequential integers, so two servers
|
||||
// routinely hold the same raw id; push removals made deletion an
|
||||
// automated event (#1646) and must not evict the other server's item.
|
||||
aggregation.onDeckResult = () => [_item('42', serverId: 'server_1'), _item('42', serverId: 'server_2')];
|
||||
aggregation.hubsResult = () => [
|
||||
_hub(
|
||||
'hub-1',
|
||||
items: [
|
||||
_item('42', serverId: 'server_1'),
|
||||
_item('42', serverId: 'server_2'),
|
||||
],
|
||||
),
|
||||
];
|
||||
await provider.load();
|
||||
|
||||
// The deletion's follow-up Continue Watching refresh refetches from the
|
||||
// server, whose truth no longer contains server_2's copy.
|
||||
aggregation.onDeckResult = () => [_item('42', serverId: 'server_1')];
|
||||
DeletionNotifier().notifyDeletedItem(item: _item('42', serverId: 'server_2'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(provider.onDeck.map((i) => i.serverId), ['server_1']);
|
||||
expect(provider.hubs.single.items.map((i) => i.serverId), ['server_1']);
|
||||
});
|
||||
|
||||
test('library order change re-sorts hubs without any refetch', () async {
|
||||
aggregation.hubsResult = () => [_hub('hub-lib2', libraryId: 'lib-2'), _hub('hub-lib1', libraryId: 'lib-1')];
|
||||
await provider.load();
|
||||
@@ -1012,14 +1043,238 @@ void main() {
|
||||
expect(multiServer.onlineServersListenerCount, before);
|
||||
});
|
||||
|
||||
test('loadGeneration bumps on full loads only', () async {
|
||||
test('loadGeneration bumps only when a pass lands first content', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
final initial = provider.loadGeneration;
|
||||
|
||||
await provider.load();
|
||||
expect(provider.loadGeneration, initial + 1);
|
||||
expect(provider.loadGeneration, initial + 1, reason: 'initial load resets the hero');
|
||||
|
||||
await provider.refreshContinueWatching();
|
||||
expect(provider.loadGeneration, initial + 1);
|
||||
|
||||
// A background full pass with existing content swaps silently: no hero
|
||||
// reset, no focus steal, even when the list content changed.
|
||||
aggregation.onDeckResult = () => [_item('b')];
|
||||
await provider.load();
|
||||
expect(provider.loadGeneration, initial + 1, reason: 'refetch with existing content is silent');
|
||||
|
||||
// Content drains, then returns: the recovery pass is fresh content again.
|
||||
aggregation.onDeckResult = () => const [];
|
||||
await provider.load();
|
||||
expect(provider.loadGeneration, initial + 1);
|
||||
aggregation.onDeckResult = () => [_item('c')];
|
||||
await provider.load();
|
||||
expect(provider.loadGeneration, initial + 2, reason: 'recovery from empty resets the hero');
|
||||
});
|
||||
|
||||
group('refreshIfStale (#1646)', () {
|
||||
test('fresh hubs skip the reload; stale hubs run one full pass', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
await provider.load();
|
||||
|
||||
// Exactly staleAfter old is still fresh; staleness requires *older*.
|
||||
currentTime = currentTime.add(DiscoverProvider.staleAfter);
|
||||
expect(provider.refreshIfStale(), isFalse);
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
expect(aggregation.onDeckCalls, 1);
|
||||
|
||||
currentTime = currentTime.add(const Duration(seconds: 1));
|
||||
expect(provider.refreshIfStale(), isTrue);
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 2);
|
||||
expect(aggregation.onDeckCalls, 2);
|
||||
});
|
||||
|
||||
test('a hub list that never committed is not stale', () async {
|
||||
// Initial load, error retry, and reconnect are owned by other hooks;
|
||||
// the staleness check must not turn every tab-shown into a retry loop.
|
||||
currentTime = currentTime.add(const Duration(hours: 1));
|
||||
expect(provider.refreshIfStale(), isFalse);
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 0);
|
||||
expect(aggregation.onDeckCalls, 0);
|
||||
});
|
||||
|
||||
test('reports busy during an in-flight pass without queueing a trailing one', () async {
|
||||
final gate = Completer<void>();
|
||||
aggregation.hubGate = gate.future;
|
||||
aggregation.hubStarted = Completer<void>();
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
|
||||
final first = provider.load();
|
||||
await aggregation.hubStarted!.future;
|
||||
currentTime = currentTime.add(const Duration(hours: 1));
|
||||
expect(provider.refreshIfStale(), isTrue);
|
||||
|
||||
gate.complete();
|
||||
await first;
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 1, reason: 'a busy refreshIfStale must not queue a duplicate pass (#1784)');
|
||||
});
|
||||
|
||||
test('stale hubs behind a busy delta pass still queue the full reload', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
await provider.load();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
|
||||
currentTime = currentTime.add(const Duration(minutes: 16));
|
||||
// A newly-online server's delta merge is in flight. It never refetches
|
||||
// the already-loaded servers' hubs, so it must not satisfy staleness.
|
||||
final gate = Completer<void>();
|
||||
aggregation.hubGate = gate.future;
|
||||
aggregation.hubStarted = Completer<void>();
|
||||
final delta = provider.syncToOnlineServers(const {'server_1', 'server_2'});
|
||||
await aggregation.hubStarted!.future;
|
||||
|
||||
expect(provider.refreshIfStale(), isTrue, reason: 'stale hubs queue the full pass behind the delta');
|
||||
gate.complete();
|
||||
await delta;
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 3, reason: 'the queued full pass refetched the stale hubs');
|
||||
});
|
||||
|
||||
test('a pass that kept the previous hubs stays stale and retries', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
await provider.load();
|
||||
|
||||
currentTime = currentTime.add(const Duration(minutes: 16));
|
||||
aggregation.hubSucceededServerIds = {};
|
||||
aggregation.hubFailedServerIds = {'server_1'};
|
||||
expect(provider.refreshIfStale(), isTrue);
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 2);
|
||||
expect(provider.hubs.map((h) => h.id), ['hub-1'], reason: 'an all-failed hub pass keeps the previous hubs');
|
||||
|
||||
// The kept-previous pass did not stamp freshness, so the next check retries.
|
||||
aggregation.hubSucceededServerIds = null;
|
||||
aggregation.hubFailedServerIds = const {};
|
||||
expect(provider.refreshIfStale(), isTrue);
|
||||
await pumpEventQueue();
|
||||
expect(aggregation.hubCalls, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('server push refresh (#1646)', () {
|
||||
DiscoverProvider pushProvider({bool Function()? isRefreshBlocked, Duration cooldown = Duration.zero}) {
|
||||
final scoped = DiscoverProvider(
|
||||
multiServer,
|
||||
hiddenLibraries,
|
||||
libraries,
|
||||
profileId: 'profile-a',
|
||||
isProfileBinding: () => isBinding,
|
||||
now: () => currentTime,
|
||||
isRefreshBlocked: isRefreshBlocked,
|
||||
libraryEventDebounce: const Duration(milliseconds: 40),
|
||||
libraryEventBlockedRetry: const Duration(milliseconds: 40),
|
||||
libraryEventCooldown: cooldown,
|
||||
);
|
||||
addTearDown(() {
|
||||
if (!scoped.isDisposed) scoped.dispose();
|
||||
});
|
||||
return scoped;
|
||||
}
|
||||
|
||||
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
|
||||
test('a sustained event stream is rate-limited to the cooldown', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
final scoped = pushProvider(cooldown: const Duration(milliseconds: 500));
|
||||
await scoped.load();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
|
||||
// The committed load credited the cooldown, so a push landing right
|
||||
// after it defers to the trailing edge instead of refetching content
|
||||
// the provider just committed.
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1'), itemsAdded: true));
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 1, reason: 'a just-committed pull pass absorbs the immediate push pass');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
expect(aggregation.hubCalls, 2, reason: 'the change still lands as the trailing pass');
|
||||
|
||||
// Quiet period: the trailing pass's own window expires with nothing
|
||||
// latched, so the stream stays at two passes.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
expect(aggregation.hubCalls, 2, reason: 'no further passes without new events');
|
||||
|
||||
// A burst after a quiet period refreshes promptly and coalesces.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1'), itemsAdded: true));
|
||||
}
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 3, reason: 'the first burst after a quiet period runs one prompt pass');
|
||||
|
||||
// A bulk import keeps emitting: events inside the fresh window latch
|
||||
// one trailing pass instead of fanning out per batch.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1'), itemsAdded: true));
|
||||
}
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 3, reason: 'events inside the cooldown must not fan out');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
expect(aggregation.hubCalls, 4, reason: 'exactly one trailing pass once the cooldown expires');
|
||||
});
|
||||
|
||||
test('a change event runs one debounced full pass; a burst coalesces', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
final scoped = pushProvider();
|
||||
await scoped.load();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
|
||||
// Three servers reporting in quick succession collapse into one pass.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_$i'), itemsAdded: true));
|
||||
}
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 2);
|
||||
expect(aggregation.onDeckCalls, 2);
|
||||
});
|
||||
|
||||
test('an event with no changes is ignored', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
final scoped = pushProvider();
|
||||
await scoped.load();
|
||||
final hubCallsBefore = aggregation.hubCalls;
|
||||
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1')));
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, hubCallsBefore);
|
||||
});
|
||||
|
||||
test('active playback defers the refresh until it ends', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
var playbackActive = true;
|
||||
final scoped = pushProvider(isRefreshBlocked: () => playbackActive);
|
||||
await scoped.load();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1'), itemsAdded: true));
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 1, reason: 'the playback path must stay quiet');
|
||||
|
||||
playbackActive = false;
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 2, reason: 'the deferred refresh runs once playback ends');
|
||||
});
|
||||
|
||||
test('dispose cancels the pending push refresh', () async {
|
||||
aggregation.onDeckResult = () => [_item('a')];
|
||||
aggregation.hubsResult = () => [_hub('hub-1')];
|
||||
final scoped = pushProvider();
|
||||
await scoped.load();
|
||||
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('server_1'), itemsAdded: true));
|
||||
scoped.dispose();
|
||||
await settle();
|
||||
expect(aggregation.hubCalls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/library_change_event.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:plezy/providers/libraries_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/utils/library_content_notifier.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
import '../test_helpers/multi_server_fixtures.dart';
|
||||
@@ -853,4 +855,64 @@ void main() {
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('library content epochs (#1646)', () {
|
||||
Future<LibrariesProvider> seeded() async {
|
||||
final p = LibrariesProvider();
|
||||
addTearDown(p.dispose);
|
||||
await p.updateLibraryOrder([
|
||||
_serverLib(ServerId('s1'), '1', 'Movies'),
|
||||
_serverLib(ServerId('s1'), '2', 'Shows'),
|
||||
_serverLib(ServerId('s2'), '1', 'Other'),
|
||||
]);
|
||||
return p;
|
||||
}
|
||||
|
||||
test('a push event bumps only the named libraries, without notifying', () async {
|
||||
final p = await seeded();
|
||||
var notifies = 0;
|
||||
p.addListener(() => notifies++);
|
||||
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(p.libraryContentEpoch('s1:1'), 1);
|
||||
expect(p.libraryContentEpoch('s1:2'), 0);
|
||||
expect(p.libraryContentEpoch('s2:1'), 0, reason: 'other servers untouched');
|
||||
expect(notifies, 0, reason: 'epoch bumps are bookkeeping, not a UI change');
|
||||
});
|
||||
|
||||
test('unnamed or unknown library ids mark the whole server', () async {
|
||||
final p = await seeded();
|
||||
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('s1'), itemsAdded: true));
|
||||
await pumpEventQueue();
|
||||
expect(p.libraryContentEpoch('s1:1'), 1);
|
||||
expect(p.libraryContentEpoch('s1:2'), 1);
|
||||
|
||||
// A brand-new library's id matches nothing loaded — fall back to the
|
||||
// server rather than silently marking nothing.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'brand-new'}, itemsAdded: true),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(p.libraryContentEpoch('s1:1'), 2);
|
||||
expect(p.libraryContentEpoch('s1:2'), 2);
|
||||
expect(p.libraryContentEpoch('s2:1'), 0);
|
||||
});
|
||||
|
||||
test('events with no changes or for unknown servers are ignored', () async {
|
||||
final p = await seeded();
|
||||
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('s1')));
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('ghost'), itemsAdded: true));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(p.libraryContentEpoch('s1:1'), 0);
|
||||
expect(p.libraryContentEpoch('s1:2'), 0);
|
||||
expect(p.libraryContentEpoch('s2:1'), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,6 +359,118 @@ void main() {
|
||||
expect(client.hubCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('Refreshable.refresh refetches hubs only once they are stale (#1646)', (tester) async {
|
||||
// The stale-resume gate goes through Refreshable.refresh(). A fresh
|
||||
// refresh must stay Continue Watching-only (zero hub calls), but once the
|
||||
// hub list is older than DiscoverProvider.staleAfter it must run a full
|
||||
// pass — otherwise media added server-side never appears until a restart.
|
||||
await SettingsService.getInstance();
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.view.resetPhysicalSize();
|
||||
});
|
||||
|
||||
var currentTime = DateTime(2026, 1, 1, 12);
|
||||
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: const [], size: 0);
|
||||
final client = _GatedHubsFakeClient(hubs: [hub]);
|
||||
final multiServerProvider = testMultiServer(clients: [client]).provider;
|
||||
final hiddenLibrariesProvider = HiddenLibrariesProvider();
|
||||
final librariesProvider = LibrariesProvider();
|
||||
final watchTogetherProvider = WatchTogetherProvider();
|
||||
final companionRemoteProvider = CompanionRemoteProvider();
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final connectionRegistry = _FakeConnectionRegistry(db);
|
||||
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfileProvider = ActiveProfileProvider(
|
||||
registry: _FakeProfileRegistry(db),
|
||||
plexHome: plexHome,
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
final discoverProvider = DiscoverProvider(
|
||||
multiServerProvider,
|
||||
hiddenLibrariesProvider,
|
||||
librariesProvider,
|
||||
profileId: null,
|
||||
isProfileBinding: () => false,
|
||||
now: () => currentTime,
|
||||
);
|
||||
addTearDown(() async {
|
||||
discoverProvider.dispose();
|
||||
activeProfileProvider.dispose();
|
||||
companionRemoteProvider.dispose();
|
||||
watchTogetherProvider.dispose();
|
||||
librariesProvider.dispose();
|
||||
hiddenLibrariesProvider.dispose();
|
||||
// multiServerProvider + its manager are torn down by testMultiServer.
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
|
||||
ChangeNotifierProvider<DiscoverProvider>.value(value: discoverProvider),
|
||||
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
|
||||
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: MainScreenFocusScope(
|
||||
focusSidebar: () {},
|
||||
sideNavigationWidth: SideNavigationRailState.expandedWidth,
|
||||
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
|
||||
foregroundLeft: 0,
|
||||
foregroundWidth: 1280,
|
||||
viewportWidth: 1280,
|
||||
child: const SizedBox(width: 1280, height: 720, child: DiscoverScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Let the initState load pass commit, stamping hub freshness at T0.
|
||||
client.release();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(client.hubCalls, 1);
|
||||
expect(discoverProvider.isLoadInFlight, isFalse);
|
||||
|
||||
final screen = tester.state(find.byType(DiscoverScreen)) as Refreshable;
|
||||
|
||||
// Fresh: the resume refresh stays Continue Watching-only.
|
||||
screen.refresh();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(client.hubCalls, 1, reason: 'a fresh refresh must not refetch hubs');
|
||||
|
||||
// Stale: the same refresh entry point now runs the full pass.
|
||||
currentTime = currentTime.add(DiscoverProvider.staleAfter + const Duration(seconds: 1));
|
||||
screen.refresh();
|
||||
await tester.pump();
|
||||
client.release();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(client.hubCalls, 2, reason: 'a stale refresh must refetch the home hubs');
|
||||
});
|
||||
|
||||
testWidgets('TV selects Continue Watching when it arrives after recommendation hubs', (tester) async {
|
||||
await SettingsService.getInstance();
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
@@ -5,7 +5,15 @@ import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/screens/libraries/tabs/base_library_tab.dart';
|
||||
import 'package:plezy/screens/libraries/state_messages.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/library_change_event.dart';
|
||||
import 'package:plezy/providers/libraries_provider.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/utils/library_content_notifier.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
const _library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies');
|
||||
const _libraryB = MediaLibrary(id: '2', backend: MediaBackend.plex, title: 'Shows');
|
||||
@@ -45,7 +53,7 @@ class _ProbeTabState extends BaseLibraryTabState<String, _ProbeTab> {
|
||||
}
|
||||
|
||||
class _ControlledTab extends BaseLibraryTab<String> {
|
||||
const _ControlledTab({super.key, required super.library, required this.load, super.onDataLoaded})
|
||||
const _ControlledTab({super.key, required super.library, required this.load, super.onDataLoaded, super.isActive})
|
||||
: super(suppressAutoFocus: true);
|
||||
|
||||
final Future<List<String>> Function(MediaLibrary library) load;
|
||||
@@ -73,8 +81,8 @@ class _ControlledTabState extends BaseLibraryTabState<String, _ControlledTab> {
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
});
|
||||
|
||||
@@ -199,4 +207,270 @@ void main() {
|
||||
expect(find.text('newer'), findsOneWidget);
|
||||
expect(find.text('older'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a push-marked library reloads once when its tab is next activated (#1646)', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final provider = LibrariesProvider();
|
||||
addTearDown(provider.dispose);
|
||||
await provider.updateLibraryOrder(const [library]);
|
||||
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
Future<void> pumpTab({required bool isActive}) => tester.pumpWidget(
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
home: _ControlledTab(
|
||||
key: key,
|
||||
library: library,
|
||||
isActive: isActive,
|
||||
load: (_) async {
|
||||
loadCalls++;
|
||||
return const ['x'];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pumpTab(isActive: false);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 1, reason: 'initState load');
|
||||
|
||||
// Server push marks the library stale while the tab is inactive.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await pumpTab(isActive: true);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 2, reason: 'activation consumes the staleness');
|
||||
|
||||
// Re-activation without a new event must not reload again.
|
||||
await pumpTab(isActive: false);
|
||||
await pumpTab(isActive: true);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 2);
|
||||
|
||||
// An event for a different server leaves this tab fresh.
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('s2'), itemsAdded: true));
|
||||
await tester.pump();
|
||||
await pumpTab(isActive: false);
|
||||
await pumpTab(isActive: true);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('a live push swaps items in place without clearing (#1646)', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
final second = Completer<List<String>>();
|
||||
Future<List<String>> load(MediaLibrary _) {
|
||||
loadCalls++;
|
||||
if (loadCalls == 1) return Future.value(const ['old-item']);
|
||||
if (loadCalls == 2) return second.future;
|
||||
return Future.error(StateError('server went away'));
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: library, isActive: true, load: load),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('old-item'), findsOneWidget);
|
||||
|
||||
// The initial load credited the pacer's cooldown (a committed pull pass
|
||||
// counts), so the push defers to the window's trailing edge instead of
|
||||
// refetching content the user just received.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 4)); // past the live debounce
|
||||
expect(loadCalls, 1, reason: 'a just-loaded tab defers the push to the cooldown edge');
|
||||
await tester.pump(const Duration(minutes: 2));
|
||||
expect(loadCalls, 2, reason: 'the visible tab reloads live at the trailing edge');
|
||||
|
||||
// In place: the old content stays rendered while the fetch runs.
|
||||
expect(find.text('old-item'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
second.complete(const ['new-item']);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('new-item'), findsOneWidget);
|
||||
expect(find.text('old-item'), findsNothing);
|
||||
|
||||
// A failing live refresh keeps the visible content, silently.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(minutes: 2, seconds: 4)); // cooldown + debounce
|
||||
expect(loadCalls, 3);
|
||||
await tester.pump();
|
||||
expect(find.text('new-item'), findsOneWidget);
|
||||
expect(find.byType(ErrorStateWidget), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a live push for a different library or hidden tab does nothing', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
Future<List<String>> load(MediaLibrary _) async {
|
||||
loadCalls++;
|
||||
return const ['item'];
|
||||
}
|
||||
|
||||
// Hidden tab (isActive: false): live events are ignored — activation
|
||||
// staleness owns the reload.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: library, load: load),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 1);
|
||||
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
expect(loadCalls, 1, reason: 'hidden tabs never live-reload');
|
||||
|
||||
// Active tab, but events for a different library and a different server.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: _ControlledTab(key: key, library: library, isActive: true, load: load),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'other-lib'}, itemsAdded: true),
|
||||
);
|
||||
LibraryContentNotifier().notifyChanged(LibraryChangeEvent(serverId: ServerId('s2'), itemsAdded: true));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
expect(loadCalls, 1, reason: 'unrelated events never reload');
|
||||
});
|
||||
|
||||
testWidgets('a push landing mid-fetch keeps the library stale (#1646)', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final provider = LibrariesProvider();
|
||||
addTearDown(provider.dispose);
|
||||
await provider.updateLibraryOrder(const [library]);
|
||||
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
final gate = Completer<List<String>>();
|
||||
Future<List<String>> load(MediaLibrary _) {
|
||||
loadCalls++;
|
||||
return loadCalls == 2 ? gate.future : Future.value(const ['x']);
|
||||
}
|
||||
|
||||
Future<void> pumpTab({required bool isActive}) => tester.pumpWidget(
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
home: _ControlledTab(key: key, library: library, isActive: isActive, load: load),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pumpTab(isActive: false);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 1);
|
||||
|
||||
// Push marks the library; activation starts the reload, which hangs.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await pumpTab(isActive: true);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 2);
|
||||
|
||||
// While the fetch is in flight the tab is hidden again and another push
|
||||
// lands. The commit must record the load-start epoch, not the current
|
||||
// one — the fetched data predates this second push.
|
||||
await pumpTab(isActive: false);
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
gate.complete(const ['x']);
|
||||
await tester.pump();
|
||||
|
||||
await pumpTab(isActive: true);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 3, reason: 'the mid-fetch push is still owed a reload');
|
||||
});
|
||||
|
||||
testWidgets('live passes skip a screen hidden behind another main tab', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
Future<List<String>> load(MediaLibrary _) async {
|
||||
loadCalls++;
|
||||
return const ['item'];
|
||||
}
|
||||
|
||||
// MainScreen mutes hidden tab subtrees through TickerMode; an active
|
||||
// library tab behind Discover must not fetch or move its scroll offset.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: TickerMode(
|
||||
enabled: false,
|
||||
child: _ControlledTab(key: key, library: library, isActive: true, load: load),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 1);
|
||||
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'1'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(minutes: 2, seconds: 4));
|
||||
expect(loadCalls, 1, reason: 'offscreen surfaces consume staleness on activation instead');
|
||||
});
|
||||
|
||||
testWidgets('an event naming only unknown library ids live-refreshes via the whole-server fallback', (tester) async {
|
||||
const library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: 's1');
|
||||
final provider = LibrariesProvider();
|
||||
addTearDown(provider.dispose);
|
||||
await provider.updateLibraryOrder(const [library]);
|
||||
|
||||
final key = GlobalKey<_ControlledTabState>();
|
||||
var loadCalls = 0;
|
||||
Future<List<String>> load(MediaLibrary _) async {
|
||||
loadCalls++;
|
||||
return const ['item'];
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
home: _ControlledTab(key: key, library: library, isActive: true, load: load),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(loadCalls, 1);
|
||||
|
||||
// A backend id the event names differently matches no loaded library;
|
||||
// the shared matcher falls back to the whole server, so the visible tab
|
||||
// live-refreshes exactly like the provider marks its epoch.
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('s1'), libraryIds: const {'brand-new-section'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pump(const Duration(minutes: 2));
|
||||
expect(loadCalls, 2, reason: 'provider marking and the live pass agree');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/library_filter_result.dart';
|
||||
import 'package:plezy/media/library_change_event.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
@@ -24,6 +25,7 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/library_content_notifier.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/focusable_filter_chip.dart';
|
||||
|
||||
@@ -181,6 +183,61 @@ void main() {
|
||||
expect(client.pageQueries.single.includeKinds, const [MediaKind.movie, MediaKind.show]);
|
||||
expect(client.pageLibraryKinds.single, MediaKind.folder);
|
||||
});
|
||||
|
||||
testWidgets('a server push repopulates the visible grid in place (#1646)', (tester) async {
|
||||
final client = _BrowseClient('server-a', 'Library A');
|
||||
final harness = _BrowseHarness(clientA: client);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpHarness(tester, harness);
|
||||
await _pumpUntil(tester, () => client.pageRequestCount >= 1);
|
||||
await pumpRequestFrames(tester);
|
||||
expect(find.text('Library A'), findsOneWidget);
|
||||
final requestsBefore = client.pageRequestCount;
|
||||
|
||||
// The next page fetch returns the old item plus a new arrival.
|
||||
client.pageResponses.add(
|
||||
() async => LibraryPage<MediaItem>(
|
||||
items: [
|
||||
testMediaItem(
|
||||
id: 'fresh-item',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.artist,
|
||||
title: 'Fresh Arrival',
|
||||
serverId: client.serverId.value,
|
||||
serverName: client.serverName,
|
||||
),
|
||||
testMediaItem(
|
||||
id: '${client.serverId.value}-item',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.artist,
|
||||
title: 'Library A',
|
||||
serverId: client.serverId.value,
|
||||
serverName: client.serverName,
|
||||
),
|
||||
],
|
||||
totalCount: 2,
|
||||
),
|
||||
);
|
||||
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: ServerId('server-a'), libraryIds: const {'server-a-library'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump();
|
||||
// The initial load credited the live pacer's cooldown, so the push
|
||||
// defers to the window's trailing edge; the debounce alone fires nothing.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
expect(client.pageRequestCount, requestsBefore, reason: 'push deferred while the just-loaded content is fresh');
|
||||
await tester.pump(const Duration(minutes: 2));
|
||||
await _pumpUntil(tester, () => client.pageRequestCount > requestsBefore);
|
||||
await pumpRequestFrames(tester);
|
||||
|
||||
// The new item materialized at its server-sorted position and the old
|
||||
// item survived — no clearing, no skeleton pass.
|
||||
expect(find.text('Fresh Arrival'), findsOneWidget);
|
||||
expect(find.text('Library A'), findsOneWidget);
|
||||
expect(harness.loadedLibraries, isNotEmpty, reason: 'initial load completed normally');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/library_change_event.dart';
|
||||
import 'package:plezy/media/media_library.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
@@ -22,6 +24,7 @@ import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/utils/library_content_notifier.dart';
|
||||
import 'package:plezy/widgets/card_inflation_budget.dart';
|
||||
import 'package:plezy/widgets/focusable_media_card.dart';
|
||||
import 'package:plezy/widgets/media_card_sliver_layout.dart';
|
||||
@@ -96,6 +99,43 @@ void main() {
|
||||
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a server push repopulates the collections grid in place (#1646)', (tester) async {
|
||||
var count = 2;
|
||||
Completer<void>? gate;
|
||||
final harness = _CollectionHarness.plexMovies(
|
||||
collectionCount: 2,
|
||||
currentCount: () => count,
|
||||
responseGate: () => gate?.future ?? Future<void>.value(),
|
||||
);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await _pumpTab(tester, harness: harness, library: _movieLibrary, isActive: true);
|
||||
expect(find.text('Collection 0'), findsOneWidget);
|
||||
expect(find.text('Collection 1'), findsOneWidget);
|
||||
|
||||
// The server adds a collection and pushes. The initial load credited the
|
||||
// live pacer's cooldown, so the pass lands at the trailing edge.
|
||||
count = 3;
|
||||
gate = Completer<void>();
|
||||
LibraryContentNotifier().notifyChanged(
|
||||
LibraryChangeEvent(serverId: _serverId, libraryIds: const {'movies'}, itemsAdded: true),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pump(const Duration(minutes: 2, seconds: 1));
|
||||
|
||||
// In place: while the refetch is in flight the old cards stay rendered —
|
||||
// no clearing, no loading state (the P1 regression cleared the grid here).
|
||||
expect(find.text('Collection 0'), findsOneWidget);
|
||||
expect(find.text('Collection 1'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
gate.complete();
|
||||
gate = null;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Collection 2'), findsOneWidget, reason: 'the pushed addition materialized live');
|
||||
expect(find.text('Collection 0'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('D-pad grid navigation', () {
|
||||
testWidgets('UP moves one row up without resetting the scroll position', (tester) async {
|
||||
final harness = _CollectionHarness.plexMovies(collectionCount: 60);
|
||||
@@ -167,11 +207,12 @@ Future<void> _pumpTab(
|
||||
required MediaLibrary library,
|
||||
VoidCallback? onBack,
|
||||
VoidCallback? focusSidebar,
|
||||
bool isActive = false,
|
||||
}) async {
|
||||
await pumpLibraryTab(
|
||||
tester,
|
||||
provider: harness.provider,
|
||||
tab: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: onBack ?? () {}),
|
||||
tab: LibraryCollectionsTab(library: library, suppressAutoFocus: true, isActive: isActive, onBack: onBack ?? () {}),
|
||||
size: const Size(800, 600),
|
||||
focusSidebar: focusSidebar,
|
||||
);
|
||||
@@ -244,7 +285,14 @@ class _CollectionHarness {
|
||||
}
|
||||
|
||||
/// Movie library with [collectionCount] collections, served as one page.
|
||||
factory _CollectionHarness.plexMovies({required int collectionCount}) {
|
||||
/// [currentCount] overrides the count per request and [responseGate] holds
|
||||
/// a response open, so live-refresh tests can stage a changed payload and
|
||||
/// observe the in-flight state.
|
||||
factory _CollectionHarness.plexMovies({
|
||||
required int collectionCount,
|
||||
int Function()? currentCount,
|
||||
Future<void> Function()? responseGate,
|
||||
}) {
|
||||
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(database);
|
||||
final client = testPlexClient(
|
||||
@@ -260,13 +308,15 @@ class _CollectionHarness {
|
||||
if (request.url.path != '/library/sections/movies/collections') {
|
||||
return http.Response('not found', 404);
|
||||
}
|
||||
await responseGate?.call();
|
||||
final count = currentCount?.call() ?? collectionCount;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': collectionCount,
|
||||
'totalSize': collectionCount,
|
||||
'size': count,
|
||||
'totalSize': count,
|
||||
'Metadata': [
|
||||
for (var i = 0; i < collectionCount; i++)
|
||||
for (var i = 0; i < count; i++)
|
||||
{'ratingKey': 'collection-$i', 'type': 'collection', 'title': 'Collection $i', 'childCount': 2},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ import 'package:plezy/utils/video_player_navigation.dart';
|
||||
import 'package:plezy/widgets/collapsible_text.dart';
|
||||
import 'package:plezy/widgets/cycling_media_backdrop.dart';
|
||||
import 'package:plezy/widgets/episode_card.dart';
|
||||
import 'package:plezy/widgets/fitting_title_text.dart';
|
||||
import 'package:plezy/widgets/tv_browse_rail.dart';
|
||||
import 'package:plezy/widgets/media_details_sheet.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -1141,6 +1142,7 @@ void main() {
|
||||
int? initialSeasonIndex,
|
||||
String? initialEpisodeId,
|
||||
NavigatorObserver? observer,
|
||||
ThemeData? theme,
|
||||
}) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
await SettingsService.getInstance();
|
||||
@@ -1188,7 +1190,7 @@ void main() {
|
||||
],
|
||||
child: MaterialApp(
|
||||
navigatorObservers: [?observer],
|
||||
theme: monoTheme(dark: true),
|
||||
theme: theme ?? monoTheme(dark: true),
|
||||
home: withProfileNavigationScope(
|
||||
child: MediaDetailScreen(
|
||||
metadata: show,
|
||||
@@ -1240,6 +1242,20 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('phone hero title fallback uses the light theme foreground', (tester) async {
|
||||
// The hero scrim washes artwork toward the near-white light background;
|
||||
// the old hard-coded white title vanished into it on bright covers.
|
||||
final show = buildShow();
|
||||
final theme = monoTheme(dark: false);
|
||||
await pumpPhoneDetail(tester, singleSeasonClient(show), show, theme: theme);
|
||||
|
||||
final heroTitle = tester.widget<FittingTitleText>(find.byType(FittingTitleText).first);
|
||||
expect(heroTitle.style?.color, theme.colorScheme.onSurface);
|
||||
final shadow = heroTitle.style?.shadows?.single;
|
||||
expect(shadow, isNotNull);
|
||||
expect(shadow!.color.computeLuminance(), greaterThan(0.5), reason: 'light theme halos with a light shadow');
|
||||
});
|
||||
|
||||
testWidgets('paints the item before the on-deck lookup settles', (tester) async {
|
||||
// Jellyfin needs a second round trip for on-deck; the phone/desktop
|
||||
// layout must not wait for it. Scoped to non-TV deliberately: on TV the
|
||||
|
||||
@@ -54,6 +54,8 @@ void main() {
|
||||
|
||||
final title = find.text('Shader Preset');
|
||||
await tester.scrollUntilVisible(title, 500, scrollable: find.byType(Scrollable).first);
|
||||
await tester.ensureVisible(title);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tile = find.widgetWithText(ListTile, 'Shader Preset');
|
||||
expect(find.descendant(of: tile, matching: find.textContaining('Everywhere')), findsOneWidget);
|
||||
@@ -114,6 +116,27 @@ void main() {
|
||||
expect(SettingsService.instance.read(SettingsService.deinterlace), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('turns the covered-source direct play off from the quality group (#2193)', (tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1000, 1400);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const PlaybackSettingsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final title = find.text('Play Smaller Videos at Original Quality');
|
||||
await tester.scrollUntilVisible(title, 500, scrollable: find.byType(Scrollable).first);
|
||||
await tester.ensureVisible(title);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(SettingsService.instance.read(SettingsService.directPlayCoveredQuality), isTrue);
|
||||
await tester.tap(title);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(SettingsService.instance.read(SettingsService.directPlayCoveredQuality), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('gesture toggles render on mobile and persist', (tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1000, 1400);
|
||||
|
||||
@@ -2390,7 +2390,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('getPlaybackInfo negotiates video transcodes as fMP4 HLS', () async {
|
||||
test('getPlaybackInfo negotiates video transcodes as fMP4 HLS with a ts fallback', () async {
|
||||
String? capturedBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
@@ -2405,9 +2405,12 @@ void main() {
|
||||
|
||||
final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
|
||||
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||
final videoTranscode = (profile['TranscodingProfiles'] as List<dynamic>)
|
||||
final videoProfiles = (profile['TranscodingProfiles'] as List<dynamic>)
|
||||
.map((entry) => entry as Map<String, dynamic>)
|
||||
.first;
|
||||
.where((entry) => entry['Type'] == 'Video')
|
||||
.toList();
|
||||
expect(videoProfiles, hasLength(2));
|
||||
final videoTranscode = videoProfiles.first;
|
||||
expect(videoTranscode['Type'], 'Video');
|
||||
// fMP4 segments: MPEG-TS cannot carry AV1, so a server with an AV1
|
||||
// encoder could never pick it (issue #2131). mpv consumes fMP4 HLS on
|
||||
@@ -2429,6 +2432,29 @@ void main() {
|
||||
expect(list.length, lessThanOrEqualTo(40), reason: '$key is too long for the server to accept: $list');
|
||||
expect(list, matches(RegExp(r'^[a-zA-Z0-9\-\._,|]{0,40}$')), reason: '$key has characters the server rejects');
|
||||
}
|
||||
|
||||
// MPEG-TS fallback (#2198): live tuners with
|
||||
// UseMostCompatibleTranscodingProfile — every HDHomeRun host, M3U by
|
||||
// default — drop all non-ts transcoding profiles, so without this entry
|
||||
// Live TV negotiates no HLS URL at all.
|
||||
final tsTranscode = videoProfiles.last;
|
||||
expect(tsTranscode['Container'], 'ts');
|
||||
expect(tsTranscode['Protocol'], 'hls');
|
||||
// Both ts codec lists must stay strict subsets of the fMP4 entry's, and
|
||||
// the ts entry must stay listed second: the server ranks profiles with a
|
||||
// stable sort, so this pairing guarantees ts can only win when the fMP4
|
||||
// entry has been filtered out and VOD keeps negotiating fMP4.
|
||||
for (final key in ['VideoCodec', 'AudioCodec']) {
|
||||
final tsCodecs = (tsTranscode[key] as String).split(',');
|
||||
final mp4Codecs = (videoTranscode[key] as String).split(',');
|
||||
expect(mp4Codecs, containsAll(tsCodecs), reason: '$key of the ts profile must be a subset of the fMP4 one');
|
||||
expect(tsCodecs.length, lessThan(mp4Codecs.length), reason: '$key of the ts profile must be a strict subset');
|
||||
final list = tsTranscode[key] as String;
|
||||
expect(list, matches(RegExp(r'^[a-zA-Z0-9\-\._,|]{0,40}$')), reason: '$key has characters the server rejects');
|
||||
}
|
||||
// av1 cannot ride in a TS segment; flac and truehd cannot either.
|
||||
expect(tsTranscode['VideoCodec'], 'hevc,h264');
|
||||
expect(tsTranscode['AudioCodec'], 'aac,mp3,ac3,eac3,opus,dts');
|
||||
});
|
||||
|
||||
test('the hardware decoder probe narrows both video codec lists', () async {
|
||||
@@ -2699,15 +2725,18 @@ void main() {
|
||||
expect(negotiation.url.path, '/Items/channel-1/PlaybackInfo');
|
||||
expect(negotiation.url.queryParameters['AutoOpenLiveStream'], 'true');
|
||||
expect(negotiation.url.queryParameters['EnableTranscoding'], 'true');
|
||||
expect(negotiation.url.queryParameters['EnableDirectPlay'], 'false');
|
||||
expect(negotiation.url.queryParameters['EnableDirectStream'], 'false');
|
||||
// Original quality asks for direct play; this server answers with a
|
||||
// transcode (no SupportsDirectPlay on the source) and the session
|
||||
// adopts it.
|
||||
expect(negotiation.url.queryParameters['EnableDirectPlay'], 'true');
|
||||
expect(negotiation.url.queryParameters['EnableDirectStream'], 'true');
|
||||
expect(negotiation.url.queryParameters['AllowVideoStreamCopy'], 'true');
|
||||
expect(negotiation.url.queryParameters['AllowAudioStreamCopy'], 'true');
|
||||
final body = jsonDecode(negotiation.body) as Map<String, dynamic>;
|
||||
expect(body['AutoOpenLiveStream'], isTrue);
|
||||
expect(body['EnableTranscoding'], isTrue);
|
||||
expect(body['EnableDirectPlay'], isFalse);
|
||||
expect(body['EnableDirectStream'], isFalse);
|
||||
expect(body['EnableDirectPlay'], isTrue);
|
||||
expect(body['EnableDirectStream'], isTrue);
|
||||
|
||||
expect(session, isNotNull);
|
||||
final uri = Uri.parse((await session!.streamUrlAt())!);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user