Commit Graph
312 Commits
Author SHA1 Message Date
edde746 69fadc220d chore: clean up code comments 2026-08-10 20:28:41 +02:00
edde746 01279e5fbb fix(automotive): stop blocking parked playback when the car service has no verdict
CarRestrictionsMonitor.bind() treated a null getCurrentCarUxRestrictions() as
a restricted verdict with supported = true. Dart then latched the restricted
state, every play path refused to start, and on a car that stays parked no
restriction transition ever arrives to correct it — video never played for the
whole session. This is the failure mode behind the Play Automotive rejection of
version code 128 ("unable to play video content"): a review bench whose car
service tracks no restrictions for the resolved display gets exactly that null.

A missing verdict now stays pending instead: Dart keeps lifecycle gating
(parked, foregrounded video plays; while driving the platform blocks the
activity, so DD-2/DD-3 still hold), the registered listener adopts the first
real verdict, and every later getState retries the read. Listener registration
is identity-guarded because retries re-enter bind() with the same cached
manager instance.

Verified on an API 34 Automotive emulator: CarRestrictionsMonitorTest passes on
both connect routes, parked playback starts, driving pauses it behind the OS
blocking screen, and parking again leaves it paused until the user resumes.
2026-08-10 19:00:50 +02:00
edde746 8d284efa39 chore(android): fix ktlint function-signature violation in ShelfRefreshWorkerTest 2026-08-09 17:21:01 +02:00
edde746 8e5279a487 feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android +
ExoPlayer only, default off). When enabled, the text SubtitleView is
sized to the full container instead of the letterboxed video rect, so
SRT/VTT/mov_text cues render in the black bars below widescreen video
and font size and the position setting become relative to the physical
screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged;
mpv already places plaintext subtitles in the margins by default.

close #1730
2026-08-09 17:14:34 +02:00
edde746 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.
2026-08-09 10:59:30 +02:00
edde746 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
2026-08-09 10:59:16 +02:00
edde746 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.
2026-08-09 09:56:53 +02:00
edde746 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.
2026-08-08 10:51:15 +02:00
edde746 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.
2026-08-08 10:51:15 +02:00
edde746 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.
2026-08-08 10:51:15 +02:00
edde746 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.
2026-08-08 10:51:15 +02:00
edde746 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.
2026-08-08 10:51:15 +02:00
edde746 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
2026-08-07 08:43:47 +02:00
edde746 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
2026-08-06 03:47:43 +02:00
edde746 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.
2026-08-06 03:45:09 +02:00
edde746 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.
2026-08-06 03:45:09 +02:00
edde746 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.
2026-08-06 03:45:09 +02:00
edde746 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.
2026-08-05 12:03:05 +02:00
edde746 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
2026-08-05 12:03:05 +02:00
edde746 d83d0790ba fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and
tagged `external_<n>`, then recovered from the Format id the track selector
reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges
side-loaded subtitles with the primary source and MergingMediaPeriod rewrites
every child format id to "<periodIndex>:<originalId>", so the tag arrives as
"1:external_0" - measured on device - or "0:1:external_0" behind the
container-sidecar merge. The prefix test therefore never matched and every
sidecar reached Dart as an embedded track with no URI.

A Plex sidecar's only identity is its stream key, which the app carries in
that URI, so both matchers failed on it: a server-selected sidecar could
never resolve and left subtitle selection pending, and a manually chosen one
could not be mapped back to a stream id to write to the server. The
already-attached branch of addSubtitleTrack compared the raw id too, so
re-selecting a loaded sidecar silently did nothing.

Route every write and readback of the tag through ExternalSubtitleIds, which
matches the final id segment, and cover it with an instrumentation test that
side-loads a subtitle through the real media3 media-source factory. Also stop
claiming a saved track selection when no server stream was identified - there
is no local store, so that path silently dropped the user's choice.

