Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 98bd085bff build(deps): bump actions/setup-java from 5.6.0 to 6.0.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.6.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/03ad4de0992f5dab5e18fcb136590ce7c4a0ac95...dd06d9cba3e5552c54d9f8ea23572deb30010f7c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 12:23:37 +00:00
edde746 23f9afa5b1 fix(windows): strip HDR10+ metadata from the Adreno tone-map path
Disabling peak detection (efb0e885) did not stop the per-frame shader LUT regeneration on Snapdragon devices: libplacebo keys the tone-map LUT on the frame's raw HDR metadata, and mpv forwards HDR10+ per-scene values (scene_max, scene_avg, ootf) on every decoded frame, so the LUT - at tens of milliseconds per rebuild on Qualcomm's D3D11 driver - still regenerated continuously and playback kept stuttering and flickering (#2191).

Zero the dynamic metadata before the renderer with vf=format:hdr10plus=no on Qualcomm GPUs; tone mapping uses the static HDR10 mastering metadata and the LUT is generated once. Dolby Vision L1 metadata is deliberately not stripped: dovi=no would break profile-5 rendering. The applied HDR pipeline options are now reported into the app log on first file load so uploaded logs are self-diagnosing.
2026-09-01 09:02:59 +02:00
edde746 efb0e885ae fix(windows): disable dynamic HDR peak detection on Qualcomm GPUs
Playing HDR content tone-mapped to an SDR display on Windows-on-ARM stutters heavily and flickers in brightness: dynamic peak detection moves the tone-mapping parameters every frame, libplacebo regenerates its tone-map and gamut-map shader LUTs each time, and Qualcomm's Adreno D3D11 driver takes tens of milliseconds per regeneration - roughly 100 ms of work against a 42 ms frame budget at 24 fps.

Scan the DXGI adapter list for a Qualcomm vendor id and set hdr-compute-peak=no when one is present, keeping auto elsewhere; static metadata-driven tone mapping generates its LUTs once.

close #2191
2026-09-01 07:37:10 +02:00
edde746 5fb4c78385 fix(jellyfin): restore Live TV on ts-only tuners and honor the quality preset
Since 2.18.0 no Jellyfin Live TV channel plays: opening one fails with
"Could not start the live channel", and switching channel inside the player
does nothing at all. Negotiating video transcodes as fMP4 (#2131) left the
device profile with no MPEG-TS entry, and Jellyfin discards every non-ts
transcoding profile for a live source whose tuner sets
UseMostCompatibleTranscodingProfile - hardcoded for every HDHomeRun host, the
default for M3U ones - so the negotiation came back with no HLS URL at all.

The device profile now sends a ts profile alongside the fMP4 one, listed
second, the way jellyfin-web does. Both of its codec lists are strict subsets
of the fMP4 entry's and the server ranks profiles with a stable sort, so ts
can only win where fMP4 has been filtered out; VOD keeps negotiating fMP4.
flac and truehd are absent because TS cannot carry them, av1 because that is
the gap fMP4 exists to fill.

Live TV also stops being unconditionally transcode-only. It now reads the same
saved quality preset the library path does: Original asks for direct play and
sends no bitrate ceiling, so a channel the server can hand over untouched no
longer costs a GPU encode session per viewer, and a capped preset transcodes at
that ceiling. A direct-play session whose stream dies re-negotiates a forced
transcode instead of re-opening a URL the server will not serve.

Two smaller holes on the same path: a negotiation that bailed out left the live
stream AutoOpenLiveStream had already opened running, because no playback
session existed to ever stop-report it - a few failed tunes could saturate a
provider's connection budget - and a channel switch that failed to start showed
no message at all.

close #2198
2026-08-31 19:23:22 +02:00
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00
edde746 976bdbd021 fix(ui): keep hero titles and clear logos legible in light theme
On the light theme, the detail hero's fallback title was hard-coded
white over a scrim that washes artwork toward the near-white background,
and white-on-transparent clear logos vanished into the same wash on the
detail, home, and TV spotlight heroes.

The title fallback now defaults to the theme foreground with a
background-side halo, matching what the TV hero already did. Clear logos
run through the channel-logo tone remap: light-toned marks recolor
toward the theme foreground on light backdrops (network and downloaded
artwork alike), colored marks keep their pixels, and dark themes render
the original artwork. The backdrop-luminance gate is shared with the
Live TV guide as logoToneTargetFor.
2026-08-31 18:51:46 +02:00
edde746 274b31ebac fix(livetv): recolor light channel logos for legibility in light theme
White-on-transparent broadcast logos (CBS, FOX, the NBC wordmark) vanish
on the light theme's white guide cards, making channels hard to identify.

Channel logo decodes now classify the frame's tone once per image-cache
entry and bake light-neutral pixels toward the theme foreground:
monochrome white marks recolor fully, mixed marks (colored peacock plus
white wordmark) keep their colored pixels, and self-backed or dark
artwork decodes unchanged. Applied in the guide channel column, guide
search, and favorite reordering whenever the backdrop is light,
including the dark theme's inverted focus card; dark backdrops keep the
original artwork.

close #2197
2026-08-31 18:51:46 +02:00
edde746 e1ef52aba1 fix(android): fall back to software decoding for non-Main-profile AV1
4:4:4 (High profile) AV1 played as green or black video on Android TV
devices with AV1 hardware decoders: MediaCodec defines no profile
constants beyond Main, but decoders like the Fire Stick 4K Max (MTK)
and Amlogic C2 accept the stream at configure time and emit garbage or
nothing, so no error ever reached the player and no fallback ran.

libmpv v1.2.2 carries an FFmpeg patch that reads seq_profile from the
bitstream at av1_mediacodec init and refuses High/Professional streams,
letting mpv's hwdec probing fall through to dav1d software decoding.
Main-profile AV1 keeps hardware decoding.

close #2194
2026-08-31 13:29:13 +02:00
edde746 9db6f54b7a feat(plex): make the covered-source direct play optional
A quality preset the source already fits under direct plays the file
since #2152, but a transcode above the source bitrate is sometimes the
point: a device that cannot decode the source (10-bit H.264, AV1, 4:4:4)
wants the server's highest-quality encode, and the shortcut silently
handed it the very file it cannot play smoothly.

A new Quality switch, "Play Smaller Videos at Original Quality" (on by
default), gates the shortcut in PlexClient._presetNeedsTranscode; off
restores the pre-#2152 behavior where any non-original preset always
transcodes. Plex-only by design: MediaBrowser servers make the
equivalent direct-play-vs-transcode call server-side.

close #2193
2026-08-31 11:32:07 +02:00
edde746 39ee089212 fix(audio): reload the mpv audio output when the HDMI route changes
TrueHD/Atmos passthrough with Frame Rate Matching enabled never engaged
the receiver on the mpv backend (Ugoos SK1 -> Denon AVR): the display
mode switch settles 2 s before the audio output opens, but some HDMI
chains are still renegotiating audio then, so the IEC 61937 track binds
a half-negotiated route and the sink never locks onto the MAT
bitstream - for the whole session. The same file with Frame Rate
Matching off, or on ExoPlayer, engages Atmos immediately: media3
rebuilds its sink when the audio device set changes, while mpv's
ao_audiotrack never revisited a track once created.

libmpv v1.2.1 polls AudioTrack.getRoutedDevice from the AO thread and
reloads the AO when the route changes - or vanishes and rebinds - under
a live IEC or raw passthrough track, reopening it against the settled
route. PCM tracks are exempt, and audiotrack-route-reload=no is the
escape hatch.

close #2190
2026-08-31 10:05:08 +02:00
edde746 89d54d8f1d feat(watch-together): let the host hand the room to another participant
close #2183

Reusing a room means whoever enters first becomes the host, and moving the
session to someone else's library meant everyone leaving and re-entering in
the right order. The host can now promote any connected guest from the
participant list (Watch Together screen and the in-player session sheet),
with playback, control mode, and reconnect identities carried across.

The relay owns the swap: a new `transferHost` message validates the sender
is the live host and the target a connected modern-protocol guest, then
swaps `HostPeerID` and the reconnect verifiers (each peer keeps its own
token), persists the room, and broadcasts `hostChanged` to every peer.

Clients flip roles in place: the controller swaps its role engine while
keeping the session, message queue, and player attachment. A promoted guest
seeds the coordinator with the room's last known intent (a paused room
stays paused) and the known-peer roster so the fresh epoch re-gates
instead of solo-starting; a demoted host falls back to a reconciler with a
fresh clock sync and asks the new authority for state. Guests re-pin the
host identity, reset their sequence, and re-converge their clocks.

A rejected transfer (`not_host` / `peer_not_found`) surfaces as a toast
instead of tearing the session down. Pre-transfer app builds in the room
ignore `hostChanged` and stop following the room at the next transfer;
targets on an old sync protocol are not offered the action.
2026-08-31 08:55:45 +02:00
edde746 44773fd742 feat(player): remember the swipe brightness level between playbacks
The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.

close #2178
2026-08-31 06:43:47 +02:00
edde746 4f4a7338e0 fix(search): tighten spacing around the kind filter chip strip
The chip strip sat 24px below the search field and 24px above the first
result. Drop the strip's own top padding (the field's bottom padding
already provides 16px) and halve the results sliver's top padding when
the strip is shown, so both gaps read as an even 16px.
2026-08-31 06:01:48 +02:00
edde746 10f9139ac6 fix(audio): bitstream Dolby/DTS as raw tracks on the mpv path
Audio Passthrough on 2.18.0 played EAC3 silently on a Shield in front of
a Dolby-Digital-only receiver: mpv's IEC 61937 AudioTrack bypasses the
platform's Dolby transcoder, so the pre-packed DD+ bitstream reached a
sink that cannot decode it, drained at full rate, and no failure was
observable app-side. Disabling passthrough lost surround instead, because
multichannel PCM collapses to stereo in the platform mixer before the
HDMI re-encode.

