63f2bedf2c034998f4bd69deb9fdaea9710bd4d5
2883
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
63f2bedf2c |
fix(livetv): navigate guide rows in displayed source-group order
Vertical D-pad/arrow navigation stepped through the flat channel list, which is number-sorted across servers. With overlapping channel numbers from multiple DVRs, focus interleaved source groups and could dead-end before the last displayed row. Derive the up/down order from the same grouped rows the guide renders. close #1843 |
||
|
|
ff461d9f71 |
fix(livetv): explain a guide emptied by the favorites filter
With "Default to Favorite Channels" enabled and no favorites stored — or only favorites left over from a since-rebuilt lineup — the favorites filter reduced the guide to zero channels and GuideTab rendered just the timeline bar: no rows, no message, no sign a filter was active. Users read it as Live TV being broken; the Aug 8 report in #887 shows 44 channels and 447 grid programs loading in the log while the screenshot shows a blank guide and 0 favorite channels. When the filter removes every loaded channel, the guide tab now shows an empty state naming the cause with a "Show All Channels" action that clears the filter. The action stays D-pad reachable: the tab-bar focus handoff falls through to the action's focus node while the empty state replaces GuideTab, and activating it hands focus back to the restored guide content. Favorites that match no loaded channel get the same treatment as an empty favorites list. Verified: flutter test test/screens/livetv/, analyzer parity, clean_translations --check --strict, and slang codegen freshness. Refs #887. |
||
|
|
de76c0a515 |
feat(tv): refresh the Watch Next row without the app open
The Watch Next row previously only updated while the app was in the foreground, so it drifted stale until the next launch. A WorkManager periodic job (6h, network-connected, KEEP) now runs a headless Flutter engine executing `systemShelfBackgroundMain`, which mirrors the cold-start profile bind from cached tokens (never prompting for a PIN), fetches Continue Watching through the existing multi-server aggregation, and republishes the shelf through the normal Watch Next pipeline. The job is armed by a committed foreground sync, cancelled when the shelf is cleared, and re-armed after boot or app update only when persisted shelf state exists. It skips entirely while a foreground engine holds the shelf lifecycle lease, both to defer to the live app and to avoid two engines sharing the database in one process. The Dart isolate always reports completion over `backgroundSyncComplete`; the worker hard-caps the run at 90 seconds and destroys the engine on the main thread. |
||
|
|
291a22a4a4 |
feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its last foreground Discover pass. The app publishes per-profile server descriptors on every shelf sync (`updateSources`): token-free metadata in the app group, tokens in an app-group-shared keychain item, both wiped by `clear`. On success the extension rewrites the cached payload as the offline fallback; any fetch failure falls back to the previous cache-replay behavior. Poster images are passed as remote URLs, so the extension no longer depends on app-side artwork downloads. Episodes now render season/series poster art (2:3, `.poster` shape) instead of 16:9 episode stills, and labels lead with the S/E marker so long titles no longer hide it behind the focused-item marquee. Shelf schema v3 (Dart, Android, tvOS envelopes bumped together) discards stale wide-art caches instead of letterboxing them into poster slots. close #1474 close #1835 |
||
|
|
0b4fd9e8f3 |
fix(tv): host automatic multiline input in the Android IME
Android TV's docked keyboard handles multiline editors natively, so `automatic` no longer diverts them to the Flutter overlay there. Only Apple TV keeps the overlay for multiline input — its modal fullscreen system keyboard cannot edit multiline text. Surfaces that want the overlay for editing ergonomics (mpv config, connection editor, dialog text areas) already pin flutterOverlay explicitly. |
||
|
|
ce9556db22 |
fix(tv): restore the native Android IME for single-line text input
Android TV returns to the platform keyboard for single-line fields; the Flutter overlay stays for multiline and explicit call sites. The bugs that forced the overlay (#1051, #1079) were an engine show/bind ordering race, now repaired at the app level: - MainActivity retries a soft-input show the engine dropped while the FlutterView was not yet served (flutter/flutter#177360), rebinds the IME key session once at first show, and consumes leaked D-pad keys while the keyboard is visible (bounded restartInput budget) so focus cannot wander behind a stuck keyboard. - The platform text-input hint is activation-based, so gamepad pause and the pre-IME D-pad intercept track a live session instead of mere field focus. - While a session is live with the keyboard away, Back closes it and is consumed once, Select re-raises the keyboard, and arrows keep caret-aware edge-escape navigation instead of dead-ending. |
||
|
|
9d51f5aadd |
feat(tvos): scale Siri Remote swipe distance to the focused item
A focus step cost a fixed 180pt of pan travel regardless of what was focused, so small controls felt sluggish and large cards hair-triggered compared with the native focus engine, which prices a step by on-screen geometry. Derive per-axis thresholds from the focused control's rect (gain 1.1, clamped 100-360pt) and normalize axis resolution by them, so a wide-flat tile steps vertically once the finger covers its height. Focus scopes, the player's screen-sized catch-all surfaces, and nodes without layout fall back to the fixed threshold, keeping player chrome behavior unchanged. |
||
|
|
fe79817e76 |
fix(tvos): stop a single Siri Remote flick moving focus two steps
Touch travel banked during the swipe repeat cooldown was released as a second focus step by the first post-cooldown move frame, even when the finger had stopped or was lifting. Re-anchor the swipe delta on every frame inside the cooldown so a discrete flick emits exactly one step while a sustained drag keeps repeating. close #1756 |
||
|
|
f4ce60611b |
fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since |
||
|
|
8740a19f36 |
feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision request never sent offset=, so any non-zero open - resuming a transcoded title, or switching from Direct Play to a transcoded quality mid-playback - opened a session whose produced window begins at the start of the file and seeked it. mpv immediately requests a segment the transcoder has not produced, PMS answers 404 for it and every subsequent segment, and playback buffers forever. Send offset=<seconds> (6dp) with the decision and start request - the view offset on initial open, the resolved resume position on every in-place reload - so the session begins producing at the position the player consumes first. The playlist timeline is unchanged: an offset session's media playlist still covers the full title from segment zero, so the player keeps opening with start: at the resume position and in-stream seeks work as before. Before a native player opens an offset playlist, waitForTranscodeReady walks the master playlist, the media playlist, and the segment containing the offset, because PMS can publish a manifest before that segment is fetchable and mpv treats the 404 as an HLS error. The probe is best-effort: it never fails an open, hands off immediately on HTTP 500 (on the response and exception paths alike) so the server-limit dialog stays prompt, stops on cancellation, skips itself when the playlist durations never reach the offset, and stays out of the endpoint-failover cascade. In-place reloads resolve the replacement source only after the old stop report has gone out, so Plex cannot use that stop to terminate the replacement transcode. close #1840 |
||
|
|
bb3762ed63 |
ci: remove the Android Maestro e2e workflow
The Maestro suites remain runnable locally through scripts/run_maestro.py and scripts/run_maestro_ci.py; drop the workflow, the test that parsed it, and the CONTRIBUTING reference to automatic PR coverage. |
||
|
|
7437b43207 |
fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an unreachable fallback (e.g. the server host's Docker bridge gateway, which plex.tv advertises as a local connection) for a full connect timeout, failing every request in flight during that window (log bbr90). The cascade now probes each candidate with an unauthenticated /identity request under the discovery-race budget and only switches when it answers as the expected server, mirroring the Jellyfin trust gate. Unreachable-looking private IPv4 candidates stay in the list — a client on the server host can legitimately reach them, so reachability is probed, not inferred. |
||
|
|
e6be5f9fef |
fix(player): surface a persistent HTTP 503 at open instead of retrying forever
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a server that keeps refusing the stream at open time left a silent black screen: ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached the screen. A new open-phase watchdog arms on the first 503 seen before any frame renders and, after 20s without one, synthesizes a server-http-503 error that shows an actionable dialog. Mid-stream 503s and live TV keep their existing ride-out paths. close #1830 |
||
|
|
f0debe2c32 |
feat(ui): show a system-format clock on TV home and in the player
The clock renders through the existing formatClockTime helper driven by MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour setting instead of introducing an app preference. It re-arms a one-shot timer onto each wall-clock minute boundary rather than polling, and resyncs on resume because a suspended process runs no timers. The player header is shared by the mobile and desktop/TV controls, so one insertion point covers every form factor: the player is fullscreen everywhere, so it never has an OS clock to defer to. Home is the exception and only gets one on TV, where a leanback app hides the system clock; a phone status bar and a desktop menu bar already show the time. |
||
|
|
109f1eda4d |
fix(player): fall back to decoding when a TrueHD stream contradicts its container
Selection reads Format.sampleRate, but the rate family is only certain once a major sync is parsed. When a container announces the 48kHz family and the bitstream announces 44.1kHz, the packer emits nothing: handleBuffer consumed the input and reported success, so the stream played as silence for as long as it lasted. TrueHdMatPacker.reset also left the flag latched, so every later stream on that packer emitted nothing too. Leave the offending access unit in the buffer, signal the capability change, and let the decoder take the stream over. The packer clears the flag on reset. The latch has to outlive both flush and reset. media3 resets every renderer disabled by a new selection before enabling its replacement (ExoPlayerImplInternal.enableRenderers), and both audio renderers share this sink, so the outgoing renderer's reset arrives in the middle of the handover the latch exists to cause; clearing it there loops straight back into the mismatch. The real boundary is a new media item, which only ExoPlayerCore knows, so it signals one before setting a new source. The same-item recovery, DV-mode and subtitle reloads deliberately do not. It is a generation rather than a flag because that hook runs on the app thread while the mismatch is found on the playback thread: a late buffer from the outgoing stream would otherwise disable the carrier for its successor. Verified on the SEI Box R (Android 14, armv7) with a genuine 44.1kHz TrueHD stream in a container patched to announce 48000, so the bitstream and its checksums stay valid. The sink enters the carrier at 192kHz, reports the mismatch, hands over to FFmpeg and plays on. The device test asserts that sequence from the sink's own diagnostics, because the mismatch fires before the carrier opens an AudioTrack and the rate sequence alone cannot distinguish it from never having selected the carrier. |
||
|
|
be99f27b92 |
fix(player): move TrueHD off the carrier when playback speed leaves 1x
A bitstream cannot be resampled, so the carrier only ever accepts 1x. The selection gate covered that, but nothing re-ran it: setPlaybackSpeed reaches the sink and returns, and the renderer only re-asks when audio capabilities are invalidated. A speed change during carrier playback therefore left the carrier live and handed it parameters its empty processor chain cannot apply. Signal the capability change from the sink, which reaches onRendererCapabilitiesChanged and moves TrueHD onto the decoder; returning to 1x re-offers the carrier, so a speed nudge no longer costs Atmos for the rest of the session. The carrier delegate is never given a non-1x speed while that selection is in flight. Report the requested parameters rather than the delegate's while the carrier is active. The player polls the sink through the media clock and adopts what it reads, so reporting the pinned 1x pushed it back into the player and silently undid the speed change. Rebuilding the track selector parameters is not an alternative: DefaultTrackSelector skips invalidation when the rebuilt parameters compare equal, so a forced reselection can silently no-op. Verified on the SEI Box R (Android 14, armv7): carrier at 192kHz with no decoder, speed to 1.5x moves it to the FFmpeg decoder at 48kHz with the clock advancing faster than real time, and returning to 1x restores the carrier. The device test skips itself on hardware that never takes the carrier, as the Nvidia Shield does. |
||
|
|
3b76cf3948 |
fix(player): make TrueHD carrier-or-decode and never lose access units
Three defects in the carrier path, two of them found on hardware (#1804). Falling through to the normal sink when the carrier was unavailable handed TrueHD straight back to media3's raw ENCODING_DOLBY_TRUEHD path — the exact configuration this issue is about. TrueHD is now binary: the carrier, or reported unsupported so the bundled FFmpeg decoder takes it. media3's raw path has no demonstrated working case here and two broken ones, and even Kodi's raw fallback is a different thing, offered only after verifying at 192kHz. The 44.1kHz family was decided from a packer flag that is only set once a major sync has been parsed, long after selection. The carrier was therefore chosen for those streams and then packed nothing, which is silence rather than a glitch. It is decided from Format.sampleRate now, with the packer flag left as a loud runtime backstop for a bitstream that disagrees with its container. handleBuffer consumed the whole input buffer even when a burst was refused part-way through, dropping every access unit behind it — a media3 sample holds sixteen. The buffer position now advances per unit and the method returns false with the remainder intact, which is media3's own retry contract. A test rejects a burst mid-sample and asserts the carrier output is still byte-identical. The capability gate also needed tightening. getMinBufferSize answers yes for the 192kHz/7.1 IEC tuple on a Shield and the AudioTrack then fails to initialise: it reports that a buffer can be sized, not that the route will carry the format. Without getDirectPlaybackSupport there is no way to separate the two, so the carrier is not offered below API 33 and TrueHD decodes exactly as before. Verified on both connected boxes. SEI Box R (Android 14): carrier selected, AudioTrack built as IEC61937 at 192kHz/7.1, no decoder instantiated, clock tracks wall time. Nvidia Shield (Android 11): carrier declined, FFmpeg decoder selected, identical to its behaviour before this work. |
||
|
|
b7a438789f |
feat(player): bitstream TrueHD through the MAT/IEC 61937 carrier
Copies the path Kodi uses, and replaces nothing-but-detection with a route that actually plays (#1804). Android will not bitstream raw TrueHD on the TV routes measured here. Both connected boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable. Kodi models exactly that split: it packs the carrier itself and offers "AudioTrack (IEC)" as the recommended sink, treating raw TrueHD as a fallback that it still runs at 192kHz. Media3 only ever hands Android raw TrueHD at the stream rate, which on the reporter's box takes one write and then never advances the playback head. TrueHdCarrierSink routes TrueHD onto a dedicated delegate and leaves everything else on the existing processed sink. The split is deliberate rather than enforcing that the normal processors stay inactive: the carrier is a bit-exact byte stream shaped like PCM, so a downmix, Sonic pass or silence skip turns it into full-scale noise at the receiver. A delegate built with an empty AudioProcessorChain makes that impossible by construction, instead of putting the guarantee in a different class from the thing it protects. The carrier delegate keeps OutputConfig at PCM 16-bit so media3's position, pending-data and release accounting all stay on their mature PCM path — correct here, because after packing the stream really is a fixed-rate 192kHz 8-channel carrier. Only the AudioTrack itself is switched, through the builder modifier upstream applies just before AudioTrack.Builder.build(). That avoids reimplementing AudioOutput and avoids the encoded frame-domain mismatch in androidx/media#3329. Burst timestamps come from the carrier cadence rather than from whichever access unit closed the frame; anchoring on the closing unit drifts against the time the sink derives from written frames and reports a discontinuity on nearly every frame. Availability is Kodi's test, not media3's: getMinBufferSize for the exact 192kHz/7.1 IEC tuple, plus getDirectPlaybackSupport where it exists to confirm the route will bitstream rather than quietly decode. Speed changes, downmix, normalization and 44.1kHz-family streams all decline the carrier and decode. Verified on a SEI Robotics Box R 4K Plus (Android 14, armeabi-v7a): the carrier is selected, the AudioTrack is built as IEC61937 at 192kHz/7.1, no audio decoder is instantiated, zero timestamp discontinuities, and the clock tracks wall time with no frozen samples. The same box freezes for ten seconds on raw TrueHD. |
||
|
|
33c33c3d57 |
feat(player): pack TrueHD into a MAT/IEC 61937 carrier
Groundwork for bitstreaming TrueHD the way other players do (#1804). Android will not bitstream raw TrueHD on the TV routes measured so far. Both connected Android TV boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable, and the reporter's box takes a raw TrueHD AudioTrack and then never advances its playback head. Kodi models this split explicitly: it offers an "AudioTrack (IEC)" sink where it packs the carrier itself and treats handing raw TrueHD to Android as the fallback, and even that fallback runs at 192kHz. Media3 only ever does the raw form, at the stream rate. This adds the packer half: split a sample into TrueHD access units, assemble MAT frames with timing-derived padding, and emit IEC 61937 bursts. It is a port of FFmpeg's spdif_header_truehd rather than Kodi's CAEBitstreamPacker, because Kodi's is a thin wrapper over an already-assembled buffer while the MAT code placement and padding live in FFmpeg's stateful packer. Details the port has to get right. Media3's Matroska path concatenates 16 syncframes into one sample, so access units are split here; reading a single input_timing for sixteen frames would desynchronise the carrier. Burst buffers alternate and are reused rather than allocated, because a fresh 61,440 byte array every 20ms is roughly 3MB/s of garbage on the low-power hardware this runs on. A 44.1kHz-family stream rides a 176.4kHz carrier instead of 192kHz, which changes the whole AudioTrack tuple, so it is reported as unsupported for the caller to decode instead. A wrong byte here is not subtle — the receiver drops sync or renders full-scale noise — so the test compares against FFmpeg's own output byte for byte, using its input and output as fixtures. No caller yet; the sink that routes TrueHD through this follows. |
||
|
|
636fd48f40 |
fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the session. Unmarking it on another device and refreshing did nothing; only a restart cleared it. Unlike #1829 this needs no second device to cause -- a normal watch-through is enough, and the second device only makes it visible. A threshold crossing writes an unacknowledged overlay patch, deliberately: reporting success proves the backend received the report, not that it classified the item as played, so the patch stays owed until something settles it. _settleServerMark has three settled outcomes and only one of them did. The explicit-mark branch promoted; the two branches that skip the mark because the backend already recorded the watch itself -- Jellyfin from /Sessions/Playing/Stopped, Plex from a timeline crossing past LibraryVideoPlayedThreshold -- returned without promoting. Those are the common paths, so nearly every completed playback stranded a patch that the store then refused to suppress, because an unacknowledged entry is never retired by an authoritative read. Both now promote, through one idempotent helper that clears the id so the delivery callback and the settle paths cannot promote twice. Promotion has to follow delivery rather than the settle decision. A marks-on-stop backend settles when the crossing latches, which happens before the stop is sent, and until that stop lands the watch really is still owed -- promoting there would let a refresh retire a patch the server had never heard about. MediaBrowser also drops a stop for a session it never opened, in which case the watch it would have recorded never happens at all. So the stop path promotes only once the report reached a session able to act on it, which is the same condition that already governs whether the stop persists its position; that condition is now named rather than recomputed, and reset with its siblings when a session re-arms. The crossing branch needs no such gate: it is assembled from two delivered reports, so delivery is already proven. Verified against a live Jellyfin server driving the real client and tracker: before, the server reported the item unwatched after a second device cleared it while the overlay still rendered watched; after, the overlay follows the server. The optimistic mark still appears immediately during playback -- it now yields to a later authoritative read instead of outliving one. The #1287 and #1740 contracts are unchanged: neither branch issues an explicit mark, and the tests assert that alongside the promotion. |
||
|
|
5f397a99d9 |
fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829 |
||
|
|
3364b3c22c |
fix(startup): keep the platform launch screen behind the loading frame
Since 2.10.0 the app opens on a Flutter-owned startup frame, and that frame paints an opaque themed Scaffold before any preference is readable. Its themeMode defaults to system, so the theme comes from platform brightness -- and a TV has no system dark-mode toggle, so Fire TV and Shield report light. The result was a near-white #F7F7F8 sheet held for the whole gate, from prefs through Sentry to the database open, over an Android window the television resource qualifier had already painted black. Before 2.10.0 the gate ran ahead of runApp and no Flutter frame existed to cover it. Nothing in the loading frame is worth covering the launch screen for. Android composites Flutter in TransparencyMode.transparent over a window whose colour MainActivity already restored from plezy_prefs, so the loading Scaffold is transparent there and the launch screen carries the launch. Every other platform composites opaquely with nothing behind Flutter, so they keep painting their own background. The spinner and the failure screen still need a colour, and platform brightness is the wrong one for exactly the devices this bug is about, so the startup frames now adopt the persisted theme once it can be read. TV detection has to run before that read: the theme_mode default is TV-aware and isTVSync answers false until its singleton exists, which would resolve a fresh Android TV install to the light theme. Both singletons are memoised and awaited again by the gate. The read is best-effort -- an unreadable store is the gate's failure to report, not this path's -- and it also stops a startup failure from rendering as a full-screen white error page on a TV. darkThemeFor and materialThemeModeFor move onto ThemeProvider so the startup frames and the provider resolve OLED from one mapping rather than two. Verified on an Android TV emulator in television/notnight mode, clean install, cold start: peak frame luma 228 for 78 frames before, 0 frames above 120 after, and the same on a returning launch. close #1833 |
||
|
|
24a041977b |
fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load. |
||
|
|
e3703892b3 |
fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it. |
||
|
|
feb34caeb7 |
fix(i18n): shorten nav labels and complete translations in all locales
Nav bar labels that overflow their tab slot on phones are shortened to idiomatic short forms: fr (Bibliothèque, Téléchargement, Recherche), ru/bg/it (Live TV), pl (Home). All 21 locales get the ~280 keys that were empty (falling back to English at runtime): explore detail/badges/stats, fileInfo, startup repair flow, mediaMenu delete dialogs, addServer, rating sources, downloads sync-rule removal, and more. settings.displayScale was missing entirely in 16 locales; the new exoplayer playbackBuffer keys (upstream feat) are translated too. Fixes mis-translations found in review: es/zh/zh-Hant sidecar-format wording, sv adaptation, nb transcoding. Regenerated with dart run slang; translation hygiene and i18n tests pass. Close #1823 |
||
|
|
4816e3928f |
fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819 |
||
|
|
660e375248 |
feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s
ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or less free, with no way to raise it. On hardware where mpv cannot render at all that ceiling is the whole buffer budget. Playback Buffer offers Auto, Large and Extra Large. The durations are taken from jellyfin-androidtv and jellyfin-android so the same words mean the same thing across Jellyfin clients; Auto keeps the memory-tiered values that shipped. Named tiers rather than a duration because a duration would be a promise the load control cannot keep: prioritizeTimeOverSizeThresholds is disabled, so targetBufferBytes stops the loader even below minBufferMs and the byte cap binds first above roughly 23 Mbit/s. The tier crosses the method channel as a string and resolves in the core, where an unrecognised name falls back to Auto. LoadControlPolicy clamps the resulting pair: media3 validates the ordering with Guava Preconditions, an unconditional throw R8 does not elide, so a bad pair would be an IllegalArgumentException out of player construction rather than a bad buffer. The two play-start thresholds stay fixed even though the Jellyfin tiers move them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the stall watchdog indict a player that is obeying its own load control. That leaves the byte target as a second, often smaller ceiling, and nothing surfaced either. The resolved values now reach getStats, and the overlay's Buffer section gains a Cache Limit row reading "120s / 128MB" next to the buffered-ahead duration, so a tier that appears to do nothing on a high-bitrate file explains itself. close #1816 |
||
|
|
f5ccaab3ab |
test(player): anchor the passthrough absence check to the audio block
The check that keeps Audio Passthrough out of the in-player settings sheet dragged the first Scrollable ten times and then asserted the label was absent. That scrolling never moved: at the pumped 900x700 viewport the sheet fits its own content, so maxScrollExtent is 0 and the offset stays there through every drag. The assertion passed identically with no drags at all. It caught a reintroduced toggle only because the whole list happens to sit in the element tree at rest. Grow the sheet, shrink the viewport or give it a lazy delegate and findsNothing starts passing because the label is offscreen rather than gone, with nothing in the test to say so. Land on the audio block that used to hold the toggle first, then assert the absence. scrollUntilVisible throws when that block is missing entirely, so the guard fails loudly instead of quietly weakening. |
||
|
|
f63d0fe49e |
fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on the list's first track: MusicQueueController.load anchored _order[cursor] and shuffled only the rest, and _startQueue collapsed "no start track" into startIndex 0, so the anchor was always the head. Anchoring is right for the two callers that do have a track which must play first -- the now-playing shuffle toggle, and a load with an explicit start track -- so make "no explicit start" representable instead of inferring it from the index: load takes int? startIndex and shuffles the whole list, head included, when it is null. A start track the list turns out not to contain now drops the anchor rather than falling back to 0. Video playback was never affected: Plex shuffles server-side via /playQueues and Jellyfin already shuffles its full local list. The queue's Random is injectable so the service-level regression is deterministic without depending on the SDK's seeded-PRNG sequence. Close #1811 |
||
|
|
f5488cb7ff |
fix(player): hold the Watch Together anchor while the host reloads (#1809)
An in-place source switch — audio, subtitle, version or quality — detaches the host's player for the duration of the reload. `_broadcast` falls back to a position of 0 when no player is attached, so any state published in that window names 0:00 as the authoritative position and every guest hard-seeks to the start of the item. Heartbeats already suppress themselves while detached, which is why this hides: the paths that leak the zero are the ones that answer on demand. `onStateRequested`, `onPeerJoined` and `onReconnected` all broadcast regardless of whether a player is attached, so a guest entering the player, joining, or reconnecting mid-reload is the trigger. Fall back to the last broadcast anchor instead. That field is only assigned for untargeted broadcasts, so it holds the last position the room was actually told, and the reload's own re-attach path already re-anchors from it once the player comes back. |
||
|
|
db4f7a643b | test: prune low-value coverage | ||
|
|
094be1fa3e |
fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a checkmark, and the only way to shift it was to play it and skip to the end. Continue Watching membership on a MediaBrowser server is derived from UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played normally zeroes that position as a side effect, so the row usually disappears and nothing ever checked that it had. When something writes a position back afterwards the item is left played *and* resumable, which the resume route happily keeps returning forever. markWatched now reads the UserItemDataDto the mark already returns and clears the bookmark itself when the server left one behind, so the postcondition holds however the item got into that state. The follow-up write costs a request only when the invariant is actually broken. The writer putting items there is our own offline queue. insertWatchAction already drops queued progress for an item when the mark is itself queued, but the online mark writes straight to the server and queues nothing, so a progress row recorded earlier survived and replayed afterwards — pending actions go out oldest first — restoring the very position the mark had cleared. The sync service now listens for watch-state events and discards queued progress for that item as the mark lands. Progress recorded after a mark is a rewatch and is queued later, so it is untouched. Plex never showed this because it forwards the recorded-at timestamp and lets the server discard a stale replay; the MediaBrowser stop report has nowhere to put one. Continue Watching also drops the row locally now instead of waiting a round trip for the refetch to confirm it, matching what removal events already did, and marking a season or show takes its on-deck episode with it. Watched items are deliberately still not filtered out of the shelf: Jellyfin keeps Played set when new progress arrives, so a rewatch in progress is indistinguishable from a stuck row, and filtering would hide it. close #1812 |
||
|
|
309a107912 |
feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the Settings "change storage" flow and pm move-package. Adoptable storage relocates the private data directory with the APK, so downloads follow the app onto a USB drive adopted by an Android TV. Moving the app changes the private data directory, which invalidated any download task already enqueued: those pinned BaseDirectory.root plus an absolute directory that background_downloader persists verbatim, so a queued or paused download resumed writing to a volume the app no longer owns. Enqueue app-storage targets against the base directory the downloader re-resolves from the live app context instead, and drop the tasks and records a previous location left behind so the download restarts under the current one. That sweep runs before the downloader is wired up, because initialization delivers statuses accumulated while suspended — which can mark the row failed, and a failed row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues every killed record it finds, stale absolute directory included. Compare paths by containment rather than by string prefix while making a stored path relative. A custom download root that merely starts with the base directory's name is a sibling the app does not own, and stripping it re-rooted the download inside app storage. close #1794 |
||
|
|
21cf1ff8d4 |
fix(exoplayer): recover a stalled playback session instead of spinning on it
A session that lost its sink sat spinning forever: the watchdog measured media time, which does not advance while the picture is frozen, so a stall could not be told from an ordinary rebuffer and the recovery ladder was never climbed. The stall is now judged in playout time against the load control's own view of whether the buffer was ever enough. Readiness is the union of the three signals rather than a precedence chain, because media3 stops asking its load control once a renderer wedges - the very failure this watchdog exists to catch - and a stale verdict could otherwise hold it shut. The watchdog is armed on every path that replaces the source, including the same-state reloads that produce no state change of their own, and a seek rebaselines it so a backward seek does not inherit the old clock. Handing over to MPV keeps the position playback reached rather than restarting the episode, and a play or pause issued mid-handover is recorded on the queued open, which is the only thing left to command while one core is being disposed and its replacement does not yet exist. |
||
|
|
f63a4b039a |
fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was left staring at a launcher error with no way to link the account. The QR code and the linking code are now rendered in the app on a car, so the pairing happens on a phone while the vehicle shows what to scan. |
||
|
|
961e9c0326 |
feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone, and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default, adjustable in Appearance - by giving the app a smaller logical viewport and scaling the result back, so text, spacing and touch targets grow together instead of a font size being nudged in isolation. The scale sits above the messenger and the root Scaffold so snackbars and dialogs are scaled too, and insets are divided back into the scaled space so a system bar still reserves its physical size. A scaled surface is also a short one: the setup screen's fixed offsets and the now-playing transport are laid out to survive it, and a mistyped scale in a hand-edited settings file is clamped rather than failing startup. |
||
|
|
4607d165fd |
fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The gate is read at the single point where media actually opens, so every path that can start a picture - an explicit play, a gapless arm, a track or channel switch, a frame-rate-match resume, a reload, and the queue navigation commands of the OS media session - is covered by one check rather than by a guard at each call site. A seek can also start playback with no play call, because mpv resumes when it seeks off the end of a file, so a restricted seek is followed by a pause. Watch Together needed the pause to be local. A vehicle stopping one peer is not a room-wide intent: a guest's forced pause is swallowed by the attachment's ledger rather than published, while a host's still pauses the room, because a host that kept broadcasting a frozen anchor would stall or rewind every guest it was meant to protect. The layer that owns a pause owns the resume for it, and one acknowledgement is recorded per event, so a surplus cannot eat the user's next real pause. |
||
|
|
3a56218a12 |
fix(automotive): keep music playing while a car is parked, and silence it while driving
Music ran under a foreground service whose lifecycle observer was registered for App TV, so backgrounding the app on a head unit never paused it and driving never stopped it. Both halves were wrong for a car: parked audio must survive the app going to the background, and DD-2 requires it to stop when the vehicle starts moving. The vehicle now owns exactly the pause it caused. It is claimed when a restriction arrives and discharged on the event that proves the resume, so a track the user paused during a drive stays paused when the car parks. A restriction landing while the next source is still resolving silences the native player as well as the session, because the previous track is still coming out of it, and a pause that throws ends the session rather than leaving audio running in a moving car. |
||
|
|
7ce5a443fd |
feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization, and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the verdict over the existing platform channel, where a single Dart gate answers whether playback may start. The car service is reached through the lifecycle-listener overload rather than Car.createCar(Context). That overload blocks its caller for up to five seconds polling ServiceManager, and on car-service death it reaches killClient(), which kills the hosting process for any context that is not an Activity or a Service - a crash in a system component would take the app down with it. Head units on Android 9 and 10 predate the listener, so a legacy ServiceConnection is used there, with the same identity guard on reconnect. A vehicle that has not answered yet counts as restricted, and one deadline is spent resolving it rather than one per request, so a wedged car service delays playback once instead of on every open. |
||
|
|
f93952ba6f |
fix(android): stop tunneling 24p video on the Fire TV Stick 4K
Tunneled playback on an AFTMM judders continuously through 23.976p direct play. The #1802 reporter isolated it: turning off Tunneled Playback with every other setting unchanged makes it smooth, and their log shows tunneling active for the whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing. Audio Passthrough looked like the trigger only because it is the one user-facing switch that decides it. Passthrough off, or Downmix to Stereo on, both force the Dolby track to decode to PCM, which trips the #1458 guard and takes tunneling down with it. Passthrough on with downmix off is the only combination that keeps a bitstreamed track, so it is the only one that stays tunneled. Withdraw tunneling on that model for content at or below 30fps. The cut-off keeps 4K50/60 tunneled, which is the workload Amazon documents the feature for. The mechanism stays unconfirmed: tunneling fires no VideoFrameMetadataListener and stops media3 counting frames in the codec, so nothing app-side can measure the cadence. Only the trigger is established, and the quirk is scoped to it. That needs a frame rate the app did not have. Neither MatroskaExtractor nor Mp4Extractor populates Format.frameRate, and a tunneled session renders no frames back for the native detector, so the server's rate now rides on the open call. It is sent only for direct play, matching _primeDisplayCriteria: a transcode's metadata describes the source, not what the server is about to send. Also move Audio Passthrough out of the in-player settings sheet. It configures the audio output route rather than the current playback, and applying it mid-stream bounces the audio renderer and re-decides tunneling. Settings > Video Playback already owns it, next to Tunneled Playback, which is applied the same way. That description now mentions stutter, not only black HDR video, so the workaround is findable on hardware this quirk does not cover. The mpv backend failing to start the same 4K file is a separate defect and is not addressed here; its uploaded log is no longer retrievable. |
||
|
|
1b6a811c07 |
fix(delete): name the delete target and verify what its files back
"Delete from server" read identically for an episode, a season and a
whole show: same menu label, same dialog title, same red button, and a
body that named nothing. The menu header did not disambiguate either,
because MediaItem.displayTitle collapses an episode to its show name.
A reporter deleted a whole series from the detail hero's ⋮ believing it
acted on the episode he had highlighted, and the confirmation gave him
nothing to catch it with. Every one of those strings now names the kind,
and the body names the exact item — show, season and episode number, and
episode title.
Deleting a single item also destroyed files the confirmation never
mentioned: a Plex multi-episode file (S01E01-E03.mkv) takes its other
episodes with it, and a split item takes every part. The dialog now
reports that up front and, on success, emits deletion events for the
siblings the server destroyed so their rows do not linger.
The scope behind that warning is only asserted when it is established.
MediaItem.allPartFiles drops parts with no path, so a non-empty set
proves nothing about the ones it filtered out; a version is trusted only
when every part reports a file. A browse row that omits paths is missing
evidence rather than proof of a distinct file, so both the target and
each candidate sibling fall back to the detail endpoint before any
conclusion — otherwise a thin row, including the file-less part
PlexMappers fabricates for an empty payload, would look like a server
that withholds paths. When the answer cannot be established the dialog
says so in an error-tinted block and its button reads "Delete anyway",
separating a transient probe failure from a server that never sends
paths. It deliberately does not refuse: Plex withholds paths from
restricted users the server itself authorizes to delete, so failing
closed would take the feature away from them permanently.
Probing a season stays bounded in both directions. Siblings resolve one
at a time, so a season of thin rows cannot fan out a detail request per
episode, and expiry cancels the walk rather than merely abandoning it —
`Future.timeout` completes the future the caller awaits but leaves the
work behind it running, which would resume on the next sibling once the
outstanding request answered. A cooperative flag is checked before each
lookup, so at most the one already in flight outlives the deadline; the
neutral client exposes no abort handle for item lookups, so that one
cannot be recalled.
The spinner covering the probe was only barrierDismissible, which does
not stop system back. Back dismissed it and the cleanup pop then closed
the screen underneath, dropping the user out of the detail page
mid-flow. It now traps back, matching the non-dismissible contract its
own doc claims, which also repairs the log uploader and the file-info
sheet.
Coverage splits by what each layer owns. The dialog, its copy and the
DELETE wiring are backend-neutral and stay in the menu widget tests.
Plex — the backend multi-episode files actually come from — gets the
resolver over a real PlexClient and a mocked transport: a row with no
media at all, scope recovered from /library/metadata/{id}, siblings and
paths from /children, a Part that names no file, a sibling whose path
never resolves, the request count a sixty-episode thin season may cost,
and the rating key the DELETE carries. Those are plain async tests
because the Plex metadata cache is a real database whose I/O the widget
tester's fake clock never drives. Deadline behaviour needs the opposite,
so it is pinned separately under fakeAsync against a gated fake client,
with no wall-clock waiting anywhere.
close #1781
|
||
|
|
9d51a040c3 |
fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys navigating the OSD instead of seeking: the first press seeked and silently moved focus onto Play/Pause, and every press after that walked the buttons. A window blur drops Flutter's primary focus to the root scope, so the player screen's reclaim parks it on its own node. The only handoff back down to the controls was the chrome visible->hidden transition, so with the OSD up nothing reclaimed it -- hence the reported workarounds of letting the controls hide, or moving the pointer off the player and back. Pointer exit normally hides the chrome and masks this, which is why it only shows when the pointer stays over the player while another window takes focus. Hand the surface back on window re-activation, next to the existing hide-path claim, and rename the helper since it is no longer hidden-chrome specific. The claim runs synchronously because a platform callback is not guaranteed to be followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the two no longer compete. Also gate the screen's self-heal so a directional key no longer pulls focus into the OSD when "Video Player Navigation" is off -- Tab and select keep their path in, which the ungated return value would otherwise consume with nowhere to go. |
||
|
|
23b8befe11 |
test(theme): load the deferred locale off the widget tester's fake clock
`await LocaleSettings.setLocale` inside a `testWidgets` body waits on a deferred library load that only completes on the real event loop, so the regex-dialog test hung indefinitely rather than failing. It passed only because an earlier plain `test` in the same file loads `de` first — running the file alone, under a name filter, or sharded apart from that test stalled the run until the ten-minute timeout. |
||
|
|
26dbce0277 |
fix(profiles): name the Plex user and account in one translated chip
A Plex account connection labels itself with the account owner's name. Under a profile tile that reads as being signed in as the owner: the Plex Home tile showed the owner beneath the Home user's own name, and a local profile that borrowed a Home user out of someone else's account showed only the lender. Both halves of the relation now go through a single translated string, so a locale orders them itself instead of inheriting the English "user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the account first. When the Home cache cannot resolve the connection's uuid the chip names the account alone rather than falling back to a bare name. ProfilesView carries the Home user cache that resolution needs, and chip labels ellipsize now that an account label can be an email. |
||
|
|
edaff1fbfc |
Merge pull request #1789 from JackDanger/fix/plex-home-account-chip
fix(profiles): label a Plex Home parent connection as an account Conflict resolution: regenerated the Slang outputs against main's translation set, added the empty locale placeholders the translation gate requires, and gave the new widget test the StorageService provider the picker now reads for profile recency. |
||
|
|
eaa1736c4e |
feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a Device Code app so no client secret or redirect URI ships in the binary and TV, mobile and desktop all use the same flow. MDBList omits `verification_uri_complete`, but its device page seeds the code field from a `user_code` query parameter and the sign-in redirect preserves the query string, so the activation link is built locally and the dialog's open button lands on a filled-in form instead of an empty one. A server-supplied complete URL still wins if one ever appears. Poll state is read from the response body rather than the status code: `authorization_pending` and `slow_down` both arrive as HTTP 400, and a missing grant answers 404 `device_not_found`. Writes go out as real-time `/scrobble/*` reports plus `/sync/watched` for the marks that never pass through the player, with ratings on `/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block has no `tvdb` field, so a TVDB-only item is skipped rather than written under an empty id block. |
||
|
|
541fc2c097 |
test(player): measure AudioTrack release accounting on real hardware
The #1790 fix turns on an invariant no JVM fake can observe: `DefaultAudioSink` charges a static, process-wide counter per flush and discharges it only from `Listener::onReleased`, and any lasting imbalance stops media3 escalating audio failures at all. The wrapper tests pin the wrapper's side of that contract against a fake; nothing checked it against a real sink. `onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased` once per answered flush, both on the public `AnalyticsListener`, so counting them across the cycles the reuse cache actually creates measures the counter directly without reaching into media3 internals. Seeking repeatedly exercises the park-and-reuse path; switching to a fixture with a different channel count forces the eviction path. On a Shield with the pre-fix wrapper this reports initialized=5, released=0 after four seeks — the counter climbing once per seek in a live session, which is the state that makes a later AudioTrack failure unrecoverable. A second case runs the same measurement on a bitstream route, which is the output that failed on the reporter's device. It probes the live route the way the app does and skips when there is no encoded surround, so a phone or a TV set to PCM does not report coverage it never had. |
||
|
|
b97a22c213 |
fix(player): report every AudioTrack release so a failed one can recover
An episode that opens but never plays, forever, with no error and no way out except force-quitting the app. The reporter's log has the whole shape: media opens at 85206ms, the first video frame renders, `AudioTrack init failed 0 Config(48000, 252, 5, 40000)` is logged exactly once, and the position never moves again. Force-quitting fixes it for a while, which is the tell — the state that breaks recovery is process-wide and static. `DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every renderer disable, every reconfigure — and increments a private static `pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`. `RawPositionAudioOutput.release` never called `delegate.release()` for a cacheable output, and it forwarded `addListener` straight through, so the sink's listener sat on the real output while the wrapper was parked and the increment was never balanced. media3's own delivery is lossy too: it posts `onReleased` to the playback looper, which `ExoPlayer.release()` has already quit by the time the 20ms-delayed release runs, so even a real release drops its decrement at teardown. A counter that never returns to zero silently disables media3's escalation of both init and write failures: `PendingExceptionHolder` arms its throw deadline only when nothing is pending, and short-circuits every retry while something is. So the `InitializationException` is never thrown, the audio renderer never becomes ready, and the player is pinned in `STATE_BUFFERING`. No `PlaybackException` means `retryAfterAudioTrackError` never runs, which is why the same failure recovered onto decoded PCM earlier in the same log and hung outright later. The wrapper now owns the listener set and answers every flush exactly once: at once when it parks the track, because a parked track is never going to release; on the delegate's confirmation for a real release; and from the provider at teardown, where nothing else ever will. Bitstream outputs are not parked at all — a direct route is often single-instance and a parked one would block its own successor. An eviction therefore builds its replacement while the old AudioTrack is still going away, as upstream does. Holding the count open across the park to buy media3 patience for that window was tried and is worse: it pins the counter above zero for the whole live track after the first seek, which is the hang above. Refusing to allocate until the release confirms is worse too — the refusal reaches media3 as an init failure with no pending release to excuse it, so the 200ms deadline starts immediately and a slow TV teardown turns an ordinary config change into a playback error. If the overlapping allocation does fail, media3 escalates into the audio recovery ladder and the watchdog below backs it up. Because no amount of accounting hygiene guarantees media3 will raise the next failure, add the watchdog that was missing. Nothing covered "buffering, holding data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the position past the duration, and media3's stuck-buffering detector wants an empty buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the existing audio ladder — now shared with the exception path — then to the mpv backend rather than leaving a spinner up. The watchdog only indicts a player that could have started. `DefaultLoadControl` is configured to hold playback until 5s is buffered after a rebuffer, so the stall threshold is derived from that same constant rather than guessing at one, and a buffer below it reads as starved — the loader's business, not the renderer's. Starvation also restarts the stall clock, so a minute of network rebuffering cannot bank the timeout and have the first poll after recovery report a stall that never happened. Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only retry is to keep halving; upstream adopted the same 1s floor in #3207. Recovery now resumes from the furthest position reached rather than `lastPosition`, which the poller writes down as freely as up — a dead clock reporting 0 is how an audio recovery restarted a resumed episode from the top. On the Dart side the episode loading flags are cleared on every exit of the in-place reload, not just the success and rollback paths; a flag stranded by a superseded reload made the Next button a no-op for the rest of the session. close #1790 |
||
|
|
80d3537975 |
fix(linux): do not schedule audio recovery after playback resume (#1786)
This was resulting in two audio stutters per playback resume.
Regression originates in
|
||
|
|
8879941d29 |
fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on Windows and Linux whenever HTPC-style player navigation was off, so it dropped the window out of fullscreen regardless of who put it there. For anyone running with "start in fullscreen" (or who had toggled fullscreen from the browse UI), backing out of a movie left the app windowed, with "exit fullscreen on player close" switched off. Track fullscreen ownership instead: FullscreenStateManager now exposes a scope that the player opens in initState and closes in dispose, and setFullscreen — the single funnel every desktop platform reports through (window_manager on Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) — records whether the fullscreen currently active was entered inside that scope. Escape only exits fullscreen the player itself entered; otherwise it is plain Back. The scope is depth-counted so the next-episode swap, where the incoming screen's initState runs before the outgoing screen's dispose, carries ownership across rather than resetting it. Nothing changes for a user who fullscreens from inside the player: Escape still exits fullscreen first, then acts as Back. The fullscreen toggle button and its shortcut are untouched, as is exitFullscreenOnPlayerClose. Fixes #1624. |