close #1713
2026-08-02 12:19:28 +02:00
edde746 395798f28e fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto
On Auto, Dart derives a buffer size for mpv's demuxer from the device heap and
sets it as `demuxer-max-bytes`. The Android player forwarded that same number to
`DefaultLoadControl.setTargetBufferBytes`, so ExoPlayer's sample allocator was
sized by a tier table written for a different consumer: 64MB on any device whose
large heap is 512MB or less, which every Shield is.

`targetBufferBytes` is a byte cap, so the media it represents collapses as
bitrate rises — 64MB is 53s of a 10 Mbit/s stream but 5.2s of a 103 Mbit/s UHD
remux. With `prioritizeTimeOverSizeThresholds` false the cap is hard:
`shouldContinueLoading` returns false the moment the allocator reaches it no
matter how little media that is, and `shouldStartPlayback` reports READY off the
same byte term. Read-ahead that short starves the audio sink in bursts, and on a
passthrough route that is enough to keep the AudioTrack from ever starting — the
track initializes, accepts one access unit and never renders a frame. Because an
enabled audio renderer owns the MediaClock, the whole player freezes and the
black-screen watchdog then blames the video decoder and drops the session to
mpv.

Size the LoadControl target natively instead, from what actually bounds
`DefaultAllocator`: the Java heap. `min(media3's own default for a video+audio
selection, largeMemoryClass/4, availMem/4)` with a 32MB floor, the lowest tier
that has already shipped. The quarter matches the threshold the Buffer Size
setting already warns at, and the media3 default is a ceiling — this is not
"buffer more than upstream", it is "stop buffering less". Deliberately not
bitrate-aware, because the LoadControl is built during initialize, before any
media is opened. `bufferSizeAuto` carries the distinction over the channel;
`bufferSizeBytes` still travels with it because the plugin's mpv fallback
replays it as a real demuxer property, and an explicit Buffer Size choice is
still honoured verbatim.

Confirmed against the hardware in the 2.9.1 passthrough report. That reporter's
own log is a natural A/B: three runs at 64MB fail with `0 frames rendered after
8002ms`, spanning both DV conversion modes and both tunneling states, while the
single run after he manually selected 128MB logs `Position advancing` and
renders. Reproduced on the same Shield model with codec and bitrate held fixed
and only the cap varied — 6s of audio demand stalls at 64MiB and plays at
128MiB, 4 of 4 predictions, with read-ahead measured off an injected
DefaultAllocator at 65 664 and 131 776 KiB. That device reports
`dalvik.vm.heapsize` 512m, so the heap term binds first at every free-memory
level in his log and Auto now derives exactly the 128MB he had to pick by hand;
the shipped path logs `Buffer: 128MB limit (auto, heap=512MB, available=568MB)`
where it previously logged 64MB.
2026-08-01 06:59:21 +02:00
edde746 4fe4f7b1d7 fix(mpv): stop loading the ytdl hook for media-server streams
Every URL the player opens is a media-server stream or a local file, so mpv's
bundled ytdl_hook has nothing to resolve. It still ran an on_load hook per
open and, whenever an open failed, spawned yt-dlp with the full stream URL in
its argv — access token included, readable through /proc on Linux. It also
added ~700ms to every failed open and buried the real "[stream] Failed to
open" line under three ytdl_hook errors.

mpv decides whether to load the builtin script inside mpv_initialize, so this
has to be an option set beforehand rather than a property set from Dart.
Verified against mpv 0.41: --ytdl=yes logs "Loading lua script
@ytdl_hook.lua", --ytdl=no never loads it.

Apple is deliberately excluded: the bundled libmpv is built without Lua, so
the option does not exist there and setting it would only print an mpv error
on every player init.
2026-07-31 21:45:33 +02:00
edde746 86c8011b72 fix(player): tell the user when the server cannot read the media file
A 404 on the media stream means the server resolved the item but could not
open the file behind it — moved, deleted, or on storage that went away.
Jellyfin maps the resulting FileNotFoundException to 404, and PlaybackInfo
never stats the file, so negotiation succeeds and only the stream request
fails. Playback then died with a snackbar reading "Failed to open
[REDACTED_URL]" before popping the route, which tells the user nothing and
leaves nothing useful in a bug report.