libmpv v1.2.0 unwraps the IEC bursts and feeds AC3, E-AC3 and the DTS
core to a raw ENCODING_AC3/E_AC3/DTS track - the transport ExoPlayer and
Kodi use - keeping the platform decoder/transcoder in the path, with the
IEC track as fallback and audiotrack-raw-passthrough=no as an escape
hatch. TrueHD and DTS-HD MA stay on the 8-channel IEC carrier.

The audio-spdif route probe now accepts a codec when the route takes its
raw track (the same direct-playback tiering media3 uses; encoding-only
below API 29) or one of the IEC shapes, matching the AO's transport
ladder. A dev-only PLEZY_LOCAL_MPV_AAR override allows testing locally
built fork AARs before a release is pinned.

close #2177
close #2179
2026-08-30 19:22:14 +02:00
edde746 3c1ab3cc6c fix(windows): bundle the Vulkan loader so libmpv's vulkan-1.dll import resolves
Since the libmpv bump to 20260809 (2.15.0), libmpv-2.dll dynamically links
the Khronos Vulkan loader instead of statically linking it. plezy.exe links
libmpv at load time, so on machines whose GPU driver does not install
vulkan-1.dll - Windows-on-ARM Adreno drivers, driverless x64 VMs - the
process failed before startup with "vulkan-1.dll was not found".