Generalize the HTTP-500 log probe into PlayerError.httpStatusFromLog and
latch every status in fatalPlaybackHttpStatuses. Each latches on its own so
the 503 that stream-lavf-o deliberately retries cannot mask the fatal status
behind it. A 404 now raises a dedicated modal naming the cause and the fix.

On Android a 404 previously failed the "Response code: 500" string test and
fell through to the ExoPlayer→MPV fallback, showing "switching to compatible
player" before failing again on the same request. Read the real status off
HttpDataSource.InvalidResponseCodeException instead and skip the fallback:
an HTTP status is not a codec problem.
2026-07-31 21:45:33 +02:00
edde746 69ceea9466 fix(android): grant shelf artwork to every installed launcher
The Watch Next poster art moved to a local content:// URI in 2.10.0, gated
twice on the package returned by resolveActivity(MAIN+HOME,
MATCH_DEFAULT_ONLY): once as the grantUriPermission target, once as a
caller-identity check inside SystemShelfArtworkProvider.openFile. Any
launcher that is not the resolved default HOME activity was denied on every
image and drew its broken-image placeholder instead. Fire OS pins its own
launcher and silently reverts a third-party default, so Projectivy could
never satisfy either gate; a device with several launchers and no chosen
default resolves to the resolver activity and granted nobody at all.

Discover consumers with queryIntentActivities(MAIN+HOME, MATCH_ALL) so every
installed launcher is granted, drop the hand-rolled identity check, and make
the provider non-exported so the framework enforces the per-URI grants that
are now the only access path. Because those grants became load-bearing,
grantReadAccess reports failure per poster and the sync rolls back rather
than committing a row no launcher can open.

close #1706
2026-07-29 05:12:28 +02:00
edde746 726dfc6507 fix(android): finish the item when ExoPlayer never ends playback
media3 reports STUCK_PLAYING_NOT_ENDING when the player sits in STATE_READY
past the declared duration with no renderer ending. On a tunneled MTK decoder
the clock ran a full minute past the last frame behind a black screen, so the
item never completed: no Play Next, no auto-play, and a "playing" timeline the
server kept extrapolating past the item duration.

Treat that report as the end of the file when the rendered-frame counter has
stopped as well, which separates a finished file from a container that
under-declares its duration and is still painting. The terminal event is shaped
like the STATE_ENDED one and pins the timeline at the duration first, so the
completion flow cannot mistake it for a stream that died mid-file. Shorten the
detection window to media3's stuck-playing default, and clamp a backend
hand-off to just inside the media so a fallback can no longer resume MPV past
the last frame and park there without reporting it.

close #1673
2026-07-28 23:53:52 +02:00
edde746 41ffaa7f2b fix(automotive): stop playback while a vehicle restricts the app
Plezy declares appCategory="video", so on Android Automotive OS it is a
parked app bound by car app quality DD-2/DD-3: audio must stop when the
vehicle starts driving and must not be resumable while driving. Two paths
kept audio alive. Music playback ran under a mediaPlayback foreground
service whose lifecycle observer was registered for Apple TV only, so it
never paused when Android backgrounded the app. Video pausing hung off
AppLifecycleState.hidden, which Flutter only synthesizes once Android
delivers onStop; a car without the Automotive compatibility mode delivers
onPause alone, which maps to AppLifecycleState.inactive and the player
ignored.

Gate every path that can start audio on a new lifecycle predicate,
automotivePlaybackAllowed, which permits playback on a car only while the
app is resumed and fails closed on an unknown lifecycle state. That covers
explicit play, gapless arming and track transitions, live retry and
channel switch, frame-rate-match resume, VOD/live startup, and the queue
navigation commands of the OS media session, plus a last-resort pause for
when the platform player resumes itself on native audio-focus regain.
Playback authority on the media-session router is deliberately left alone:
the router consumes a denied event, so gating it would swallow PauseEvent
and leave the OS unable to stop audio. Reacting to lifecycle callbacks is
the mechanism the platform documents as sufficient, so no android.car
dependency is added.

The music queue no longer requests POST_NOTIFICATIONS on a car, where the
foreground service and its notification never start: there is nothing to
authorize, and the prompt would take focus and make the gate discard the
first play intent.

Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV
verdict, so a rotary-only head unit no longer inherits the leanback
experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE,
which cars lack, so the app's UI cannot stay on screen while driving, and
nothing forces a preferred orientation on a fixed-orientation display.
2026-07-28 23:28:25 +02:00
edde746 8aa836d106 ci(android): gate name-based reachability on an R8-minified variant
R8 only ever ran on `release`, so every automated gate in this repository
exercised code the shipped APK does not contain. Reflective lookups, JNI
callbacks and native library loading can all break under shrinking while
`flutter test`, the Robolectric suites and `connectedDebugAndroidTest`
stay green — which is exactly how #1703 shipped, with the bundled FFmpeg
audio renderer shrunk out of release builds for TrueHD and DTS-HD.

Add a `minified` build type that inherits release's shrinker
configuration but stays debuggable and debug-signed, so it is an ordinary
test artifact and never a publishable one. Three integration details
took a run each to find: the Flutter plugin copies app build types into
every plugin module, so library-level shrinking deleted the plugin entry
points that only GeneratedPluginRegistrant references; the harness must
not be shrunk or the runner disappears; and androidx.test has to survive
in the app under test, or the runner cannot link its own supertype and
the run reports zero tests instead of failing.

Instrumentation still defaults to `debug`, because only one build type
can host androidTest and the existing playback suites drive media3
builder APIs the app never calls, which R8 shrinks legitimately. The new
reachability test opts into the minified variant instead and touches no
builder API, so the only keeps it depends on are the ones under test.
Emptying proguard-rules.pro was verified to fail it.
2026-07-28 20:13:40 +02:00
edde746 a183c17c3b fix(android): derive the mpv fallback passthrough list from the audio route
Audio passthrough defaults on for Android TV, scoped to ExoPlayer because
mpv force-passes through every codec named in audio-spdif and has no
decode fallback. That scoping did not survive the ExoPlayer to mpv
handoff: PlayerAndroid queued the raw ac3,eac3,dts,dts-hd,truehd list as
a pending mpv property and prepareMpvFallback replayed it verbatim, so a
sink that bitstreams only Dolby formats was told to force TrueHD and
DTS-HD anyway. mpv selected spdif_truehd, the audio output never
initialised, and playback froze at its start position while still showing
a first frame — the stop timeline reported the position it opened with.

Treat passthrough as a request and resolve the codec list against the
route when mpv actually starts, so an HDMI or AVR change between
ExoPlayer startup and the handoff cannot replay codecs from the old sink.
Gate each codec on the exact advertised encoding rather than media3's
passthrough probe: that probe answers DTS-HD by downgrading to the DTS
core, and mpv reads "dts,dts-hd" as "dts-hd" alone, so accepting the
downgrade would name DTS-HD MA to a core-only receiver and lose DTS too.
2026-07-28 19:44:16 +02:00
edde746 ae331b217c fix(android): keep the FFmpeg audio decoder through R8
Flutter enables minification for every release build, and nothing but a
keep rule reaches androidx.media3.decoder.ffmpeg. DefaultRenderersFactory
instantiates FfmpegAudioRenderer with Class.forName, media3's consumer
rules only -keepclassmembers its constructor, and this project had no
proguard-rules.pro at all, so R8 shrank the renderer out of the shipped
dex and the reflective lookup failed with ClassNotFoundException. The
same pass dropped FfmpegAudioDecoder.growOutputBuffer, which ffmpeg_jni
resolves in JNI_OnLoad and whose absence fails the whole
System.loadLibrary("ffmpegJNI") call.