Download the LunarG-built loader (pinned, hash-verified) at configure time
and install it next to the executable with its license. Without a Vulkan
ICD the gpu-api=auto probe fails cleanly and falls back to D3D11, matching
the old static-loader behavior.

close #2110
2026-08-30 12:16:26 +02:00
github-actions[bot] c597390e1c chore: update cask to 2.18.0 2026-08-30 08:39:52 +00:00
117 changed files with 6675 additions and 225 deletions
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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"
+23 -10
View File
@@ -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 })
}
+11
View File
@@ -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",
+11
View File
@@ -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": "Напусни сесията",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "セッションを退出",
+11
View File
@@ -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": "Сеанстан шығу",
+11
View File
@@ -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": "세션 나가기",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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ę",
+11
View File
@@ -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",
+11
View File
@@ -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": "Покинуть сессию",
+1 -1
View File
@@ -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
+50 -6
View File
@@ -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',
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "離開工作階段",
+11
View File
@@ -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": "离开会话",
+11
View File
@@ -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.
+72
View File
@@ -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();
}
+17 -5
View File
@@ -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
+16
View File
@@ -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;
+8
View File
@@ -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
+13
View File
@@ -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,
);
}
}
+6 -2
View File
@@ -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,
+82
View File
@@ -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,
);
},
+118 -7
View File
@@ -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();
+49
View File
@@ -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();
}
+16 -2
View File
@@ -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,
+24 -1
View File
@@ -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(
+12 -1
View File
@@ -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)),
),
+7
View File
@@ -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(),
),
+5 -4
View File
@@ -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;
+27 -5
View File
@@ -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,
);
},
+26 -17
View File
@@ -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,
+8 -2
View File
@@ -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;
+16 -2
View File
@@ -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;
+37
View File
@@ -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
+108 -19
View File
@@ -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,
);
}
}
}
+5
View File
@@ -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.
+16 -1
View File
@@ -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;
+7 -1
View File
@@ -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);
+21
View File
@@ -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.
+12 -1
View File
@@ -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();
+18
View File
@@ -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);
}
+133
View File
@@ -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;
}
+13 -1
View File
@@ -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>{};
+88
View File
@@ -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();
}
}
+228
View File
@@ -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),
+35 -1
View File
@@ -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),
),
+17 -5
View File
@@ -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
View File
@@ -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,
+74
View File
@@ -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"})
+265
View File
@@ -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{})
+4
View File
@@ -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(
+257 -2
View File
@@ -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);
});
});
}
+112
View File
@@ -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},
],
},
+17 -1
View File
@@ -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);
+36 -7
View File
@@ -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