Release builds therefore lost every codec that decoder adds. TrueHD and
DTS-HD fell through to MediaCodecAudioRenderer, which has no decoder for
them, so a 4K Dolby Vision file died with NO_SUITABLE_DECODER_ERROR and
handed off to the mpv fallback — losing ExoPlayer's Profile 7 to 8.1
conversion on hardware that could have direct-played it. Only debug
builds, where R8 never runs, exercised the working path.

Keep the package and the type named in the JNI callback descriptor, and
guard the invariant so it cannot silently rot again: check_shrinker_rules
fails when an app class in a reflected namespace, a FindClass target, a
native callback member, or a descriptor type has no keep covering it.
Also record the built audio renderers, because whether the extension
loaded is otherwise indistinguishable in an uploaded log.

close #1703
2026-07-28 19:44:04 +02:00
edde746 ad3af474e9 fix(android): emit playback-restart after every seek
Separate the playback-restart signal from the one-shot decoder-hang latch. A
seek flushes the codec without re-initializing it, so the claimed latch
swallowed the post-seek first frame and Watch Together guests sat in
correcting for the full settle timeout.
2026-07-27 17:44:52 +02:00
edde746 5f77da93d1 fix(android): keep fixed endpoints on system certificate authorities
Scope user-installed CA trust to the user-entered server hosts that need it
and pin the hard-coded first-party hosts to system anchors. The base config
applied user CAs to every host, including plex.tv token exchange and the
OAuth proxy.
2026-07-27 17:44:52 +02:00
edde746 fcda012468 style(android): apply ktlint's wrapping to the player sources
scripts/format_native.sh --check reported eight class-signature,
function-signature and wrapping violations. Applies --fix; no behaviour
changes.
2026-07-26 23:05:17 +02:00
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00
edde746 ecc55d9b36 fix(android): retry ExoPlayer after decoder loss
close #1540
2026-07-26 14:58:21 +02:00
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00
edde746 3b1e71b3fa feat(player): support playback speeds up to 8x
close #1545
2026-07-26 04:24:55 +02:00
edde746 e251273322 feat(downloads): warn about Android background restrictions 2026-07-26 04:24:55 +02:00
edde746 6710c87892 fix(android): accept denied audio-focus resumes 2026-07-25 17:38:09 +02:00
edde746 5c221468d6 perf(android): use Skia on 32-bit Android TVs 2026-07-25 16:45:59 +02:00
edde746 f8f366a513 fix(native): harden player teardown and shelf recovery 2026-07-25 16:16:24 +02:00
edde746 9a6f48a4cb fix(android): use Skia on 32-bit TCL TVs 2026-07-25 08:33:06 +02:00
edde746 2b3853a882 fix(player): preserve transcoded subtitles at high speed
close #1622
2026-07-25 04:21:37 +02:00
edde746 0643787fbe fix(android): prevent ghost playback after autoplay failures
close #1673
2026-07-25 04:12:54 +02:00
edde746 d6b24c3e07 fix(android): prevent Dolby Vision seek crashes 2026-07-24 19:10:38 +02:00
edde746 4cc9a35b2e fix(android): use OpenGLES on 32-bit TCL TVs 2026-07-24 12:22:58 +02:00
edde746 40833e65ec fix(ci): align toolchains and isolate platform tests 2026-07-24 10:06:50 +02:00
edde746 54a002f9b7 fix(android): consolidate native media decoders 2026-07-24 03:56:40 +02:00
edde746 fb45ff44f3 fix(android): persist startup and runtime exit diagnostics 2026-07-24 03:56:40 +02:00
edde746 9f2e050797 fix(native): bound cross-platform lifecycle ownership 2026-07-24 03:56:40 +02:00
edde746 e0bf66eea8 fix(runtime): harden application service boundaries 2026-07-24 03:46:46 +02:00