Compare commits

...
Author SHA1 Message Date
edde746 6d75e58f52 chore: bump version to 2.15.0 2026-08-18 14:50:24 +02:00
edde746 ad1a12c998 fix(release): reset fresh state and preserve deferred phases 2026-08-18 14:45:32 +02:00
edde746 9fe6dbe9f2 fix(explore): add MDBList watchlist source
Connected MDBList accounts were omitted from Explore, so their watchlists could not be selected or displayed. Register the profile-scoped MDBList client as a catalog source with watchlist mutations and search.

close #1888
2026-08-18 13:13:06 +02:00
edde746 304d8a5e33 fix(plex): keep relay out of the phase-1 race, preferred persistence, and pinned sessions
A relay connection can win the phase-1 race (plex.tv edge answers fast),
gets saved as the preferred endpoint, and the cached URL then wins the
deterministic head-start probe at every bind — pinning future sessions to
relay's 2 Mbps cap even after direct connectivity returns, surfacing as
HTTP 500s on Original quality. A session that lands on relay legitimately
(direct down at connect time) stays there all evening: re-optimization
only fired on connectivity events.

Relay is now a fallback tier in the race: all probes still start at once,
but a relay success is held until every direct candidate has failed, and a
cached relay URL gets no head start. A single persist gate refuses relay
at all four endpoint-save sites, classified against both the live server
and the connect-time capture so rotated relay URIs cannot slip through.
While the active endpoint is relay, a bounded-backoff re-probe (30s/60s/
120s) re-races the candidates so a returning direct endpoint promotes
away without a restart.
2026-08-18 09:23:04 +02:00
edde746 986237491f fix(i18n): fill missing translations and remove unused keys 2026-08-18 08:41:13 +02:00
edde746 35eef09f72 fix(tvos): present video on the host clock instead of the media timebase
Direct Play on Apple TV develops recurring micro-stutter that worsens
the longer playback runs and clears temporarily on pause/play
(issue #1776). Since 2.4 the avfoundation VO has presented frames
against a media-time CMTimebase anchored and rate-servoed to follow
mpv's audio-slaved schedule; clock skew between the two domains can
only be retired by rate slewing or a re-anchor snap that retimes every
queued frame at once. The media timebase exists for PiP, which tvOS
does not have.

Bump MPVKit to 1.0.22 and opt tvOS into its new
avfoundation-presentation=host mode: samples carry mpv's scheduled
display time against a free-running host-clock timebase, so drift is
absorbed per frame inside mpv and no timing state accumulates in the
VO. The queue lead returns to 128ms; 500ms only dampened media-mode
snaps and quadrupled their blast radius. 1.0.22 also paces the
compressed-passthrough feed off the most-advanced playhead reading,
fixing the periodic audio dropouts passthrough users reported on 2.14.
2026-08-18 08:38:46 +02:00
edde746 8461e6ba5f feat(detail): split play button with version picker segment when multiple versions exist
There was no way to tell from the detail screen that a movie or episode
has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the
only entry point was the hidden Play Version item in the overflow menu.

The Play button now becomes a Material 3 Expressive split button when
the item carries more than one version and its server is reachable: the
main segment keeps plain Play (which already resumes the remembered
version), and a narrower chevron segment runs the existing Play Version
flow (version picker, quality picker when the backend can transcode,
preference save). The flow itself is extracted from the context menu
into a shared promptAndPlayVersion helper, and FocusableActionBar gains
per-action spacingBefore so the joined pair can sit tighter than the
rest of the row. Transcode-only quality picking stays in the overflow
menu so the chevron keeps signaling a real version choice.

close #1881
2026-08-18 07:41:54 +02:00
edde746 93d3ec65e2 fix(exoplayer): decode DTS with FFmpeg when the stream cannot bitstream
DTS-HD files play silently on the Onn 4K Plus: DTS decode is license-gated
in its firmware, so c2.amlogic.audio.decoder.dtshd initialises, drains and
advances the playback position while rendering silence. Route DTS-family
decode to the bundled FFmpeg decoder whenever the track will actually be
decoded - passthrough off, downmix, normalization, a failure block, or a
route that cannot bitstream DTS in any shape. Bitstream-capable routes are
untouched: media3 selects direct output before it ever consults the decoder
list, so raw passthrough, the IEC 61937 carrier and the tunneling gate keep
their exact behavior. Kodi ships the same policy as its only configuration:
its MediaCodec audio whitelist is empty and DTS always decodes in FFmpeg.

close #1995
2026-08-18 06:28:28 +02:00
edde746 35dce90ea9 feat(player): persist player-sheet changes at a configurable scope
Changing playback speed, shader preset, aspect ratio, or a sync offset in
the player always rewrote the global default, so a show watched at 1.5x
forced 1.5x onto movies, an Anime4K pick followed the user into
live-action libraries, and a file-specific subtitle offset leaked onto the
next title.

Each of those settings now has a scope under Settings > Video Playback >
Remember Player Changes: don't save, everywhere (default, pre-existing
behavior), per library, or per show/movie. Player writes route through the
configured scope; playback start and in-place item changes resolve the
value for the current item, falling back to the global default. Items
without the needed identity (live TV placeholders, Jellyfin/Emby downloads
without a stamped library) keep the global behavior. Resolution consults
only the configured scope, so entries saved under a previous scope become
inert instead of shadowing the new one.

close #1984
close #1322
close #1616
2026-08-18 06:09:11 +02:00
edde746 483691d43a feat(player): surface skip forward/back on the iOS and tvOS lock screen and remote
On iOS and Apple TV the lock screen, Control Center, and iPhone remote card only offered previous/next track for video, so the transport buttons restarted the episode or jumped to the next one instead of skipping a few seconds.

MediaControlsManager suppressed the MPRemoteCommandCenter skip commands on all Darwin platforms because they displace the next/previous buttons — the right call for music, the wrong one for video. The suppression is now a per-surface policy: the video player opts in with preferSkipOverTrackButtons and advertises the in-player small-skip step (seekTimeSmall, default 10s) via setSkipIntervals, while music keeps next/previous as its lock-screen transport. Skip events already routed through MediaControlRouter to _seekRelative on every platform, so the OS-echoed interval drives the actual seek.

close #1994
2026-08-18 04:06:11 +02:00
edde746 b4a84c6a28 fix(profiles): don't re-prompt for profile when a system overlay closes
On Fire TV, invoking Alexa and dismissing its overlay popped the profile
picker over a still-playing session: MainScreen treated every resumed
lifecycle event as an app open and re-applied "ask for profile on open".
The Alexa overlay only produces inactive -> resumed, which the handler
could not tell apart from a real return to the foreground.

Track the deepest lifecycle state since the last resume in a small gate
(ProfileSelectionResumeGate): only a genuine backgrounding (hidden,
paused, or detached) arms the prompt for the next resume. This also
keeps iOS working, where returning from the background arrives as
hidden -> inactive -> resumed.

close #1990
2026-08-18 02:09:28 +02:00
edde746 c5e7ce648b fix(player): gate audio passthrough on the IEC 61937 shapes each backend opens
On Shield Experience 8.x (API 28) ExoPlayer force-decoded TrueHD to
multi-channel PCM, and enabling passthrough on the mpv backend made every
non-AAC file wedge on an infinite buffering spinner with no audio pipeline
at all.

Two defects behind one symptom pair:

- The MAT/DTS-HD carrier oracle stopped at API 29 because
  AudioTrack.isDirectPlaybackSupported does not exist below it, so API 24-28
  never offered the carrier even on routes that genuinely bitstream it. Below
  API 29 the HDMI AudioDeviceInfo is the only vouching signal, so the carrier
  is now offered when an HDMI output explicitly advertises IEC 61937 at
  192kHz/8ch; unspecified (empty) capability arrays deliberately do not
  count, because an unvouched IEC track that initialises without being
  bitstreamed renders as full-scale noise. A route that advertises and still
  refuses the track fails AudioTrack init into the existing force-decode
  recovery.

- The mpv audio-spdif list was gated on the raw encodings the route
  advertises, but mpv's audiotrack AO opens every spdif format as a stereo
  ENCODING_IEC61937 track clamped to the 48kHz mixer rate. E-AC3, TrueHD and
  DTS-HD MA structurally cannot survive that shape, and a route can advertise
  raw encodings while its HAL takes no IEC track at all - naming any codec
  there leaves mpv's audio chain stuck before AO init (reproduced on a Shield:
  spdif_ac3 selected, no AudioTrack ever opened, playback never starts). The
  list is now capped to ac3,dts and additionally gated on the route accepting
  the stereo IEC shape, using the same tiered oracle as the carrier. The
  primary mpv backend previously wrote a hardcoded five-codec list from Dart;
  it now asks the plugin to derive the value from the audio route, like the
  ExoPlayer fallback already did.

Verified on a Shield (API 30) with a full-capability EDID: TrueHD rides the
carrier and survives speed transitions (instrumentation), and an E-AC3 title
that previously wedged under mpv+passthrough now decodes and plays with
audio-spdif=ac3,dts.

close #1991
2026-08-17 23:34:05 +02:00
edde746 7cfd68c491 test(player): match the renamed carrier diagnostic in the mismatch regression
The rate-mismatch regression greps the audio diagnostics for the literal
'MAT/IEC 61937 carrier', but 658469f9 reworded the sink's engage message to
'TrueHD (MAT) via IEC 61937 carrier' when DTS-HD joined the carrier. The test
self-skips on carrier-less CI emulators, so the rot only surfaces on real
hardware, where the fallback behaves correctly and the assertion still fails.
Match the shared 'via IEC 61937 carrier' fragment both codecs emit.
2026-08-17 23:33:35 +02:00
edde746 137f2a0e95 merge: music-library polish and Live TV guide day navigation
Brings in the feedback-driven fixes: user-assigned Plex playlist
posters, square music-grid gutters, grouped artist discography
(albums/singles & EPs/live/compilations for Plex), and the guide
day-jump rework (mounted reloads, immediate day apply, focus
retention).
2026-08-17 23:03:24 +02:00
edde746 1bdeca3b5f fix(libraries): give square music grids an inter-card gutter
Square-card (music) grids had zero delegate spacing - the only
separation was each card's internal 3px padding, and square shapes are
excluded from the full-card spacing path, so playlist and album tiles
sat nearly edge to edge. Square grids now get an 8px cross/main gutter
(automotive keeps its larger spacing); poster, list, and full-card
layouts are unchanged.
2026-08-17 22:44:12 +02:00
edde746 cff6666219 feat(music): group artist discography into albums, singles & EPs, live, and compilations
Artist pages showed one flat album grid, unlike Plex's own clients.
Plex listing rows never carry Format/Subformat tags (even with
resolveTags=1), so fetchArtistDiscography follows the unchanged album
listing with one batched /library/metadata/{ids} request - whose rows
do include the tags - and classifies each album individually
(EP/Single, then live, then compilation, case-insensitively). A failed
tag fetch degrades to the flat list, and single-album artists skip the
lookup entirely. Jellyfin/Emby carry no album taxonomy on the wire and
return a single albums group.

The artist screen renders one titled section per non-empty group
(single-group artists keep the flat grid), with the grid focus index
space offset per section so D-pad traversal crosses section
boundaries.
2026-08-17 22:44:12 +02:00
edde746 b0798cde9f fix(plex): backfill show logos on continue-watching rows missing them
PMS versions before the ~1.43 hub refresh omit a show's inherited clearLogo
image from episode/season hub rows, so the Discover hero fell back to the
title for TV series while movies (which carry their own logo) and the show's
detail page (which reads the show metadata directly) kept theirs.

Resolve the missing owners' logos in one bulk /library/metadata request
(comma-joined rating keys) and stamp them onto the rows, mirroring the
grandparent lookup Plex Web performs. Best-effort: a failed lookup never
fails the shelf and is retried on the next refresh.
2026-08-17 21:46:12 +02:00
edde746 417f3a5e36 fix(livetv): keep the guide mounted during reloads and apply day picks immediately
The guide replaced itself with a bare spinner on every window change,
unmounting the day-picker anchor and the guide focus node - after one
day or time change the pickers silently no-oped and the remote went
dead. Picking a day also applied nothing by itself: the window only
moved after the second (time-slot) menu, so backing out of it read as
'the day picker does not work'.

The grid now stays mounted during in-session reloads behind a light
loading overlay, a day pick re-anchors the window immediately (the slot
menu is an optional refinement), D-pad SELECT arms the key-up
suppressor before opening the menu, and the day chip uses the existing
'Tomorrow' translation.
2026-08-17 21:36:35 +02:00
edde746 d4d44e6c72 fix(music): show user-assigned Plex playlist poster instead of the auto composite
Plex emits an auto-generated composite mosaic for every playlist, so the
displayImagePath fallback order (composite first) meant a poster the user
assigned in Plex could never appear. Prefer thumbPath and fall back to the
composite; no-op for Jellyfin/Emby, which never set a composite.
2026-08-17 20:59:13 +02:00
edde746 658469f954 feat(player): bitstream DTS-HD MA through the IEC 61937 carrier on Android
Fire TV Stick 4K Max (and other Fire OS devices) advertise ENCODING_DTS_HD on
the HDMI route but only implement the DTS-HD basic profile, so media3's raw
path builds an AudioTrack that drains normally while the receiver hears
silence. The same routes do bitstream the 192kHz/7.1 ENCODING_IEC61937 carrier
TrueHD already rides, so DTS-HD MA access units are now packed into DTS type IV
bursts (a port of FFmpeg's spdif_header_dts4 at the 768kHz HD rate, the same
bytes Kodi's IEC packer produces) and played through that carrier.

The TrueHD MAT carrier sink is generalized into IecCarrierSink with one
IecCarrierPacker per codec. While the carrier route exists DTS-HD is binary -
the carrier or decoded PCM, never the lying raw path; routes without the
carrier keep the pre-carrier raw behavior. Master Audio peaks the carrier
cannot hold strip to the always-fitting core substream for ~60s, exactly as
FFmpeg does, and the packer output is pinned byte-for-byte against FFmpeg
spdif golden fixtures.

close #1988
2026-08-17 20:58:04 +02:00
edde746 efd33b3a4e fix(database): restore api_cache.cached_at read by the playback freshness gate
Main stopped compiling: the v21 column drop treated cached_at as
never-read, but the fresh-cache-first playback metadata gate
(ApiCacheSingleton.getIfFresh, from the playback start-latency fix)
reads it on every Plex and Jellyfin playback start, and a later
cleanup removed the put() stamp the gate depends on.

Reinstate the column, keep the v21 migration to the connections
half only (it never shipped in a release), restore the explicit
put() timestamp - on conflict the upsert only updates the
companion's columns, so without it a refreshed row would keep its
original write time and read as permanently stale - and copy
cached_at through the pinned-metadata rescope statement again. The
v21 migration test now pins that api_cache is left untouched.
2026-08-17 20:47:40 +02:00
edde746 86498547ba fix(plex): burn Live TV DVB subtitles on explicit selection
Plex Live TV lost DVB subtitles when 2.10 moved the live path to HLS
(#1983): the transcode starts with subtitles=none, which drops a
tuner's bitmap subtitle streams from the output entirely - unlike
in-band CEA captions, they are separate elementary streams.

The tune response now surfaces the part's embedded bitmap subtitle
streams as selectable tracks in the player's subtitle menu. Picking one
points the part's server-side selection at that stream and rebuilds the
live transcode with subtitles=burn, preserving the time-shift position.
Off stays the default so tuner captions are never auto-burned (#1590),
and channels without bitmap streams keep the native track list. The
selection survives stream recovery by re-mapping onto the re-tuned
session's streams and resets on channel zap. Jellyfin live sessions
record subtitle selection as intentionally unsupported: their one
negotiated URL has no rebuild to deliver it through.
2026-08-17 20:20:20 +02:00
edde746 5fe1e6dc65 refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
2026-08-17 19:02:25 +02:00
edde746 1df47ed8e1 refactor(watch-together): remove the unconstructable disconnected state and write-only session surface
Participant.lastKnownPosition had zero reads or writes; SessionState.disconnected was never constructed, making isInSession's comparison tautological (now _session != null); the session indicator's onLeaveSession callback had no production passer; enterRoom's bool was awaited and discarded. Music engine: MusicPlayContext.id and sleepTimerEndsAt were write-only, and the coordinator constructor seam was never injected.
2026-08-17 19:02:25 +02:00
edde746 a7527718df refactor(services): delete dead branches, write-only fields, and test-only cache/service surface
plex_client.selectStreams' allParts=false branch, download_manager's both-branches-true conditional and unscoped getAllPinnedMetadata tail, playback_source_resolver.preferOffline, and update_service's unreachable catches had no reachable path; PlaybackSession.result/streamHeaders, PlaybackContext.clientScopeId, FileInfoStreams.audioStream, PrefsRepairOutcome.settingsReset/sessionsSalvaged, and JellyfinLiveSessionTracker.playSessionId were write-only; PlexConnection.directUrl, PlexServer.isOnline plus its parse-and-persist presence field, SafStorageService.createDirectory, FullscreenStateManager.stopMonitoring/dispose, the play_queue_launcher re-export shim, track-selection's constant params, and PlexApiCache.unpinForOffline/isPinnedRatingKey (matching the earlier Jellyfin removal) were dead API.
2026-08-17 19:02:25 +02:00
edde746 7cd5bb64bb refactor(player): remove dead completion-latch channels, unreachable settings states, and unread DV stats
classifyPosition's return enum was discarded by its only production caller (rearm is the side effect that matters); the latch's tolerance/retry knobs were never tuned; _loadAdjacentEpisodes' result was unread by all three callers; countsAsApplied carried a subsumed disjunct and _syncMediaControlsAvailability a stale-generation guard with no suspension point before it; _SettingsView.audioSync/subtitleSync could never be current (both routes early-return into the sync bar); PerformanceStats.dvRpuOutputTooSmall/dvPlaybackReason and ContentStrip.chaptersLoaded were written but never read.
2026-08-17 19:02:24 +02:00
edde746 b38df008ec refactor(screens): delete never-passed knobs, dead enum values, and the unconsumed context-menu onTap
plex_pin_auth_flow's QR knobs, ProfileNameField's navigation params, MusicDetailHeader.wideArtworkSize, and _buildReorderableList's bool were never passed; DownloadType.manage was never constructed; MediaContextMenu.onTap was declared but never invoked — removed with its four card passes (each card's own gesture handlers stay); the pin dialog's obscure chain was always true and _PinKey.label's icon branches unreachable; libraries_screen._isInitialLoad was write-only; LiveTvActionsMixin loses dead findChannel (the generic parameter stays — required by the on-clause, the report was wrong); add_jellyfin's focus-map removal branch ran only against an empty map; the recommended tab's hub-key null fallback was dead behind _ensureHubKeys. media_card also loses its always-false seasonal-rank comparison from the CatalogRankScope removal.
2026-08-17 19:02:24 +02:00
edde746 ceab5a5b59 refactor(providers): remove the unreachable init fallback, unread load results, and dead refresh surface
hidden_libraries' constructor always seeds _initFuture, so ensureInitialized's ?? _initialize() fallback could never run (field now late final); _loadLibrariesInternal returned a bool no caller read; OfflineModeProvider.refresh() had zero call sites; ExploreScreen's Refreshable application was dispatched by nobody (only the discover tab and library tabs are dispatched); the discover null-serverId branch stays — the TV spotlight resolver passes null items during initial load.
2026-08-17 19:02:24 +02:00
edde746 6eb0805f8d refactor(profiles): delete dead client accessors, the write-only focus aspect, and test-only registry API
getServerBoundPlexClient/getClientForLibrary existed only for their own unit tests; MetadataEditAdapter.backend and Plex's unreachable prefKey fallback and music-kind branches had no production path (edit is double-gated on supportsKind); MainScreenFocusScope's focus aspect, isSidebarFocused, and focusContent were write-only; resolveActivePlexIdentity.preferredAccount was never passed; ProfileRegistry.get was unused outside tests — its removal exposed a stale test contract, now pinned correctly: list() deliberately never serves plex_home rows.
2026-08-17 19:02:24 +02:00
edde746 a33c423985 refactor(models): stop parsing wire fields and enum members nothing consumes
Parse-only DTO surface deleted across trackers and catalog models: quality-preset storageKey/fromStorage round-trips (persistence uses EnumPref on .name), PlexHome's placeholder identity fields (id stays — deriveHomeSecret reads it), SeerrUser.email/avatar, SimklSearchResult.endpointType, SimklDetail.type, SimklAllItemsEntry.status/isShow, TraktCatalogEntry.rank, CatalogItem.relevance, and the never-produced CatalogRankScope.favorited/.seasonal members with their label arms. Tracker root: OAuthProxyStart.expiresIn, TrackerSession.scope/isExpired, copyWith slimmed to its one real parameter, TrackerHttpClient.service. TrackerAccountStore.service and the trackerAccountStore() alias are live (persisted-blob validation, callers) and stay.
2026-08-17 19:02:24 +02:00
edde746 1a26b0dd08 refactor(media): delete write-only item fields, dead copyWith/getters, and the unread live-TV activity wrapper
PlexMediaItem.subtitleMode/extraType were mapped from DTOs and never read (subtype stays — the detail screen's trailer picker destructures it); MediaPlaylist.copyWith, PagedMediaListState.mapItems, isMusicContent/isCollection, and MediaHub.copyWith's eight never-passed params had zero call sites; LiveTvActivityResult wrapped reloadGuide results nobody unwrapped (now Future<void>); PlayerLogLevel.none was unreachable — nothing parses or produces it; MediaSourceInfo.getPartId() was an accessor over its own public field.
2026-08-17 19:02:23 +02:00
edde746 4cc065b73a refactor(focus): delete never-selected auto-open behaviors and never-passed focus params
TvTextInputAutoOpenBehavior.onFocus/.onFirstFocus were handled by both TV-keyboard state machines but selected by no caller; FocusableWrapper.longPressDuration duplicated the controller's own 500ms default; FocusableActionBar.mainAxisSize was never passed; FocusableActionBuildState carried focusNode/isFocused/isKeyboardMode that no builder reads (AGENTS.md updated to document the surviving showFocus/animationDuration pair). includeFocusSemantics stays — focusable_media_card passes it.
2026-08-17 19:02:23 +02:00
edde746 071ae8e1c2 refactor(database): drop the never-read connections.isDefault and api_cache.cached_at columns
connections.isDefault was maintained by three write paths (upsert preservation, remove-promotion, setDefault) that no production code ever read back; api_cache.cached_at was documented 'optional future use' and written on every cache store. Both columns are dropped in a v21 migration with row-preservation coverage; the dead write machinery, recordAuthSuccess, updateSyncRuleLastExecuted, getDownloadOwnerCount, and OfflineActionType.fromId (rejection semantics live in the sync service's explicit switch) go with them.

Recovery-image compatibility: committed tvOS snapshots written by older builds still carry isDefault in connections rows, and the restore decoder deliberately rejects rows that do not round-trip exactly. A retired-columns allowlist now strips such keys before the strict check, so pre-retirement images stay restorable — the legacy-plaintext restore test caught this and now pins it. _bindServerStatusListener in main.dart also loses its unused provider param and resolver indirection.
2026-08-17 19:02:23 +02:00
edde746 7658587584 refactor(player): one subscription list, bool play-pause focus request, one desktop click contract, one sync-offset layout
The screen's player stream subscriptions were enumerated by hand in four places (re-wire, rollback, dispose, plus a parallel nulling list) kept in sync only by prose — _cancelPlayerStreamSubscriptions is now the single cancel-and-null authority, living beside the fields. PlayerChromeFocusTarget was a one-value enum with request/consume plumbing, now a bool with identical post-frame consumption timing. The desktop tap and controls-overlay tap duplicated the click-toggles-playback/double-click-fullscreen contract around shared state, now one helper. SyncOffsetControl shipped two layouts of which production only ever rendered compact — the full layout existed solely for tests, which now cover the shipped layout. TrackControlsState's never-non-null onLoadSeekTimes/onSyncOffsetChanged sheet-compat callbacks are gone.
2026-08-17 19:02:23 +02:00
edde746 49fce5ace9 fix(player): keep right-arrow alive on the last control when rotation and screen lock are both visible
_getButtonCount re-implemented every button-visibility condition from build() and had already drifted: the screen-lock button incremented the built index but was never counted, so with rotation lock and screen lock both visible the reported total was one short and arrowRight on the last button silently died. Navigation now bounds against the actual built list, so there is no condition table left to drift; the dead onLoadSeekTimes sheet-compat callback invocation goes with it.
2026-08-17 19:02:22 +02:00
edde746 057ff98ec1 refactor(ui): delete never-passed widget parameters and unreachable surfaces
Grep-verified dead surface across shared widgets: the app-menu system's minWidth/maxWidth/childPadding/alignmentOffset knobs threaded through four layers with no caller ever passing them; FocusableListTile's suppressInitialSelect/hoverColor/textColor/iconColor machinery (~55 lines) reachable by no call site; OptimizedMediaImage.playlist with zero constructions; TvSpotlightBackground's primary-action button that neither caller can render; MediaGridDelegate.createDelegate whose only caller was a test (MediaGridGeometry.resolve is the single delegate composition now); and the CustomAppBar->Config->DesktopTopBar chain collapsed onto DesktopSliverAppBar with its ~11 never-passed passthrough params (snap kept — real callers pass it).
2026-08-17 19:02:22 +02:00
edde746 00a6e056b0 refactor(plex): drop the test-only raw-JSON mapper flavours and title obfuscation branches
PlexMappers advertised a parse-and-map FromJson flavour of every mapper 'for callers that haven't already parsed a DTO' — no such production caller exists; every call site uses the DTO-typed flavour. Tests now parse the DTO and map it, preserving every assertion. The kBlurArtwork JSON-walking branches in the DTO factories (title/summary vowel rotation on the decode hot path) are deleted with the flag's title half.
2026-08-17 19:02:22 +02:00
edde746 78fa2c29e3 refactor(input): compile the disabled diagnostics and screenshot-blur subsystems out of normal builds
TextInputDiagnostics.enabled and kBlurArtwork were mutable/const-false flags nothing ever set, yet the diagnostics side eagerly built log strings on the gamepad and key-simulator hot paths on every event. Both flags are now const bool.fromEnvironment (PLEZY_TEXT_INPUT_DIAGNOSTICS / PLEZY_BLUR_ARTWORK) so the branches const-fold away, every eager interpolation sits behind a guard, and screenshot blurring stays one dart-define away. The vowel-rotation title obfuscation had no consumer need beyond artwork blur and is gone.
2026-08-17 19:02:22 +02:00
edde746 0e1af9faaf refactor(watch-together): flat engine callbacks instead of bundle classes
HostCoordinatorCallbacks and GuestReconcilerCallbacks existed only to be unpacked by the controller through ten forwarding lambdas into its own flat fields. The engines now take individual nullable callbacks; the controller keeps its late-binding lambdas (the provider assigns the public fields after construction), and the test harnesses forward the callbacks they exercise.
2026-08-17 19:02:22 +02:00
edde746 99b0528623 refactor(settings): drop the orphaned mute-toggle helper
SettingsService.resolveMuteToggle had zero production callers — the live mute policy moved to VideoVolumeController.toggleMute long ago, leaving two diverged implementations of the same rule. The two TV-aware pref defaults in the same file now read through the PlatformDetector facade like the rest of the app.
2026-08-17 19:02:22 +02:00
edde746 f5c7b95b02 refactor(tv): read TV detection through the PlatformDetector facade everywhere
TV state had two interchangeable entry points — PlatformDetector.isTV/isAppleTV and the raw TvDetectionService.*Sync accessors — with call sites split arbitrarily between them. App code now goes through the facade; the raw accessors document that they exist for the facade and tests.
2026-08-17 19:02:21 +02:00
edde746 d78a7d925e refactor(trackers): single-client Trakt refresh, distinct Simkl coalescer names
TraktClient's static per-refresh-token coalescer, initiating-vs-joiner branches, session-adoption identity checks, and both updateSession push methods defended a multi-client world production never creates — the one long-lived client is shared via TrackersProvider, and the two throwaway clients never refresh. Refresh now matches MalClient/MdblistClient's instance-coalescer shape. Simkl's _watchlistLoad field shadowed the identically-named coalescer on the mixin it applies (legal today, a trap on any future library merge) — renamed with comments distinguishing the two caches.
2026-08-17 19:02:21 +02:00
edde746 62c25e493e refactor(settings): drop the dormant manager-registration branch and the duplicate Plex add-account error surface
persistAndBindConnection's addToManager/visibleServerId parameters and post-commit manager phase had no production caller (the Jellyfin flow defers runtime pickup to the profile binder) — the function is void now. A failed Plex account add threw a StateError that PlexPinAuthFlow re-rendered as a raw 'Bad state:' string beside the already-rendered inline error; the flow's normal return already stops the spinner, so the inline error is the single surface.
2026-08-17 19:02:21 +02:00
edde746 e543f417ce refactor(profiles): one initial-focus mechanism and one end-session/recover scaffold
The profile picker requested first-tile focus twice (FocusableWrapper.autofocus plus a one-shot post-frame requestFocus for the same node) — autofocus alone remains, verified by the picker's D-pad tests. The end-session/try/catch-resume skeleton was copied across deleteProfile, the Plex sign-out, and connection removal; withEndedProfileSession now owns the pause/recover scaffolding while each flow keeps its own success-path resume decision and error policy.
2026-08-17 19:02:21 +02:00
edde746 4c2f533eef refactor(music): share the playlist play/download/delete flows between the context menu and the detail screens
The audio-playlist play flow, the playlist download flow (including its inline MediaItem synthesis), the delete-with-confirm flow, and the fetch-artist-then-navigate flow each existed twice — once in MediaContextMenu and once in the playlist/album/now-playing screens, with 'Match PlaylistDetailScreen' comments standing in for shared code. They now live in music_navigation.dart and download_utils.dart with per-caller strings preserved; the menu's collection/playlist download handlers fold into one parameterized handler, its duplicated sync/download menu tree is built once, and the generic picker dialogs move to collection_picker_dialog.dart (pure move).
2026-08-17 19:02:21 +02:00
edde746 fb048097ab refactor(livetv): one refresh-gating mixin, one multi-server iteration helper, simpler recordings coalescer
The pause/resume/ticker state machine (three gates, lifecycle observer, TickerMode subscription, timer sync) was triplicated across the What's On, Recordings, and Guide tabs — now LiveTvRefreshMixin, with the guide's drift catch-up layered on its hooks and the lifecycle test asserting the same contract against the shared implementation. Six hand-rolled multi-server loops share forEachLiveTvServer (the channel/favorites loaders deliberately iterate per DVR entry and say so). Recordings' Completer-based load drain became the same whenComplete coalescing shape the screen's channel loader already uses.
2026-08-17 19:02:20 +02:00
edde746 4eb3ab4632 refactor(libraries): inline the one-caller filter/sort loader, drop the dead ItemUpdatable hookup, one grouping sheet
LibraryFilterSortLoader had one call site that constructed it with a constant client resolver and bypassed it for MediaBrowser anyway; the Plex branch now loads both futures inline with types instead of casts. LibrariesScreen's ItemUpdatable application ended in an empty no-op that would have burnt a network fetch had anything ever called it. The chips-bar grouping sheet re-built the browse-options grouping page's chrome by hand; both paths now render the same BottomSheetPageScaffold page.
2026-08-17 19:02:20 +02:00
edde746 a910aa95cb refactor(tvos): publish menu passthrough directly, trim untuned remote-touch knobs
TvosMenuPolicyPublisher batched an idempotent fire-and-forget publish behind a transaction-depth counter that could never exceed one across its three non-nesting call sites — the call sites now publish directly and the pure predicate keeps its test coverage. AppleTvRemoteTouchService exposed 14 injectable knobs of which only six are exercised anywhere (verified per knob); the other eight are private constants or internal construction now.
2026-08-17 19:02:20 +02:00
edde746 3703ac47c3 refactor(explore): share the catalog enum-to-label mappings
The catalog detail screen and the card badges each kept their own enum-to-i18n switches; the genuinely shared mappings (season name, rank scope, request state, status/format/relation labels) now live in catalog_labels.dart, while badge precedence and the deliberately different availability/4k wording stay local with comments naming the divergence.
2026-08-17 19:02:20 +02:00
edde746 c390e0fb0c refactor(detail): share the season-download filter and the download-retry pipeline
The downloaded-episodes-for-a-season filter+sort was written out three times in media_detail_screen (with drifting show-id expressions, preserved per call site), and the failed/cancelled download branches in the action buttons duplicated the same six-step retry pipeline verbatim. One helper each.
2026-08-17 19:02:20 +02:00
edde746 3665d416c4 refactor(providers): deduped peer disposal, hoisted discover outcome tail, live server-connection getter
CompanionRemoteProvider memoized peer disposals in an Expando even though CompanionRemotePeerService.dispose() already dedups concurrent and repeat calls — the wrapper now only keeps cleanup failures from escaping teardown, and the test fake mirrors the service's idempotent-dispose contract. DiscoverProvider._loadOnce carried the same outcome/generation/boundary tail copy-pasted in three branches, now one local settlePassOutcome. OfflineModeProvider cached _hasServerConnection in five write sites when it always equalled a two-term expression — now a live getter.
2026-08-17 19:02:20 +02:00
edde746 2e1a7130fc refactor(downloads): one auto-remove-watched core, trimmed offline-watch provider, shared focus-restore mixin
The auto-remove-watched-downloads rule existed twice (DownloadProvider's sweep and OfflineWatchProvider's single-item copy) — the kind/completed/delete/title core now lives once on DownloadProvider, with the watched judgment staying per caller because the offline path fires before metadata can reflect a local mark. OfflineWatchProvider drops five members with no production callers (isSyncing, getPendingSyncCount, isWatched, getViewOffset, getEpisodesWithWatchStatus) plus the sync-service listener that only served them. The downloads screen's three verbatim 'suppressAutoFocus flipped — focus the first item' didUpdateWidget blocks are now one mixin.
2026-08-17 19:02:20 +02:00
edde746 55382deaf9 refactor(android): drop the dead mpv-command emulation on the ExoPlayer backend
PlayerAndroid.command() emulated four mpv commands ('loadfile', 'seek', 'stop', 'sub-add') that no caller ever sends through the interface, while the commands actually dispatched ('change-list', 'drop-buffers', 'sub-seek', 'screenshot') fell into the default branch and vanished. The override is now a documented no-op, which is what every dispatched command already observed.
2026-08-17 19:02:19 +02:00
edde746 4436efea72 refactor(models): drop parse-only wire fields nothing reads
Four model surfaces parsed, coerced, and re-serialized data with zero readers: PlayQueueResponse's selectedItemIndex/offset/sourceURI/version (version stays as a parse-time validity gate), PlexUserProfile's seven non-track-selection account fields, MediaSubscriptionCreateRequest's hints/params/providers, and the whole CatalogPlayState pipeline (parsed by the Plex catalog source, merged and round-tripped on every CatalogItem, read by nobody).
2026-08-17 19:02:19 +02:00
edde746 8e9c81fe47 refactor(media): plain LocalPlayQueue, trimmed MediaItem factory, one library-content entry point
PlayQueue was a freezed union whose Plex variant was never constructed (server queues flow through PlayQueueResponse), whose pattern getters had zero call sites, and whose backendId was written but never read — it is now a plain four-field LocalPlayQueue. The MediaItem compatibility factory re-listed the entire ~65-field union surface twice while every caller passes at most 22 fields; it now declares exactly that union. fetchLibraryContent had zero production callers (the UI pages through fetchLibraryPagedContent), so the interface method and both implementations are gone, with Jellyfin's drain folded into its paged entry point.
2026-08-17 19:02:19 +02:00
edde746 093c0596f1 refactor(jellyfin): declare shared client internals once, make live-TV negotiation private
Two part mixins re-declared members owned by siblings (five playback methods with full 17-parameter signatures, _safeFetchItemsArray with four lint suppressions), violating jellyfin_client.dart's own 'declared exactly once' contract — they now live on _JellyfinClientInternals, with _safeFetchItemsArray declared as its positional core so no suppressions survive. LiveTvSupport.resolveStreamUrl's only caller anywhere was Jellyfin's own startPlayback, so it is now a private negotiation step and Plex drops its permanent null stub; the negotiation tests observe the same contract through startPlayback and the heartbeat wire. JellyfinApiCache's unpinForOffline/isPinnedItemId (zero callers, kept only for Plex symmetry) are gone, as are MediaSubscriptionCreateRequest's never-populated hints/params/providers expansions in the Plex live-TV serializer.
2026-08-17 19:02:19 +02:00
edde746 908b4a8dac refactor(database): serialize durable mutations through one queue, keep only guarded watch-action mutators
Every identity/pending mutation nested a per-instance SerialFutureQueue inside the static tvOS-recovery queue, so the per-instance layer added no serialization domain (reentrancy is already prevented by the durability Zone check). The unguarded updateSyncAttempt/deleteWatchAction pair had zero production callers — the sync service deliberately uses the revision-guarded variants.

Tests migrated to the IfUnchanged pair by passing the row's current revision.
2026-08-17 19:02:18 +02:00
edde746 8033a308ce refactor(connection): drop dead status/kind enums, share the Plex account build pipeline
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers.

Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone.
2026-08-17 19:02:18 +02:00
edde746 68c328b469 feat(search): label results with their source server and library
One server with overlapping libraries (movies in both an HD and a 4K
library, shows duplicated per language) returns search results that
cannot be told apart, forcing users to open each copy to find the
right one (#1970).

Each search row now carries a source line under the summary: backend
icon, server name, and library name, shown whenever the owning server
has more than one library. Plex rows name their section inline, with
sectionKey-only rows back-filled from the loaded libraries; Jellyfin
and Emby send no library field on search hits, so search always runs
the per-library scoped fan-out that previously only served
hidden-library exclusion and stamps every hit with its library. The
Jellyfin detail lookup adds a best-effort /Items/{id}/Ancestors stamp
so full metadata stays attributed, row refreshes merge instead of
dropping the stamp, and the mapper no longer misreads SeriesStudio or
ParentId as library identity.

Episode rows trade one summary line for the source line so the
wide-thumb row keeps its height, and the badge paints slightly below
the line-box center to sit on the text's optical middle.

close #1970
2026-08-17 17:13:26 +02:00
edde746 01f04eda42 fix(ui): show loading feedback while play actions fetch
Several Play entry points gave no feedback while their network round
trips ran: a show or season Play button silently awaited a seasons
fetch (with a 10 s completer window) and a first-episode fetch, and
Plex collection/playlist launches showed no spinner at all. Where a
spinner did exist, the first request was gated on the dialog's first
frame instead of running concurrently with it.

Extract executeWithLoading's dialog plumbing into
ScopedLoadingDialogController (mount-aware dismissal, idempotent,
never pops a foreign route) and reuse it for the show/season Play
path. Start the launcher operation before awaiting the dialog frame so
the first round trip overlaps the render, and show the loading
indicator for Plex list launches too.
2026-08-17 13:47:00 +02:00
edde746 0f016e4e13 fix(player): start the playback fetch before player construction
The playback data resolve — the one network request that must land
before open() — was kicked off only after the SharedPreferences load,
the Windows display-mode sync, the music-session teardown in
claimVideo(), Player construction, and (Android/Exo) a native
setLogLevel, so none of that setup overlapped the round trip.

Move the kickoff to immediately after the settings reads; the resolve
depends only on settings and provider lookups, so display sync, player
construction, and the whole mpv property chain now hide behind the
network latency instead of preceding it.

Also remove redundant work elsewhere on the start path: memoize
PlayerAndroid.getHeapSize (asked again on every open for an immutable
device value), skip the ten subtitle-style settings reads on mpv
backends where setSubtitleStyle is a no-op, and stop navigateToVideoPlayer
awaiting SettingsService.getInstance twice per launch — the external-player
read now sits behind the supportsExternalPlayers guard.
2026-08-17 13:46:51 +02:00
edde746 953acedcab fix(player): pipeline mpv http-header commands at open
Every mpv open rebuilt the HTTP header list with one awaited channel
round trip per change-list command — with Plex's 9-13 identity headers
that is ~12 serialized round trips sitting between the playback resolve
landing and loadfile, on every mpv backend.

Dispatch the clr and append commands without awaiting between sends and
await them together: the method channel delivers messages in send order
and the native side executes them in arrival order, so the
clr-before-append contract holds while the latency collapses to one
round trip. Failures still propagate out of open() unswallowed.
2026-08-17 13:46:41 +02:00
edde746 be9197eda6 fix(playback): serve playback metadata from fresh cache instead of refetching
Videos take noticeably long to start. On Plex, tapping Play refetched
/library/metadata/{id} network-first even though the detail screen wrote
a strict superset of that exact payload under the same cache key seconds
earlier. On Jellyfin/Emby, playback start issued a full-detail item GET
before the PlaybackInfo negotiation, and the video controls issued the
same heavy GET again while the stream was opening (#1784) — the most
expensive queries a small home server serves, paid two to three times
per start.

Add ApiCache.getIfFresh over the existing cachedAt column and serve
rows younger than playbackMetadataCacheFreshness (5 min) without a
round trip. Plex guards with the strict stream-detail check
(_plexMetadataHasStreamDetail) so a thin row written by
getPlaybackExtras' lean fetch still goes to the network; Jellyfin's
row has a single full-shape writer, so fetchPlaybackBundle and
fetchPlaybackExtras share the new fetchItemFreshCacheFirst. Any miss,
stale, thin, or malformed row falls through to the unchanged
network-first path, and offline behavior is untouched.
2026-08-17 13:46:33 +02:00
edde746 a2b3377d91 fix(libraries): stop d-pad up snapping collection and playlist grids to top
On TV, pressing UP in the library Collections or Playlists grid snapped
the list back to the top and dropped focus on the tab chips, making long
lists impossible to navigate. Default directional focus traversal scrolls
the found card into view via Scrollable.ensureVisible, whose
outer-scrollable pass routes through the NestedScrollView coordinator and
resets the inner grid position to zero on every UP press.

Give the shared paginated card grid explicit per-card d-pad navigation,
matching the browse tab: managed per-index focus nodes, row/column moves
that request focus directly, first-row UP to the tab bar, and
first-column LEFT to the sidebar. Focus changes now scroll only through
FocusableWrapper's delta-based auto-scroll.

close #1977
2026-08-17 08:58:00 +02:00
edde746 e1b488f860 fix(lifecycle): force-close IOClients stuck in unabortable TCP connects
tvOS logs flood with "HTTP client drain timed out" / "close deferred"
warning pairs after every Plex endpoint race, and each sweep leaks the
losing probes' sockets for the OS connect timeout (~75 s of SYN
retries on Darwin).

A request stuck in TCP connect cannot be aborted: package:http's
IOClient only registers the abort handler once openUrl completes, so
the graceful drain never finishes and ManagedHttpClient defers the
inner close that would have reclaimed the socket.

Fix at both layers: every IOClient now gets an explicit
HttpClient.connectionTimeout matching MediaServerTimeouts.connect, and
ManagedHttpClient gains an opt-in forceCloseOnDrainTimeout escalation
used by all dart:io-backed clients — close(force: true) promptly fails
in-flight requests, unlike the native-callback clients (CupertinoClient)
the deferral exists for.

close #1972
2026-08-17 08:08:45 +02:00
edde746 a79084b0a5 chore(player): link upstream issue androidx/media#3377 in seek workaround 2026-08-17 04:47:06 +02:00
edde746 75704c7a9b test(player): cover tracks-after-clusters MKV seeking on device
Canary asserts stock media3 1.11.0 still reports these files unseekable
and snaps seeks to the start — when a media3 upgrade makes it fail, the
TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired.
The second test drives the production wrapper stack and requires the
seek to hold.
2026-08-17 04:47:06 +02:00
edde746 f2ce587a85 fix(player): seek via per-track cues in MKV files with Tracks after Clusters
media3 1.11.0 builds the Matroska seek map while parsing Cues, which for
files whose Tracks element follows the Clusters is before any track is
known — the map permanently reports unseekable and ExoPlayer coerces
every seek to t=0, snapping playback to the start. Route seeks through
TrackAwareSeekMap's per-track cue lookups using the track IDs observed
on the extractor output.
2026-08-17 04:39:59 +02:00
edde746 947fddf540 fix(ci): resolve the repository root after the symbols script moved
The scripts reorganization moved upload_symbols.dart from scripts/ into
scripts/release/ but kept the single-.parent root resolution, so the
script searched scripts/build and scripts/debug-info and every CI symbol
upload failed with "no symbols found". The sibling checks scripts were
updated to .parent.parent in the same move; match them.
2026-08-17 02:46:36 +02:00
edde746 50a9365be8 fix(linux): keep the Wayland display away from mpv on software GL
On a Wayland session whose compositor hands clients no GPU device
(Cinnamon/Muffin 6.6.3 on Mint 22.3), Mesa falls back to llvmpipe and
libva-wayland's vaInitialize segfaults inside mpv_render_context_create
the moment mpv is given the wl_display - the app silently closes when
playback starts. 2.14 was the first release to hit this because its
bundled libmpv is the first with VAAPI compiled in, and the device init
runs eagerly at render-context creation, so the hardware-decoding
toggle cannot avoid it.

Skip MPV_RENDER_PARAM_WL_DISPLAY when GL_RENDERER names a software
rasterizer: zero-copy Wayland interop does not exist on llvmpipe anyway,
and mpv's hwdec=auto still reaches the GPU through a DRM render node,
which works even on these sessions. Accelerated sessions keep the
zero-copy handoff unchanged. Verified in a container against Muffin and
weston on llvmpipe: the handle is withheld and playback works.
2026-08-17 02:20:43 +02:00
edde746 06ecba5fb5 fix(player): render Dolby Vision via gpu-next on the Android mpv backend
Dolby Vision Profile 5 files play with pink and purple colors on Android
devices without DV support (#1902): vo=gpu cannot apply DV RPU reshaping,
so the raw IPTPQc2 base layer reaches the screen. The shipped libmpv AAR
builds mpv 0.41 with libplacebo, where gpu-next is the upstream default VO
and reshapes DV correctly.

Prefer vo=gpu-next with an explicit vo=gpu fallback on both the primary
mpv backend and the ExoPlayer failure-fallback path, drop the
vd-lavc-film-grain=cpu override so film grain applies on the GPU under
gpu-next, and report current-vo in player stats.

Verified on a Pixel 7 (no DV display or decoder): gpu-next initializes on
Mali-G710/GLES 3.2, SDR hardware decode is unchanged, and a 4K DV stream
decodes to dolbyvision/bt.2020/pq with correct colors under software
decode. Reshaping still needs software decode: FFmpeg 8.0's mediacodec
wrapper exports no DOVI side data, so hardware-decoded DV keeps playing
the base layer untouched.
2026-08-17 01:41:46 +02:00
edde746 096e285ab0 fix(ui): center sheet back button hover circle on the arrow
Hovering the back button in bottom sheet headers drew the circular
highlight 12px right of the arrow: the 48px hit target was positioned
from the stack's padded origin while the glyph sits flush at the
leading content edge. Move the horizontal padding onto the header row
and position the target so the InkResponse box centers on the glyph.
2026-08-17 01:41:46 +02:00
edde746 aeb6108654 feat(scripts): add the deploy.py multi-channel release pipeline
One command releases to Play, Amazon, App Store Connect (iOS + tvOS), the GitHub build farm, Microsoft Store, and the GitHub release + cask, with checkpointed resume. Replaces the fastlane release flow; first used for 2.14.0.
2026-08-17 01:40:54 +02:00
edde746 f622ba8efe chore(scripts): group scripts into checks, codegen, maestro and release subdirectories
scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
2026-08-17 01:40:54 +02:00
edde746 e47b4e0a73 fix(macos): fall back to AVFoundation audio when CoreAudio fails to open
On macOS 27 beta, CoreAudio rejects ao_coreaudio's channel-layout setup
with paramErr (-50), and since 2.14.0 pinned ao=coreaudio as the only
output, the failed init left every video playing with no audio and no
selectable audio track. Append avfoundation as the fallback, mirroring
upstream mpv's macOS probe order: every format still opens through the
HAL-backed CoreAudio path when it works, and the fallback only engages
when CoreAudio's init fails outright.

close #1964
2026-08-16 22:40:53 +02:00
edde746 9dd8aa4679 fix(macos): remove the audio passthrough option
Enabling audio passthrough on macOS 2.14.0 silenced every AC3/EAC3/DTS
item: the macOS player now pins ao=coreaudio, where audio-spdif redirects
to coreaudio_exclusive. That needs an IEC61937-capable device Mac setups
essentially never have, and with a restricted ao list mpv has no PCM
fallback, so the failed AO init stalls playback with no audio. The toggle
never delivered real bitstreaming on macOS anyway (2.13 decoded through
AVPlayer), so stop offering and applying it there.
2026-08-16 22:40:53 +02:00
edde746 3f6e4c0716 fix(windows): render black letterbox bars in HDR via newer bundled libmpv
With HDR playback enabled on Windows, letterbox bars rendered dark gray
instead of black on HDR displays, for both SDR and HDR content. The
bundled libmpv (20251228-git-a58dd8a) carries a libplacebo that maps the
gpu-next background clear color through the display's reported black
point in HDR mode; libplacebo ff2799a67 (2026-01-07) fixes it by using
infinite contrast for the background. The pin had already been past the
fix (20260303) but was downgraded to 20251228 when downloads moved to
SourceForge.

Bump to 20260809-git-dd5d17d328 (mpv v0.41.0-920, libplacebo v7.371.0),
verified to contain the fix commit.

close #1965
2026-08-16 22:40:52 +02:00
edde746 49c117db83 fix(music): open the gapless next track ahead of the boundary
Gapless playback still gapped between tracks on network streams: mpv
only opened the armed next track's stream at the moment the current one
ended, so any server whose connect+probe outlasts the audio output's
buffered tail (~0.5s) - remote Plex over TLS, a transcode session
starting up - produced an audible dropout at every transition.

Enable mpv's prefetch-playlist when arming a network track so the open
happens while the current track still plays. Measured on a Pixel 7 the
boundary goes from 60-233ms of inline network work to 9-16ms with no
network activity at all; a failed or superseded prefetch falls back to
the old boundary open. Local fdclose:// arms keep prefetch off: an
early open would consume the fd while playlist-pos still reads 0,
breaking _clearArmedNext's "provably never opened" close proof.

close #1869
2026-08-16 22:40:52 +02:00
github-actions[bot] d77836ef22 chore: update cask to 2.14.0 2026-08-16 16:38:32 +00:00
edde746 92b0524edb chore: bump version to 2.14.0 2026-08-16 17:00:37 +02:00
edde746 bb95d54bac style(android): fix ktlint violations in PgsCompositionParser 2026-08-16 16:57:27 +02:00
Aldo BarrerasandGitHub 36b2549506 Add loudnorm to enabled ffmpeg filters. (#1958) 2026-08-16 16:48:29 +02:00
edde746 9c6fcd2625 refactor(player): share one playback-open orchestration between start and reload
_startPlayback and _reloadMediaInPlace each inlined the same ~180-line open sequence around the playback_open.dart helpers — frame-rate prep, display priming, startup gate, external-subtitle plan, open, track manager build, track apply, gate release — with comments instructing that the two copies be kept in sync, and they had already drifted (live _isTranscoding vs result.isTranscoding, Watch Together attach vs detach/reattach).

The sequence now lives once in _openResolvedMedia; the genuine divergences are explicit parameters and caller hooks (transcoding source, WT handling, session-commit boundary, automotive start deferral, resume timing), so a future change to open sequencing lands in both flows by construction. Every await boundary and staleness guard keeps its original position in both flows.
2026-08-16 16:48:02 +02:00
edde746 24f63a63c5 refactor(libraries): make loadItems the single library-tab load hook
BaseLibraryTabState documented an abstract loadData() that every tab "must implement", but three of the four tabs override loadItems() entirely and carried never-invoked empty loadData stubs; only the recommended tab exercised the contract, so the class docs described an extension surface that did not exist.

loadItems() is now the one overridable hook, and the shared load transaction (generation tracking, localized error mapping, post-frame onDataLoaded) lives once in the protected runLoadTransaction helper that the recommended tab and the focus tests route through. No tab's load behavior changes.
2026-08-16 16:48:02 +02:00
edde746 364cdea59b refactor(libraries): show server labels with one policy in the picker and the dropdown
The library quick picker and the libraries dropdown each hand-rolled their own BackendBadge + server-name label rendering with divergent show-when policies: the picker only labeled libraries whose titles collided, so a uniquely-titled library on a multi-server list showed its server in the dropdown but not in the picker.

Both surfaces now share library_server_label.dart — one label widget, one grouping loop over groupLibrariesByFirstAppearance, and one policy: group headers whenever the visible list spans more than one server, per-row labels only in ungrouped lists. The picker's duplicate-title heuristic is gone; its test now pins the unified policy.
2026-08-16 16:48:02 +02:00
edde746 ab199b04cd refactor(profiles): resolve the active profile's Plex token through one shared helper
The job "which Plex token represents the active profile right now" was implemented three times against the same registries — the Discover session supplier, the Seerr token supplier, and UserProfileProvider's settings refresh — and the copies had already diverged on whether a Plex Home profile may fall back to the account token.

resolveActivePlexToken in lib/profiles/active_plex_token.dart now owns the policy: the per-user ProfileConnection token wins when present, else the account-owner token, with an explicit allowAccountTokenForHomeUser flag (true for Discover/Seerr, false for the settings refresh, which must not impersonate the owner). The three call sites keep only their genuine differences.
2026-08-16 16:48:02 +02:00
edde746 ef2ab13abd refactor(profiles): render the profile picker from ActiveProfileProvider
profiles_view.dart rebuilt the exact merged-profile view ActiveProfileProvider already computes — the same four source streams, the same merge/avatar derivation, plus a hand-rolled combineLatest4 — and the profile switch screen was its only consumer while already reading the provider for activeId.

The screen now renders from the provider (new connectionsByProfile/connectionsById/plexHomeByConnectionId getters) and gates loading on provider initialization; visibleProfileConnections moved to profile_merge.dart for profile_detail_screen; profiles_view.dart is deleted. The switch-screen tests initialize the provider up front like boot does, using a timer-less PlexHomeService subclass so start()'s periodic refresh timer cannot trip the widget-test pending-timer invariant; the deleted pipeline's merge assertions were ported to profile_merge_test and active_profile_provider_test.
2026-08-16 16:48:02 +02:00
edde746 fc061dc38d refactor(media): merge the two MediaStreamKind enums into one shared enum
lib/media declared two different enums named MediaStreamKind — a 4-value one in media_stream.dart and a 7-value one in media_file_info.dart — so any file importing both libraries silently picked one by import order.

media_stream.dart's enum now carries the full member set (image/data/lyric added before unknown; the original four ordinals are preserved) and media_file_info.dart re-exports it instead of declaring its own.
2026-08-16 16:48:01 +02:00
edde746 58b510a6c4 fix(music): handle hardware media keys while the app is foreground on Android
Media buttons on HID remotes (USB/Bluetooth keyboards, common on Android
TV) are delivered as key events to the focused window instead of the
MediaSession, so they only worked while the app was backgrounded. A
global handler now routes play/pause, next/previous, stop, and
fast-forward/rewind to the live music session anywhere in the app and
consumes the key burst, so a press can neither leak to Android's
fallback MediaSession dispatch nor start a focused library item. Same
lifecycle as the OS media session; video playback never coexists with it
because claiming video disposes the music session first.

close #1948
2026-08-16 16:47:06 +02:00
edde746 ec605fd8b7 fix(downloads): keep the parallel download limit intact on slow networks
On a slow connection a queued batch of episodes would end up
downloading all at once instead of one at a time. Every download that
hit Android's 9-minute background task limit re-enqueued its
continuation outside the native holding queue's accounting while the
interrupted run freed a slot, permanently raising the effective
concurrency; the notification Resume action leaked the same way on
both Android and iOS.

Pins background_downloader to a fork revision that routes timeout and
notification resumes through the holding queue, only adjusts its
counters for tasks the queue actually promoted, and periodically
recalculates Android queue state the way iOS already did.

close #1955
2026-08-16 16:22:03 +02:00
edde746 25b2939f0a fix(player): stop same-day Specials from hijacking up next
close #1952

Shows whose Season 0 holds aftershow featurettes (e.g. House of the
Dragon "Inside the Episode") share air dates with the episodes they
accompany, so the air-date interleave from #1416 queued them between
regular episodes: playing S03E04 offered S00E76/S00E84 as up next.

Air date alone cannot separate canon Specials from featurettes, so
Specials placement is now a three-way preference, defaulting to
"follow server order":

- respectServer: Plex keeps its server-built /allLeaves queue (aired
  order, Specials interleaved, as before); the Jellyfin queue now
  preserves the /Shows/{id}/Episodes response order, which is
  Jellyfin's native watch order — Specials placed only via explicit
  AirsBefore* metadata per the server-wide DisplaySpecialsWithinSeasons
  setting (the endpoint ignores SortBy except Random). Client-side
  selections with no server order to respect (offline next/prev,
  download "next N", offline OnDeck) fall back to Specials-last.
- airDate: the #1416 aired interleave on every surface.
- specialsLast: Specials strictly after the regular seasons (#1414);
  Plex builds its show queue from /children.
2026-08-16 15:31:47 +02:00
edde746 a7354cd3b6 fix(android): render every PGS composition object in ExoPlayer
PGS subtitles vanished or displaced each other when a display set put two
images on screen at once (dialogue plus a sign or song caption), and
palette-only fade updates blanked the subtitle entirely. media3's PgsParser
keeps one bitmap buffer and only the first composition object's coordinates,
and it discards all state between display sets.

Replace it with PgsCompositionParser, a port of FFmpeg's pgssubdec model:
epoch-scoped object/palette caches keyed by id, in-place palette updates,
one cue per composition object reference, and limited-range BT.709/BT.601
color conversion selected by plane height.

close #1953
2026-08-16 14:03:51 +02:00
edde746 eaccef33dc fix(subtitles): clear a displayed ASS cue immediately when subtitles are disabled
Turning subtitles off (or hiding them) while an ASS/SSA cue was on screen left
that cue painted until its natural end time on the ExoPlayer backend: AssHandler
nulls the libass track, after which every render returns null, no payload ever
reaches the GL thread again, and the overlay keeps its last swapped atlas. The
existing invalidateSubtitles() call from the #1387 fix could never repaint it.

The atlas pipeline now detects a trackless render request and hands the GL
thread one zero-quad payload, which clears and swaps a transparent frame. The
clear is keyed on the renderer state generation so one dropped as stale is
retried, and it self-heals from per-video-frame requests while playing; the
existing invalidate on the disable transition covers the paused case.

close #1884
2026-08-16 12:42:04 +02:00
edde746 2ff3b350ae feat(player): give sync delay sliders 50ms steps over a ±10s range
The sync delay slider spanned ±60s with 100ms steps, making fine
adjustment between e.g. 100ms and 200ms impractical on touch and mouse.
The slider now covers ±10s at 50ms per step (taps and D-pad included),
while the +/- step buttons still reach the ±60s absolute limit via
long-press, so existing large saved offsets keep working and display
their true value.

close #1907
2026-08-16 09:18:49 +02:00
edde746 bb642fce03 fix(plex): send Live TV favorite updates as JSON so they persist again
Adding or removing a Plex Live TV favorite channel never persisted: the PUT
to epg.provider.plex.tv/settings/favoriteChannels answered 400. The dio ->
package:http migration (15b22f75) lost dio's implicit JSON content type, so
the JSON-encoded favorites list went out as text/plain and the Plex cloud
refused to parse the body. Verified live: the identical payload succeeds
with application/json and the mutation persists.

MediaServerHttpClient now defaults content-type to application/json for
structured bodies. Callers and client defaults still win (the headers map
is case-insensitive and already populated), so Jellyfin's pinned default
and the octet-stream artwork upload are unaffected; favorites is the only
Plex request with a JSON body.

close #1878
2026-08-16 09:14:28 +02:00
edde746 8069683bd3 fix(ios): give music playback lock-screen and headphone controls
Music on iOS played through a mixable audio session: mpv's audiounit
output requests mixWithOthers unless audio-exclusive is set, and a
mixable session disqualifies the app from Now Playing. iOS ignored the
published metadata and remote-command targets, so the lock screen showed
no controls, headphone buttons drove the previous media app, and other
apps kept playing alongside plezy.

Set audio-exclusive on the audio-only core at init on iOS — the same
contract the video player already applies at playback start. Its only
effect there is dropping mixWithOthers.

close #1921
2026-08-16 09:00:49 +02:00
edde746 d1d393eec0 fix(plex): stop timeline heartbeats once the server terminates the session
Pausing past the server's paused-session limit (or an admin stop) removed
the session on PMS, but Plezy kept sending paused timeline heartbeats,
re-registering it as a zombie session the server could no longer clear.

Plex signals the termination with terminationCode/terminationText on the
MediaContainer of the next timeline reply. Detect it, close the reporting
session with one final stopped report at the current playhead (which
removes the session row), and suppress paused heartbeats plus the
transcode keepalive until playback actually resumes, which opens a fresh
server session.

close #1916
2026-08-16 08:58:57 +02:00
edde746 972b62f6fa fix(watch-together): tell lobby guests the room's control mode
Guests joining an "Anyone" room saw "Host controls playback" and a
locked room until the host actually started something. Control mode
only travelled inside the host's PlaybackState broadcast, and every
broadcast path requires an active media epoch, so an idle lobby had no
carrier at all: guests sat on the joinAsGuest hostOnly default. The v1
protocol's sessionConfig message covered this; the v3 rewrite lost it.

Carry the mode on the host's join messages instead: the directed join
reply every participant already sends to a new peer, and the host's
reconnect re-announce. The field is optional on the wire ('cm'), so
older clients ignore it and rooms with older hosts degrade to the
previous behavior. Guests apply it only from the relay-derived host
peer ID, never from a join's own spoofable isHost flag.

close #1950
2026-08-16 08:48:58 +02:00
edde746 ea528b5213 fix(macos): use CoreAudio for every audio format
The AVFoundation/CoreAudio split still left PCM on AVFoundation and maintained two macOS timing paths. Select CoreAudio as the sole macOS audio output so PCM and compressed streams share the HAL-backed implementation. This intentionally drops macOS AVFoundation spatialization; iOS and tvOS remain unchanged.
2026-08-15 20:09:35 +02:00
edde746 5a0b595466 fix(android): play MKV files with late track metadata
MKV files whose Tracks element follows media clusters could direct-play with audio but no video. Upgrade Media3 to 1.11.0 and cover the extractor regression.

close #1947
2026-08-15 19:08:00 +02:00
edde746 35b3e97730 fix(macos): route compressed audio through CoreAudio
AC3/EAC3 playback could stutter after AVFoundation began handling compressed streams. Decline compressed formats in macOS AVFoundation so mpv falls through to CoreAudio, while retaining AVFoundation for PCM and the tvOS Dolby path.

close #1940
2026-08-15 17:52:51 +02:00
edde746 2f0a6a6bf5 fix(android): preserve PGS subtitle display-plane aspect
PGS cues use their own composition plane, but ExoPlayer sized bitmap subtitles from the cropped video aspect, stretching and displacing them. Infer the plane aspect from Media3 cue geometry and fit it inside the visible video bounds.

close #1945
2026-08-15 15:05:14 +02:00
edde746 d623c2164f fix(player): exit on phone back even with the chrome up (#1938)
4443b761 staged the system-back path (strip/fullscreen/hide/exit), so on
Android a back with the controls visible hid them instead of closing the
player. The PlayerNavigationCoordinator now takes an exitPlayerBeforeChrome
policy; the player enables it on mobile, restoring immediate exit on Back
while TV/desktop keep the staged chrome handling.
2026-08-15 14:02:07 +02:00
Aldo BarrerasandGitHub 8383c7b73b chore: bump go server version, run formatter & regenerate podfile lock file checksum (#1944)
* Bump go server and run formatter.

* Regenerate Podfile lock file checksum.
2026-08-15 13:44:56 +02:00
edde746 be863a0c1c fix(linux): remove the Flutter-texture fallback permanently
The display-agnostic texture renderer restored by 9cdfe759 is deleted
again, this time for good: it is a second, SDR-only rendering stack
(isolated EGL context on Flutter's display plus EGL-image handoff) kept
alive solely to host sessions that cannot bring up the Wayland plane -
X11/XWayland or a failed plane bootstrap - and it was the source of the
native lifecycle and EGL state-churn fixes of the 9f2e0507 era. Linux
video now requires a Wayland compositor; a session that cannot host the
plane fails initialization with VIDEO_PLANE_UNSUPPORTED instead of
silently rendering through the second stack.

Reverts the restore commit's machinery: mpv_texture.cc/.h and
mpv_gpu_bootstrap.cc/.h deleted, the plugin's texture-registrar,
bootstrap, and waitForVideoReady paths removed, MpvPlayer's texture-mode
render-context API dropped, and the Dart-side renderMode setting, its
settings tile, translation keys, and the Player textureId member
withdrawn. The #1874 HDR diagnostic work is unaffected.
2026-08-15 01:20:44 +02:00
edde746 fe3460ad12 fix(linux): drop the unreachable 8-bit render-context retry
The retry added while chasing the 2.13.0 hwdec regression never fires:
mpv probes hwdec interop lazily at the first decode attempt, and its
failure does not fail mpv_render_context_create, so the deep config never
gets rejected and the 8-bit tier is never reached. The actual regression
was the libmpv build losing the DRM providers, fixed in the previous
commit. Remove the dead retry and its prefer_deep plumbing, and correct
the pre-flight comment that claimed the interop probe runs at context
creation.
2026-08-14 21:49:46 +02:00
edde746 ae001d9ff3 fix(linux): restore VAAPI hardware decode and AV1 software fallback in the bundled libmpv
Hardware decoding stopped working for Linux users on 2.13.0 (Fedora 44
report): every source decodes in software, and AV1 plays black video with
audio. Two defects in the pinned libmpv build.

First, mpv's meson 'drm' feature silently disabled itself because the CI
builder lacks libdisplay-info, and every VAAPI path that does not depend
on a display server is derived from it: vaapi-copy's standalone render-node
device (the path 2.12.1 worked on) and the GL dmabuf interop for direct
vaapi. With only the Wayland VA provider compiled in, a machine whose
Wayland VA display fails to initialize has no fallback, and vaapi-copy
has an empty provider list - every source lands on software decoding.
Pin -Ddrm=enabled, -Dvaapi-drm=enabled, -Degl=enabled and
-Dvaapi-wayland=enabled, and add libdisplay-info-dev to the CI package
lists, so a missing piece fails the build instead of shipping silent
software decode.

Second, the bundled static FFmpeg has no AV1 software decoder: its native
av1 codec is hardware-accelerated only, so once hwdec fails there is no AV1
path at all - every packet errors, video hits EOF, the plane goes black
while audio keeps playing. Pin dav1d 1.5.4 (both VideoLAN remotes agree on
the tag object and root commit), build it static before ffmpeg, and pass
--enable-libdav1d.

The build-plan stub test now asserts the hwdec feature flags, the dav1d
static build, and ffmpeg's libdav1d. Verified in an ubuntu:24.04 container
with the production flag sets: meson reports drm, vaapi-drm, vaapi-wayland,
egl and dmabuf-interop-gl enabled, and ffmpeg configures CONFIG_LIBDAV1D=yes
with the AV1 VAAPI hwaccel.

close #1874
2026-08-14 21:49:41 +02:00
edde746 18c9f710bb style(linux): clang-format the native rendering changes 2026-08-14 19:50:28 +02:00
edde746 7937a7e30a style(dart): apply dart format to the Linux rendering changes 2026-08-14 19:30:32 +02:00
edde746 991184b451 fix(linux): repair native compile errors caught by the CI build
The first GitHub Actions build of the Linux path failed on six issues the
macOS host could not catch:

- HandleFrameDone is a static handler; CancelFrameAckWatchdog() needed the
  explicit self-> qualification.
- handle_texture_ready_result/handle_ready_timeout call
  release_video_resources before its definition; forward-declared.
- finish_leg captured self without using it (Werror).
- CanCommandOutputProperties was private; made public for the kUnknown
  live-core check.
- MPV_ERROR_UNKNOWN does not exist in libmpv; the timeout now reports
  MPV_ERROR_UNSUPPORTED (any non-success serves the latch; the log line
  names the reason).
- The hdr-tone-mapping error path referenced the handler's FlValue value
  inside an async lambda; an owned std::string copy is captured instead.
- The texture register-failure response used a heap string freed before the
  handler responded; use a literal.
2026-08-14 19:29:39 +02:00
edde746 c3dbbf6479 test(music): implement Player.textureId on the fake audio player 2026-08-14 18:20:12 +02:00
edde746 f56264dcce test(player): cover embedded-VO ownership, colour sanitization and styling refusals
Three new Linux startup cases, one isolate each (a second VideoPlayerScreen
in one isolate never reaches initialize):

- A custom mpv config naming vo/gpu-context/gpu-api cannot detach the
  embedded renderer: the writes are withheld by name while ordinary entries
  (sub-scale) still land.
- Unparseable stored subtitle colours (named, 3-digit hex) reach the wire
  canonicalized to the defaults, proving the OPT_COLOR sanitization.
- A refused sub-color write no longer aborts initialization: the write is
  attempted, contained, and playback continues past the styling block.
2026-08-14 18:18:09 +02:00
edde746 9cdfe7591e feat(linux): restore the Flutter-texture path as the SDR fallback
2.13.0 deleted the display-agnostic EGL/Flutter-texture renderer and made
the native Wayland plane the only path, hard-rejecting every session that
cannot host one - X11, XWayland (SteamOS Gaming Mode runs native apps
through Gamescope's XWayland), or a plane whose EGL bootstrap failed. This
restores the 2.11.0 texture path from git history as the fallback:

- Re-add mpv_texture.cc/.h and mpv_gpu_bootstrap.cc/.h (2.11.0 verbatim):
  an FlTextureGL whose populate renders mpv into an offscreen FBO sampled
  by Flutter via an EGL image.
- MpvPlayer gains the texture-mode render-context API (InitRenderContext,
  HasRenderContext, GetEglDisplay/Context, Render(w,h,fbo)) beside the
  plane's InitRenderContextForSurface/RenderToSurface. The isolated ES 2.0
  context on Flutter's display and the X11 display param are exactly the
  2.11.0 configuration hardware decode demonstrably worked in.
- initialize now tries the plane first and falls back to the texture path
  when it cannot be brought up, returning the texture id (Dart's 2.11.0
  'result is int' contract) with waitForVideoReady gating playback until
  the GPU bootstrap settles. Both paths share one mpv core, so the
  plane/texture decision precedes render-context creation.
- hdr-enabled/hdr-tone-mapping are intercepted in texture mode (no plane,
  no HDR); the HDR toggle hides itself via isHDRSupported.
- New Linux setting 'Video rendering mode' (Automatic / Texture) forces
  the fallback - the user-visible workaround for plane-only trouble and
  for the hwdec interop regression, plus translations in all locales.

SDR only on the fallback, matching 2.11.0; the plane path is unchanged.
2026-08-14 18:16:32 +02:00
edde746 48f365ab30 fix(linux): bound HDR transactions and restore hwdec interop fallback
Three failure modes in the 2.13.0 Wayland path, three fixes:

- The mpv leg of an HDR transaction had no timeout: the surface watchdog
  re-armed while it ran, but the HDR method call stayed unanswered when mpv
  never replied, leaving the transaction queue stuck behind a ghost forever.
  A 5 s timeout (sharing the surface's horizon) aborts the transition,
  withdraws any description, resumes presentation, and answers the request
  exactly once via a shared latch; a late mpv reply self-heals through the
  stale-token re-apply path.

- The kUnknown quarantine hid the plane for the whole session when mpv
  stopped answering - the AV1-transparent report was a plane hidden this
  way while sound kept playing. Hiding is now reserved for a core that is
  genuinely going away; a live one presents undescribed (sRGB by protocol),
  and a playback restart clears the quarantine so one poisoned source
  cannot hide every later one. Product decision: visible-wrong beats
  invisible.

- hwdec's dmabuf interop probe runs at mpv_render_context_create time
  against the plane's fresh EGL display/context, and drivers that fail it
  on a deep config silently land every source on software decoding (the
  Fedora 44 report; 2.12.1 created the context on Flutter's display). The
  prerequisites are now logged explicitly at creation, and a failed render
  context is retried once on the 8-bit config tier - the 2.12.1-equivalent
  configuration - with HDR off by the depth gate.
2026-08-14 18:11:14 +02:00
edde746 093109ae09 fix(linux): bound plane presentation against unacknowledged frames
Present() arms wl_surface.frame and frame_pending_ is cleared only by the
frame callback. Compositors are entitled to stop acknowledging frames for
occluded or minimized surfaces - wlroots-lineage compositors (Hyprland) do
exactly that - and nothing bounded the wait: one missed callback froze the
plane on its last buffer forever, because every later render bailed on
frame_pending(). That is the 2.13.0 black-video report on Hyprland: the
first commit is the pre-allocated 1x1 (or pre-video black) buffer, fully
occluded by the opaque Flutter surface, and the callback it armed never
arrives.

Two changes, one stall:

- A 500 ms frame-acknowledgement watchdog in Present(): on expiry the dead
  callback is withdrawn and a fresh present is asked for, so the plane
  re-commits instead of sitting on the latch. A miss budget (5) stops
  poking a surface the compositor is still ignoring; a real
  acknowledgement resets it.

- The very first present is refused until mpv actually has a frame: the
  first forced render happens at setVideoRect time, before anything is
  decoded, and committing that empty buffer is exactly the commit an
  occluded surface ignores. The sticky plane_needs_render flag keeps the
  owed resize refresh pending until content exists, so the first present
  still happens at the right size the moment the first frame lands.
2026-08-14 18:11:06 +02:00
edde746 14cae85931 fix(player): never fail playback on preference writes; keep vo authoritative
mpv 0.40's OPT_COLOR parser rejects anything but #RRGGBB/#AARRGGBB, so a
stored subtitle colour that does not parse made mpv refuse the write with
MPV_ERROR_PROPERTY_FORMAT — and the bare await in _runPlayerInitializationAttempt
turned that into the initialization error screen on every open. Subtitle
styling, volume-max, and the pre-open defaults (start/pause/sid) are now
sanitized (colours canonicalized to hex with fallback to the default) and
non-fatal, matching the existing hdr-enabled tolerance policy: a refused
preference write must never become "this session cannot play video".

The user mpv.conf editor is applied as runtime mpv_set_property writes, and
vo/gpu-context/gpu-api were not withheld, so a vo=gpu-next line re-created
mpv's output as a separate uncontrollable window and orphaned the embedded
render context. Add the VO family to the Linux-owned property set with a
key-aware skip log, a native reject for vo != libmpv on the video core (the
render API is OpenGL-only; gpu-next is windowed by construction), and a hint
in the mpv.conf editor explaining why, with translations across all locales.
2026-08-14 18:07:16 +02:00
edde746 9090ad6283 fix(linux): name refused property writes and surface the hwdec path
The SET_PROPERTY_FAILED error surfaced to Dart carries only mpv's own text
("unsupported format for accessing property"), which names the failure
mode, never the property. Every report of a refused write is therefore a
guessing game. Include name=value in the error description on all three
setProperty branches (generic, hdr-enabled, hdr-tone-mapping) and warn with
the same pair from MpvPlayer::SetPropertyAsync so the HDR transaction's
target-* writes — which never reach the channel — are attributable too.

mpv_request_log_messages is a single global level, and the vaapi probe and
"Using software decoding" fallback are MSGL_INFO, so at "warn" a silently
software-decoding session leaves no trace in the app log. Raise the request
level to "info" and observe hwdec-current natively, logging every decode
path transition from the player instead of relying on an overlay readout.
2026-08-14 18:07:09 +02:00
edde746 8f9ffc76eb fix(emby): show scrub previews from the /Videos/{id}/index.bif transport
Scrubbing an Emby video showed no preview thumbnails on the timeline
while Plex and Jellyfin both worked: Emby's scrubThumbnails capability
was off, so the player never attempted a load. It was off because the
4.9.5 test server answered the preview endpoints with empty payloads —
its extraction task had not run.

Emby's preview transport is a Roku-format BIF at
/Videos/{id}/index.bif?Width=320, the same wire format Plex serves, so
the existing BIF parser handles it unchanged. Enable the capability and
route Emby previews through the shared BifThumbnailService, whose load
now takes a bytes callback instead of a Plex-typed client. MediaBrowser
sources also carry videoAspectRatio from the video stream so the
tooltip sizes itself to the stream. A server without extracted frames
still answers a header-only BIF, which parses to zero frames and keeps
the tooltip suppressed.

close #1930
2026-08-14 13:36:29 +02:00
edde746 12d9865a41 fix(subtitles): render Korean glyphs on the MPV path via bundled Hangul font
GoNotoCurrent lacks Hangul syllables, and the Android libmpv build has no
fontconfig/system-font fallback, so libass could not resolve any Korean
glyph and subtitles rendered as boxes. Ship a Hangul subset of
GoNotoKurrent-Regular beside the default font in the extracted subtitle
fonts directory; libass picks it up as fallback by glyph coverage.

close #1932
2026-08-14 13:02:26 +02:00
edde746 688bfdacb4 fix(profiles): keep offline downloads visible when a profile's servers are unreachable
Switching to a profile whose only server is offline verified the PIN,
failed the bind with zero reachable servers, and rolled back to the
previous profile — whose scope owns none of the downloads. The Downloads
UI then showed nothing while the files and pinned metadata sat intact on
disk. Startup offline mode only covered the cold-start bind, so such a
profile could never be entered while its server was away.

The binder now classifies a settled bind failure as connectivity-only
when the profile expected servers, reached none, and none were
auth-rejected (snapshotting auth markers before the visibility sweep,
which clears them via removeServer). On such a failure,
switchProfileFromUi keeps the profile active when it owns downloads
instead of rolling back; OfflineModeProvider drives the offline UI from
the empty visible-server set. Auth failures, PIN cancels, and
downloads-free profiles keep the existing rollback and error snackbar.

close #1927
2026-08-14 12:20:20 +02:00
edde746 cd4432f27c fix(auth): keep polling Plex PIN after transient network errors 2026-08-14 12:20:20 +02:00
edde746 5f6d9dc610 fix(navigation): keep the sidebar Libraries section collapsed across restarts
Collapsing Libraries in the sidebar only lasted until the app closed. The
next launch always came back expanded, which buries the rest of the nav
menu for anyone running a lot of libraries.

The expansion flag was plain widget state on SideNavigationRailState. It
survived tab navigation only because MainScreen pins the rail with a
GlobalKey, and died on relaunch, profile switch or a layout change. It now
lives in the librariesSectionExpanded preference, so the rail reads and
writes the persisted value and follows a settings reset or import too.

close #1896
2026-08-13 21:44:25 +02:00
edde746 ce151a91e8 fix(player): show AAC instead of mp4a.40.2 in ExoPlayer track labels
ExoPlayer reports RFC 6381 codec IDs (mp4a.40.2, ec-3, dtsc) where mpv
reports ffmpeg names, so the audio track picker and performance overlay
showed raw identifiers instead of friendly names on the ExoPlayer path.

Normalize RFC 6381 audio IDs in CodecUtils.formatAudioCodec, route the
performance overlay's audio codec through it, and cover the video IDs
(hvc1/hev1, av01, vp09) that fell through the overlay's matcher.

close #1899
2026-08-13 21:23:08 +02:00
edde746 922789c014 fix(tv): stop the server session when a backgrounded TV releases the player
On Fire OS, standby never freezes a backgrounded app, so the paused
heartbeats that kept a suspended session "alive and resumable" pinned it
in the server dashboard indefinitely. The live session bought nothing:
the restore path already performs a fresh playback decision.

When the TV background grace expires, stop the heartbeat timer (its
paused tick also keeps a Plex transcode session alive) and report the
session stopped before releasing the native pipeline, since stop()
resets the player state the report reads. Standby entry can drop Wi-Fi
into power-save and mutations never fail over, so a failed terminal
report is redelivered on a bounded schedule, gated on actual backend
delivery and on the suspend still standing so a restored session is
never reported stopped.

close #1911
2026-08-13 20:34:12 +02:00
edde746 a49f506a0b fix(tv): keep quality labels visible when rating badges crowd the detail line
On TV, a movie carrying a full set of attributed scores pushed the resolution and audio labels past the right edge of the detail metadata line, which silently clipped them.

The line now sheds its least useful parts when it overflows -- surplus rating badges first, then the whole ratings slot, then quality labels -- instead of hard-clipping the tail. It also leads with the year instead of the redundant "Movie"/"TV Show" label, matching the desktop hero chip order, and the Discover spotlight line gets the same fitting. Branded badge icons are pinned to their SVG viewBox aspect so the fit is measured exactly.

close #1893
2026-08-13 02:29:01 +02:00
edde746 8cdf04122f fix(tv): pause on the first Select press after skipping the intro
After activating Skip Intro with the remote, the next Select only raised
the player controls and pausing took a second press. The skip button
autofocuses on TV, and every path that hides it dropped focus out of the
controls subtree - the screen node reclaimed the remote and its self-heal
consumed the next key to raise the chrome instead of toggling playback.

Hand the remote back to the player surface whenever the skip button
disappears while focused: after a skip seek, when the playhead leaves the
marker window, and when the auto-dismiss timer hides the button. The
credits-at-end path is exempt because the play-next flow requests its own
focus.

close #1890
2026-08-12 23:03:44 +02:00
edde746 3e1481c238 fix(windows): bundle the MSVC runtime so an outdated redist stops crashing launch
A user's plezy.exe 2.13.0 crashed on every launch with an access violation
in C:\WINDOWS\SYSTEM32\MSVCP140.dll 14.29.30139.0 (VS 2019-era redist). CI
builds with the current VS 2022 toolset, and since 17.10 std::mutex uses a
constexpr layout that an older msvcp140.dll misreads, so the app dies inside
the DLL before the first frame. Nothing shipped the runtime: the bundle,
installer, portable archive, and MSIX all relied on whatever redist the
machine happened to have.

Install the MSVC runtime DLLs next to plezy.exe via
InstallRequiredSystemLibraries; app-local copies precede System32 in the DLL
search order, so the bundled version always wins. One install rule covers the
installer, portable 7z, and MSIX, which all package the Release directory.
The Windows bundle verification in build.yml now requires msvcp140.dll and
vcruntime140.dll so a silently missing redist dir fails CI instead of
shipping.
2026-08-12 23:00:36 +02:00
YorickandGitHub 0d24240af4 fix(linux): defer buffer_scale until first frame is presented (#1876)
* fix(linux): defer buffer_scale until the first frame is presented

The video plane's wl_egl_window starts as a 1x1 placeholder. When playback starts, SetRect() sends wl_surface.set_buffer_scale(2) and resizes the window, but mesa commits the EGL surface's pre-allocated 1x1 back buffer on the first eglSwapBuffers. A 1x1 buffer is not an integer multiple of scale 2, so the compositor raises WL_SURFACE_ERROR_INVALID_SIZE, tears down the Wayland connection and plezy exits (issue #1872). At display scale 100% the same 1x1 buffer is legal, which is why that workaround worked.

Defer sending the new scale until after the first commit: the first buffer is 1x1 at scale 1 (always legal), then the scale change lands on the wire and applies to the next commit, whose buffer mesa allocates at the resized window size. The one-frame 1x1 flash is mpv's black first frame - imperceptible.

* fix(linux): gate buffer_scale on a first-frame latch, reset state on destroy

Address review: buffer_attached_ cannot represent "first frame
presented" - DetachBuffer() clears it while the committed scale stays
on the wire, leaving a crash path (present at scale 2, detach, move to
a scale-1 display, then a swap commits a scale-1 buffer while scale 2
is still active).

Replace it with first_frame_presented_, set once a frame has been
presented and cleared only when the wl_surface is torn down, so scale
changes queue even with no buffer attached. Reset scale_sent_ to 1 in
Destroy() too: a freshly created wl_surface starts at scale 1, and a
stale value would suppress the first scale request after recreation.
2026-08-12 11:18:36 +02:00
edde746 9769c4e092 chore: bump MPVKit to 1.0.20 2026-08-12 08:56:33 +02:00
edde746 f291425f20 fix(tvos): increase AVFoundation presentation lead 2026-08-12 02:22:44 +02:00
1c1bba7e27 Update installation instructions for Arch Linux (#1870)
* Update installation instructions for Arch Linux

Arch now maintains an official package for plezy.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-12 01:50:40 +02:00
edde746 26e98e898d fix(desktop): reserve root Escape for leaving fullscreen instead of quitting
Physical-keyboard Escape at root Home now exits window fullscreen on
Windows and Linux the way it already did on macOS, and never arms the
press-back-again quit — so Escape aimed at fullscreen can't close the
app. Remotes, gamepad B, and system back keep the double-press exit.

close #1748
2026-08-11 11:15:51 +02:00
edde746 6663353895 fix(player): retry episode advances that fail on a transient server blip
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
2026-08-11 09:08:07 +02:00
edde746 83c50d93a2 fix(subtitles): flatten atlas-overflow ASS frames into an RGBA composite
Signs built from hundreds of overlapping paint-stroke drawings (masked
smartphone screens and similar typesetting) sum to far more bitmap area
than the paged ALPHA_8 atlas can hold: the issue sample needs 5 pages of
16M px at 1080p and 19 at 4K against the 4-page cap, so the packer
dropped the painter-order tail - the sign's text and late mask strokes.

Move the packer out of the JNI file into AssPack.c (pure C, compilable
against a desktop libass for verification) and add a composite fallback:
when a frame can never fit MAX_ATLAS_PAGES pages or the vertex budget,
blend the image list CPU-side into one premultiplied RGBA rect over the
union bounding box - O(frame area) instead of O(sum of image areas) -
and draw it as a single quad through a new MODE_COMPOSITE path in the
GL renderer. Oversized composites reuse the existing grow-and-re-render
contract; the atlas fast path is byte-identical for every frame that fits.

Verified with a desktop harness compiling the shipped AssPack.c against
fork libass 0.18.3 and the issue sample: all atlas-mode frames byte-match
the previous packer, the sign's frames composite with zero truncation and
byte-match a reference full-frame blend at 1080p and 4K, and the
multi-page composite grow path round-trips.

close #1868
2026-08-11 08:41:56 +02:00
edde746 3a704a2b9b fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.

The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2026-08-10 23:04:35 +02:00
edde746 aff6b6576f fix(player): offer the TrueHD MAT carrier on API 29-32 routes
Carrier-or-decode gated the carrier on getDirectPlaybackSupport, which only
exists on API 33, so every older route force-decoded TrueHD - including
routes that bitstreamed it before the carrier existed. The #1863 Fire TV
Stick 4K Max is Fire OS 8 (API 30): its HDMI route advertises raw TrueHD
and IEC 61937 at 8 channels, 2.12.1 passed TrueHD through, and 2.13.0 hands
the same stream to the FFmpeg decoder. The Shield is API 30 as well.

API 29-32 now asks AudioTrack.isDirectPlaybackSupported about the exact
192kHz/7.1 IEC tuple before offering the carrier. It is coarser than the
API 33 probe - it cannot tell bitstream from offload - but an IEC 61937
track is PCM-shaped by definition, so direct support means the route
carries the frames. getMinBufferSize stays as the precondition on every
tier, and a route that still lies fails AudioTrack initialisation, which
the audio recovery path already answers by blocking direct output and
force-decoding in place. Below API 29 nothing can vouch for the tuple, so
the carrier is still not offered and TrueHD decodes as before.

The tier decision is split from the platform probes so it is unit-testable;
each probe is consulted only on the tiers where its API exists.
2026-08-10 22:54:17 +02:00
edde746 d19ec625cd fix(tv): remove the background Watch Next refresh
2.13.0's ShelfRefreshWorker boots a second headless FlutterEngine in
the app process to refresh the launcher row every six hours. Its
foreground guard is checked only once at worker start, so launching the
app during a run leaves two engines sharing a low-RAM TV for up to 90
seconds, and a failed run retries with backoff. Suspected of
destabilizing the compositor on the 32-bit TCL panel in #1862. The tvOS
Top Shelf live fetch is unaffected and stays.

The foreground sync pipeline keeps the row fresh while the app runs, as
before 2.13.0. Updated devices still carry the persisted periodic job,
which would wake the process once more only to fail instantiating the
deleted class; the package-replaced receiver now cancels it.
2026-08-10 22:30:51 +02:00
edde746 c2bd1d28fd fix(subtitles): load external subtitle files with the media whether or not selected
Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.

Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.

close #1860
2026-08-10 21:23:55 +02:00
edde746 ea356a6112 fix(plex): use fMP4 HLS for video transcodes so HEVC presets stop corrupting
Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.

Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.

close #1859
2026-08-10 20:38:45 +02:00
edde746 69fadc220d chore: clean up code comments 2026-08-10 20:28:41 +02:00
edde746 5611c6785a chore: bump version to 2.13.0 (129) 2026-08-10 19:03:03 +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 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00
edde746 3177290083 chore: bump MPVKit to 1.0.19
Picks up two vo_avfoundation fixes: keep the displayed frame across
seek resets, and refuse Core Image geometry rendering for Dolby
Vision passthrough frames (defense in depth - the app already keeps
mpv video-zoom at 0 on iOS/tvOS and zooms the display layer instead).
2026-08-10 14:50:05 +02:00
edde746 a0269c6feb fix(player): keep HDR/Dolby Vision through zoom on iOS and tvOS
Nonzero mpv video-zoom flips vo_avfoundation into a per-frame Core
Image re-render that destroys HDR/DV passthrough - DV frames render
near-black on tvOS (verified on Apple TV 4K, DV P7->8.1 content:
panel luma mean 0.0 zoomed vs 87-103 unzoomed at locked exposure).

Zoom now scales the AVSampleBufferDisplayLayer itself (a
sublayerTransform on the container is ignored by the video plane)
via the existing Player.setVideoZoom seam, and VideoFilterManager
pins the mpv property to 0 on backends with native zoom. The layer
tree at 100% stays identical to before: clipping engages only while
zoomed, and updateFrame sizes the layer via bounds/position, which
frame= decomposes to anyway.

macOS keeps the property path (gpu-next zooms losslessly in-shader);
Android is untouched.
2026-08-10 14:17:40 +02:00
github-actions[bot] f2ef15806a chore: update cask to 2.13.0 2026-08-10 08:32:01 +00:00
github-actions[bot] dfdb38707d chore: bump version to 2.13.0 2026-08-10 07:26:07 +00:00
edde746 e9a213807f ci(linux): check the runner's libraries reach the package metadata
The plane added three runtime libraries that bundle-libs.sh deliberately does not
bundle, so they have to be declared per distro by hand - and two hand-maintained
lists drifting apart is the failure this guard exists to prevent.

check_linux_package_deps.py parses the runner's CMake for every pkg-config module
it links, follows target_link_libraries to prove each one actually reaches the
binary, and requires a package name for it in every distro's depends list. It
fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call
naming several modules, options preceding the module name, and version
constraints like mpv>=0.40 that would otherwise be read as a package nobody
ships.

The smoke job builds the three packages and reads the dependencies back out of
the artifacts, deriving what to expect from build-packages.py rather than
restating it - so a library is declared once and verified everywhere. That job is
off by default, which is exactly why it must not carry its own copy of the list.

The Linux native job names libwayland-dev and libegl-dev instead of riding
GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own
rationale. In CI the host-dependency guard runs once: the named step covers the
staged bundle, and build-packages.py's internal run - which exists for by-hand
packaging - is skipped. The smoke job also drops patchelf, which nothing
invokes.
2026-08-10 08:48:14 +02:00
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00
edde746 c27dc0a1a7 build(linux): vendor the color-management-v1 protocol bindings
wayland-scanner output for the staging colour-management protocol, which the
native video plane uses to describe itself to the compositor as PQ / BT.2020.

Committed rather than generated at build time: the protocol only appeared in
wayland-protocols 1.41, newer than the version the distributions this app is
built for ship. Vendoring keeps the build working regardless of the host and
adds no build dependency on wayland-scanner. Generated from wayland-protocols
1.49 with wayland-scanner 1.25.0.

Nothing links these yet; the CMake wiring and the plane that uses them follow.
The local .clang-format exempts the generated sources from the runner's style.
2026-08-10 08:47:51 +02:00
edde746 f5665df43f fix(music): report a gaplessly advanced track's first timeline at its own start
When a gapless advance was announced, the new track's tracker sent its
initial report from live player state, which still carried the finished
track's position and duration - telling Plex the new track was already
at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on
top of the one from the real playthrough, and the tracker latched the
new track watched locally the moment it began.

The music bind now pins the initial report to the track's own start
(position zero, metadata duration); timer ticks keep reading live state.

close #1849
2026-08-10 08:33:11 +02:00
edde746 f1d4be70e2 feat(trackers): show a QR code in every tracker sign-in dialog and fit them on TV screens
Every tracker auth dialog (Trakt/Simkl/MDBList device-code and MAL/AniList
OAuth proxy) now shares the same PendingAuthDialog affordances: a QR code for
the sign-in URL, a large copyable URL with the scheme stripped, the browser
launch button (hidden on Apple TV, which has no browser), and the polling
spinner. On wide viewports (TV logical 960x540, desktop, phone landscape) the
QR pane sits beside the instructions so the dialog no longer clips on tvOS,
and the content is scrollable as an overflow safety net. Device activation
codes scale down instead of wrapping.
2026-08-10 08:33:11 +02:00
Tolu AdegbehingbeandGitHub 85d1672909 fix(player): accept clock-sync pongs only from the host (#1850)
Every authoritative message a guest acts on is gated on the relay-stamped
`senderId` matching `_session.hostPeerId` — room state, `hostExitedPlayer`
— except `pong`, which `_handleMessage` fed to `ClockSync` on nothing more
than "I am a guest and this pingId is one I am waiting for".

That matters because the guest's clock, not just the state it receives, is
part of the trust boundary. A guest estimates the host's clock offset from
the round trip: it sends `ping` at its own local time, the host answers
`pong` stamped with the host clock, and the guest takes the midpoint as the
one-way delay. Every anchor the host publishes — `anchorHostTimeMs` on a
state, a scheduled synchronized start — is translated into local time
through that offset, so a wrong offset silently shifts the target position
`GuestPlaybackReconciler` computes from otherwise authentic state. Past the
2000 ms hard-seek threshold the guest seeks, then keeps mistranslating the
corrections that follow, while the host and every other guest stay fine.

Any peer in the room could therefore reply to another guest's ping. This is
a weak primitive rather than playback takeover: the forged pong has to name
a `pingId` that is currently outstanding and land inside its RTT window,
and `ClockSync` discards samples over a second. But the relay already
stamps the sender on every inbound message, so the check costs one
conjunct.

Verified: full suite green (5788 tests, 5 skipped), 186 of them under
test/watch_together, plus dart format and analyzer parity.
2026-08-10 07:13:39 +02:00
Tolu AdegbehingbeandGitHub 5c08b29756 fix(plex): keep compatible audio on capped transcodes (#1845)
`maxVideoBitrate` budgets the whole stream, so a capped transcode forced
even profile-compatible audio down to low-rate AAC (measured on Plex
1.43: EAC3 5.1 640k became 360k AAC). The client profile already carries
the video cap through `add-limitation(video.bitrate)`, so drop the
redundant `maxVideoBitrate` param and send `directStreamAudio=1`: the
video stays capped per preset while audio in the codecs the profile
declares (aac/ac3/eac3/mp3) is copied through untouched. Audio the HLS
target cannot carry still transcodes as before.
2026-08-10 07:05:23 +02:00
edde746 672047924c fix(test): stop the desktop prefs preflight deadlocking widget tests on Linux
PrefsRecovery.assertStoreReadable reads the real on-disk store through
path_provider, and on the hosts where it is active (Linux and Windows)
that genuine file IO can never complete inside testWidgets' fake async
zone. Since 7f0cad33 wired the preflight into shared-preference init,
every widget test that initialises settings on the Linux CI runner hung
for its full ten-minute timeout, turning the unit-test job into a
six-hour cancellation. The hermetic prefs fixture swaps the backends for
in-memory fakes anyway, so it now disables the preflight and restores it
on teardown; the repair-flow suite keeps opting in explicitly.

Verified in an ubuntu-24.04 container on Flutter 3.44.0: the full suite
now completes in nine minutes with zero failures.
2026-08-09 18:51:47 +02:00
edde746 e92cc3cebf fix(website): update undici, postcss, nanoid, and SvelteKit past open advisories
Overrides move undici to 7.29.0, postcss to 8.5.26, and nanoid to 3.3.18;
SvelteKit updates in range to 2.70.2. The eleven undici acceptances in the
Bun audit baseline are stale once undici is current, so they are removed.
2026-08-09 18:51:47 +02:00
edde746 8e1be64d8d fix(windows): rewrite the preference store in place when a reader vetoes the rename
dart:io opens files without FILE_SHARE_DELETE, so MoveFileExW with
MOVEFILE_REPLACE_EXISTING - and any other replacement strategy - fails
while such a reader holds the document open. Fall back to the upstream
in-place rewrite so a hostile reader costs at most crash-atomicity for
that one write instead of silently dropping it, and sweep the staging
copy the rename did not consume.
2026-08-09 18:51:47 +02:00
edde746 5a3a1f3c70 fix(ios): drop RunnerTests coverage of the removed Atmos raw EC3 loader
RawEc3Loader and its ProbeURLProtocol harness were deleted with the Atmos
output diagnostics, but the iOS RunnerTests kept exercising them, so the
Apple native reliability job no longer compiled.
2026-08-09 18:51:47 +02:00
edde746 0ee5672426 fix(ci): replace unused main.dart test wrappers with the public root shell
The unused-code gate flags debugSetCrashReporterReady (referenced nowhere)
and the formFactorScaleForTesting/rootShellForTesting wrappers (referenced
only from tests, which the lib-scoped check cannot see). Delete the dead
setter and let the car-scale test build the real rootShell and
FormFactorScale directly.
2026-08-09 18:51:47 +02:00
edde746 df0570b861 style: apply dart format to 21 drifted test files 2026-08-09 17:50:58 +02:00
edde746 8d284efa39 chore(android): fix ktlint function-signature violation in ShelfRefreshWorkerTest 2026-08-09 17:21:01 +02:00
edde746 716fe9b51b feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.

External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.

close #1822
2026-08-09 17:14:48 +02:00
edde746 9d13584c00 feat(settings): add appearance toggle to hide the Explore tab
The Explore tab appears for every Plex-backed profile because the Plex
Discover catalog source connects implicitly, with no way to opt out
short of a Jellyfin-only profile. Add a Show Explore Tab switch to
Appearance > Navigation that hides the tab in the bottom navigation and
side rail. UI-only: catalog sources stay connected so watchlist
surfaces keep working while the tab is hidden.

close #1844
2026-08-09 17:14:47 +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 8ad13e94cf chore(mpv): bump MPVKit to 1.0.17 2026-08-09 12:31:48 +02:00
edde746 e0a364e26a chore(tvos): remove the Atmos output diagnostics 2026-08-09 11:52:48 +02:00
edde746 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
2026-08-09 11:12:09 +02:00
edde746 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.
2026-08-09 11:12:08 +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 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.
2026-08-09 10:06:29 +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 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.
2026-08-09 08:08:59 +02:00
edde746 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
2026-08-09 07:46:31 +02:00
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00
toluLikesToCodeandedde746 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
2026-08-09 06:28:59 +02:00
edde746 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.
2026-08-08 12:36:49 +02:00
edde746 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.
2026-08-08 12:18:32 +02:00
edde746 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
2026-08-08 12:06:32 +02:00
edde746 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.
2026-08-08 10:58:59 +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 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.
2026-08-08 10:02:04 +02:00
edde746 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
2026-08-08 09:09:48 +02:00
edde746 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
2026-08-08 08:29:37 +02:00
edde746 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.
2026-08-08 00:37:17 +02:00
edde746 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.
2026-08-07 13:23:53 +02:00
edde746 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
2026-08-07 10:15:48 +02:00
edde746 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
2026-08-07 08:43:48 +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 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.
2026-08-07 08:43:47 +02:00
edde746 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
2026-08-06 06:11:12 +02:00
Tolu AdegbehingbeandGitHub 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.
2026-08-06 05:54:17 +02:00
edde746 db4f7a643b test: prune low-value coverage 2026-08-06 05:33:18 +02:00
edde746 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
2026-08-06 04:21:24 +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 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.
2026-08-06 03:45:09 +02:00
edde746 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.
2026-08-06 03:45:09 +02:00
edde746 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.
2026-08-06 03:45:09 +02:00
edde746 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.
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 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
2026-08-06 03:45:08 +02:00
edde746 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.
2026-08-05 15:24:57 +02:00
edde746 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.
2026-08-05 13:17:26 +02:00
edde746 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.
2026-08-05 13:05:15 +02:00
edde746 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.
2026-08-05 12:31:29 +02:00
edde746 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.
2026-08-05 12:03:05 +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
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 9f2e05079 (release 2.10.0).

Co-authored-by: Torin Cooper-Bennun <torin.cbennun@googlemail.com>
2026-08-05 11:55:28 +02:00
Vincent ValleeandGitHub 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.
2026-08-05 07:14:11 +02:00
edde746 3b0b407cd0 docs: list Emby alongside Plex and Jellyfin
Feature footnotes distinguish the two MediaBrowser backends where they diverge:
favorite and unwatched filters work on both, while Quick Connect stays
Jellyfin-only because Emby exposes no such route. LAN discovery covers both,
since Emby answers only its own datagram.
2026-08-05 06:09:27 +02:00
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00
edde746 f36e20bcad fix(profiles): keep the profile picker highlighted while its list sorts
The picker resolved StorageService asynchronously and rebuilt its profiles
stream once it landed. Storage is what supplies profile recency, so the second
view arrived re-sorted a microtask after first paint. The sliver children
carried no keys, so that reorder handed each tile's Element the next profile's
focus node; detaching the old node dropped primary focus onto the enclosing
scope and took the D-pad highlight with it. The launch picker has no back
route on tvOS, so a user who can no longer see or move the selection has
nothing useful left to press.

Read StorageService from the provider graph, where it is already resolved
before any route exists, so the stream is built once and the first painted
frame is already recency-sorted. Key the tiles and add findChildIndexCallback
so a later re-sort from a refreshed profile source moves a tile instead of
destroying it: without the lookup the sliver re-inflates the tile, which keeps
primary focus but resets FocusableWrapper's chrome to unfocused.

close #1792
2026-08-05 06:09:26 +02:00
github-actions[bot] d7c4ea0a8b chore: update cask to 2.12.1 2026-08-05 03:57:31 +00:00
Jack DangerandClaude Opus 5 dcc06768e0 fix(profiles): label a Plex Home parent connection as an account
A Plex Home profile's chip rendered the parent connection's displayLabel,
which for a Plex account is the account owner's username. The owner's name
appeared directly beneath the Home user's own, reading as the wrong user
being signed in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF
2026-08-04 10:11:38 -07:00
github-actions[bot] ddc3a4d77c chore: bump version to 2.12.1 2026-08-04 14:59:38 +00:00
edde746 58d5d3c4ef fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series
agents. A library still on a legacy agent answers with the scalar `guid`
alone, so `fetchExternalIds` returned nothing for it and every consumer
went quiet: trackers logged "no external IDs" and skipped the write,
manual ratings showed "Not available", the detail screen dropped its
watchlist button, and Continue Watching stopped collapsing duplicate
copies. The reverse lookup already read that scalar; only the forward
path ignored it.

Read both shapes from the one request the method already makes, with the
array winning per field and the scalar filling the rest.

HAMA identifies anime by AniDB id and nothing else, which no id set could
carry. AniDB is the Fribb mapping's own primary key, so it now travels on
`ExternalIds` and indexes those rows directly — 7177 of them expose no
tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain
`anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one
TVDB-numbered show, so the guid names the root entry only.

Two guards keep the new id where it means something. It is trusted for
season 1, because that mode puts the anime there and its specials in
season 0, while a higher season means the library is numbered by TVDB
instead. And it resolves nothing for Trakt and Simkl, which never map
anime and cannot address an AniDB id, so they keep reporting no ids
rather than failing silently further down. `hasCatalogIds` marks the
callers that can only speak IMDb/TMDB/TVDB.

close #1788
2026-08-04 16:56:54 +02:00
edde746 4872adcde3 feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
2026-08-04 16:10:02 +02:00
edde746 61ae314c94 fix(player): tell same-language subtitle rows apart across episodes
The committed track kept the server display title, which collapses to the
bare language ("English") and is identical for every same-language row: a
carried signs/songs choice tied with the full dialogue track on the next
episode and latched onto whichever row sorted first. The row's own title
is preferred now, so the carried intent names the exact row again and the
native pass can match the right container track by title instead of
language order.

Reproduced against a live library where both English ASS rows differ only
by Title ("Styled Subtitles" vs "Signs/OP/ED").

close #1785
2026-08-04 15:22:46 +02:00
github-actions[bot] d82a5c5cd7 chore: bump version to 2.12.0 2026-08-04 13:03:59 +00:00
edde746 fdd4c661fe fix(player): carry a picked subtitle language across episodes with sparse tags
The cross-item subtitle intent required declared languages on both sides,
and a null on either side counted as a contradiction. Any untagged track -
common when a title like "Swedish" is the only signal - declined on every
episode advance, fell to the server's per-item priority, and turned the
viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard
gates).

A unique real title match now vouches for a row when language evidence is
missing on either side. Declared languages that disagree still decline no
matter what the title says, forced-class parity is untouched, codec and
external parity only break ties within the title-matched set, and a
residual tie declines rather than guesses, so the wrong-track class of
#1716 stays closed.

A decline is also no longer laundered into a viewer decision: the resolver
keeps the unserved preference on the selection, the open flow hands it to
the track manager instead of a navigation-priority off (late native tracks
may carry the container tags the server rows lack), the next episode
boundary re-carries it instead of hardening it into an explicit off, and
progress reports withhold the -1 subtitle index that would otherwise come
back as the item's server-side default forever.

A pick the screen could not map to a source row (no subtitle catalog, or
an identity-matcher miss) previously never reached the committed session
selection at all, so the next episode carried the stale off while the
picked track was visibly on screen. Such picks now commit the raw native
track without source ids and demote to a semantic intent at the boundary.

close #1785
2026-08-04 13:51:20 +02:00
edde746 439ae1d733 perf(detail): paint a show before its on-deck episode is looked up
Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show
detail open chained `/Shows/NextUp` behind the item fetch and the screen sat
on a spinner for both round trips. The second one is not needed to paint:
everything except the play button's episode label comes from the item.

`fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as
soon as the item is known, when that is strictly before on-deck settles.
Plex returns both together and never invokes it.

Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal`
holds the foreground at opacity 0 until extras, related hubs, seasons and the
first episode page have all loaded, and those still run after the on-deck
lookup settles, so TV sees no change. Both halves are pinned by tests.

Measured on a remote Jellyfin server, 15 interleaved show-detail opens per
version: time to content 1264ms -> 1042ms (-18%), with the rest of the load
unchanged.

Seasons and extras deliberately still start after the whole lookup settles.
Starting them at the early paint measured worse (time to settled +21%)
because they contend with the on-deck request instead of overlapping it — the
same reason `/Shows/NextUp` is not fired in parallel with the item fetch.
That trade-off is also why TV was left alone rather than being unblocked by
moving those loads earlier.

Two ordering hazards the early paint introduces, both covered by
`media_detail_screen_test.dart`:

- The early call must not write on-deck. `_loadFullMetadata` runs again after
  playback, and clearing there would blank the play button for the length of
  the round trip. `onDeckSettled` marks the authoritative write, so a reload
  that finds the series finished still clears it.
- A settled empty on-deck must not drop the episode-derived fallback that
  `_ensureFallbackOnDeckEpisode` supplies.

close #1784
2026-08-04 08:38:19 +02:00
edde746 a759e8b3c6 perf(jellyfin): fetch a detail item once when several callers want it at once
Opening a detail screen issued two identical full-detail GETs for the same
id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and
`_initWatchlistState` calls `fetchExternalIds`, which fetches the same item
purely to read `ProviderIds`. Playback start adds three more for its own id.

Each of those makes the server rebuild the entire dto — `People`, `Chapters`
and `MediaSources` cost a database query apiece and `Trickplay` costs several
plus a filesystem stat — so the duplicate is expensive on both ends.

`fetchItem` now shares an in-flight request per item id. Single-flight only:
once a request settles the next caller re-fetches, so nothing can serve a
stale item.

Measured on a remote Jellyfin server, 12 interleaved show-detail opens per
version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is
unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first
rather than behind it; this removes duplicated work, not latency.

Two things were tried and rejected because measurement did not support them:
starting `/Shows/NextUp` in parallel with the detail fetch (the requests
contend rather than overlap — NextUp went from 380ms alone to 1395ms beside
it — and it costs a wasted request per movie), and dropping `Trickplay` /
`Chapters` from the detail field set (no measurable effect; both are real
data the playback path reads).

Refs #1784
2026-08-04 06:39:23 +02:00
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.

Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:

  requests  19 -> 9      payload  219 KB -> 94 KB
  settled   5231ms -> 2502ms median, 13222ms -> 5927ms p95

Four independent causes:

- Retry policy. `Client.send` resolves on response headers, so the
  connect budget covers the server's think time and a slow-but-alive
  query raises `connectionTimeout`. Replaying it made the server re-run
  the query with a shorter budget than the one it just missed; the
  `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
  23s. Hub surfaces now get one whole-request deadline, retry only
  immediate connection errors, and the deadline bounds the whole call
  including the request still in flight.

- Request shape. `/Items/Latest` groups a TV library by series, so its
  rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
  a DB count each, per row. Hub rows now ask for `Overview` only; watch
  state survives because Jellyfin derives `UserData.Played` from
  `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
  sends `NextUpDateCutoff` to bound the server's series-key scan, and
  `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
  and `PremiereDate` leave the browse set: neither is an `ItemFields`
  member, so the server dropped them anyway.

- Fan-out. Per-library hubs ran in batches of three separated by a
  barrier, so one slow library stalled every library behind it. A
  sliding window keeps the same peak concurrency without head-of-line
  blocking. Concurrent `fetchLibraries` calls now share one `/Views`
  instead of racing two identical round trips, Plex's global and music
  hub legs start together, and Jellyfin gets Plex's pool tuning.

- Duplicate pass. `DiscoverScreen.initState` starts a load and the
  online-entry hook asked for a full refresh on top of it, which
  `CoalescedLoadCoordinator` correctly queued as a trailing pass. The
  hook now calls `primeRefresh`, which rides along with a load already
  in flight; profile switches still go through `fullRefresh`.

Refs #1784
2026-08-04 04:35:06 +02:00
edde746 8624c37041 fix(profiles): notice a corrected connection creation time
ActiveProfileProvider diffed connections on toConfigJson alone, but
createdAt is a real column and now decides which connection lends a
profile its picture. A creation-time correction was therefore invisible
to the guard and left a stale avatar until the next launch.

Compare createdAt alongside the config. ConnectionRegistry pins
creation order across re-authentication, so this adds no notifications
in normal operation — it only stops an out-of-band correction, such as
a restore or a backfill, from being swallowed.
2026-08-04 02:22:44 +02:00
edde746 7a19f4149e fix(connections): keep a connection's creation time across re-authentication
ConnectionRegistry.upsert already preserved isDefault on conflict but
rewrote created_at from the in-memory model. Re-signing in rebuilds the
connection with DateTime.now() under the same stable id, so the row's
creation time jumped forward on every reauth.

That was cosmetic while created_at only drove list ordering. It is now
behaviour: it picks which connection lends a profile its picture, so
re-adding the originally-first connection could hand the avatar to a
later one. remove() also promotes the oldest remaining row to default
and was reading the same restamped value.

Preserve the existing row's created_at on conflict, reusing the lookup
upsert already performs for isDefault.
2026-08-04 02:22:44 +02:00
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
2026-08-04 02:22:44 +02:00
edde746 2b4875d389 fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so
a way of turning subtitles off that the screen never sees is undone by the
next episode.

ExoPlayer has no renderer-level visibility switch, so the player's hide
toggle is emulated by deselecting the track. That emulation lasted until the
next selection: the automatic pass after an episode change put subtitles
straight back on screen while the toggle still read "hidden", and un-hiding
then restored a track id belonging to the episode that had already ended.
Hiding is now sticky across media opens the way mpv's global sub-visibility
is, selections made while hidden become what un-hiding restores, and the
toggle no longer refuses to restore because the hidden track reads as Off.

Cycling subtitles over the native track list — downloads, and items whose
server exposes no subtitle rows — went straight to the track manager, which
owns the player selection and the server write-back but not the committed
choice. The screen records the cycled track now.
2026-08-03 17:07:14 +02:00
edde746 e7aa1e4782 fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the
user's SubtitleMode is None: the index is the server's whole answer, and null
means it picked no subtitle. The mapper read null as "the server did not say"
and promoted the container's default/forced flags to a server selection
instead, which outranks the profile subtitle mode in the selection ladder. A
viewer who had turned subtitles off for their Jellyfin user got them switched
back on by every item that carried a default or forced row.

Only the row the server names is selected now. A stream the viewer picks, and
an explicit off, still survive per item because Plezy reports the index
through playback progress and the server hands it back as that index or -1.

close #1779
2026-08-03 17:07:05 +02:00
edde746 bbaf5f0f9e fix(music): replace the Instant Mix faders with a wand icon
The three vertical faders read as an equalizer in a music context and
are the vertical twin of the video player's settings icon. Use
wand_stars, which names what the action produces and collides with no
neighbouring affordance in the action bar or the music context menu.

close #1629
2026-08-03 13:29:27 +02:00
edde746 0f5e5c8b6e feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a
track's path, container, and audio stream detail were unreachable even
though both backends already answer getFileInfo for them.

Gate the entry on the new MediaKind.hasFileInfo instead of a literal
kind list: movies, episodes, tracks, and clips are leaf items with real
files, while shows, seasons, artists, albums, collections, playlists,
and folders carry no Media/MediaSources and would only ever produce the
"not available" snackbar.

Also fix the Plex stream classifier, which mapped streamType 4 to an
embedded image although PlexStreamType.lyrics is 4. Only music tracks
carry that type, so a track's lyric stream rendered under "Embedded
Images" with the video field block. Type 5 was invented outright and is
now unknown.

close #1747
2026-08-03 02:34:37 +02:00
edde746 a9f0532f5f fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is
36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16
disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only
SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on
API 35+ the window is edge-to-edge from the first frame and
MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation.

MainScreen's phone layout hides that. It supplies a bottomNavigationBar and
never sets extendBody, so Flutter's Scaffold strips padding.bottom from the
body MediaQuery and every tab is already safe. Routes pushed on the profile
navigator are full-screen siblings of MainScreen with no bottom bar, so they
receive the untouched inset and nothing consumes it - the last settings card
and the final log lines render under the back, home, and recents buttons.

Three shared hosts own most of those routes, so the inset is consumed there:
FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and
FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing
SliverSystemBottomInset, and the four screens that build their own Scaffold
around a CustomScrollView append it directly.

The new widget codifies the convention this repository had already written
down but open-coded - insets baked into the scroll content rather than a
SafeArea around the scroll view - so content still paints under the bar while
the scroll extent grows enough to bring the last row above it. It reads
padding from its own context and collapses to zero height wherever the inset
is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside
MainScreen's tab bodies. No platform branching, and it stacks additively with
the music detail screens' existing mini-player spacers, which is correct
because the mini-player itself floats above the navigation bar on a pushed
route.

Scroll views that are not sliver lists take the inset in their own padding:
the companion remote's ListView, the auth screen's scroll container, and the
two SliverFillRemaining sign-in forms, whose children size themselves from
the extent remaining before them and so cannot be helped by a trailing
sliver. The logs empty state is left alone for the same reason inverted - it
already fills the viewport, and a trailing inset would only add scroll slack.

Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation:
Settings, Logs, and Video Playback all end clear of the bar.

close #1766
2026-08-03 00:05:21 +02:00
edde746 fb0613e3db feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and
covers the subtitle line they were trying to finish reading. A two-finger tap
now toggles playback with the chrome left down, so the frame that pauses is the
frame that was on screen. It fires the moment the chord resolves, in every
player state.

The two-finger double tap no longer resets the video zoom. Keeping it would mean
holding this toggle back for the double-tap window before acting, and pausing
late is pausing on the wrong frame. Zoom reset stays in the video settings sheet,
its presets and the keyboard shortcut, and pinching back to 100% now snaps
exactly within three percent so touch has a one-gesture path too.

Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert
under screen lock, in PiP and while the content strip is open; the zoom reset
previously fired straight through a locked screen.

close #1505
2026-08-03 00:04:03 +02:00
edde746 9c08c78f6d fix(plex): stop recording a second play when the server already logged one
Plezy reported a completed playback twice: the /:/timeline heartbeats let
the server mark the item played on its own, and the in-player auto-scrobble
then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds
a second Play History row; on 1.43 the row is suppressed but viewCount still
lands on 2 for one playback.

Measured against PMS 1.43 to find what the server acts on: a watched-threshold
crossing observed inside one session. Consecutive above-threshold reports mark
nothing, a resume point left by an earlier session does not arm a new one, and
a report at position zero is inert while one at a single second is enough. So
the explicit mark now goes out only for sessions that gave the server no
crossing to observe.

That decision cannot be made while the session is live. A session beginning
past the threshold has no crossing yet, but rewinding and playing forward
creates one, and the server records it — marking eagerly and then hitting that
path leaves viewCount at 2 again. The mark is therefore deferred to the
terminal stop, and rides its future so callers that await the stop before
tearing the player down do not drop it. Deferring also covers a crossing
coalesced away during startup and a seek back below the threshold before
stopping.

Crossing state is tracked from reports the backend actually received rather
than from PlaybackReportSession.report()'s bool, which resolves true for a
same-state snapshot dropped during startup.

The same-file sibling hook (#1500) still runs exactly once, on the transition
to a settled mark rather than at the local crossing, so sibling episodes are
never marked watched while the episode actually played is not.

Local watched state and Continue Watching removal still happen on the observed
crossing, so the only behaviour that moves is the redundant server call.

close #1740
2026-08-02 16:16:19 +02:00
edde746 95b013e155 fix(seerr): show worldwide popular titles in Explore again
Overseerr and Jellyseerr bind the `language` query parameter of
`/discover/movies` and `/discover/tv` to `originalLanguage`, which becomes
TMDB's `with_original_language`. Sending the app locale there collapsed both
shelves to titles originally made in that language, so a Portuguese UI saw
only Portuguese films. Those two routes take their display language from the
instance/user locale, which already wins over the query value, so the
parameter was pure filtering with no localization to show for it.

Drop it from the two paged discover routes. Trending, both upcoming rows,
search, details and recommendations keep it: Seerr treats it as the display
language everywhere else.

close #1763
2026-08-02 15:35:42 +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 bbed260169 fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.

The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.

Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.

Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.

Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.

The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.

close #1765
2026-08-02 11:45:27 +02:00
edde746 35061f9f68 fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded.

Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state.

close #1770
2026-08-02 09:29:59 +02:00
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00
edde746 bac2a0d201 fix(player): keep Delete and Home editing text in player sheets
Bare Backspace and Home are player navigation keys, but they are also
caret editing keys. The player screen's Focus wraps its OverlaySheetHost,
so it saw them before the subtitle-search field could act: the press was
consumed on key-down, DefaultTextEditingShortcuts never turned it into a
deletion, and the back pipeline hid the chrome and then left the player.

A focused text editor now takes both keys back, but only for physical
keyboard presses — a synthesized dpad/gamepad press has no caret, and
browserHome has no editing role at all.

The screen also resolved its overlay-sheet controller from the State's
own context, which sits above the host it was querying, so the lookup
always returned null and Back skipped the sheet stage entirely. Resolve
it from a context below the host instead, matching NowPlayingScreen.

close #1741
2026-08-02 07:37:12 +02:00
edde746 2a7e5f4f9c fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library
delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and
the per-library grant, and only the first user a server creates gets the
former for free. Gating the "Delete from server" entry on the admin bit
therefore offered a destructive action that answers 401 to later
administrators, and hid it from plain users who do hold the grant.

Ask the server per item instead, through the new
MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already
folds the global grant, the per-library grant, and item state such as
missing files or an in-progress recording. The probe runs when a menu
opens on a deletable kind, costs ~0.5 KB, carries a whole-request
deadline because the client's own budget covers connect and receive
separately, and fails closed on anything unknown. Plex keeps its
account-level owner/admin gate; it has no per-item permission on the
wire.

close #1749
2026-08-02 07:08:14 +02:00
edde746 bc0d14a749 fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
2026-08-02 06:40:04 +02:00
edde746 f78f65faf5 chore(player): report marker counts when loading playback extras
The extras loader logged only the chapter count, so a user report of
"auto skip never fires" could not be told apart from "the server has no
intro marker for this item" — the two need opposite fixes. Log the
marker count and types on all three load paths, including the cache-only
one that previously logged nothing at all.

Drop PlexVideoPlaybackData.markers while here: the playback-start parse
filled it on every item and no caller ever read it, because the player
controls fetch their own PlaybackExtras.

Document why getPlaybackExtras may serve the shared metadata cache row
without a freshness check: getPlaybackInitialization refreshes that row
network-first before the controls mount. That ordering is what makes
cache-first correct, and nothing said so.
2026-08-02 04:55:54 +02:00
edde746 957711a650 fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.

Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.

The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.

`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.

close #1732
2026-08-02 04:37:29 +02:00
edde746 2cb2c3eb95 feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.

The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.

Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.

Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.

Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.

The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.

Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.

close #1755
2026-08-02 03:59:56 +02:00
1055 changed files with 108139 additions and 27016 deletions
+2 -2
View File
@@ -3,5 +3,5 @@
!.maestro/jellyfin-demo/
!.maestro/jellyfin-demo/**
!scripts/
!scripts/maestro_fixtures.py
!scripts/maestro_real_jellyfin.py
!scripts/maestro/maestro_fixtures.py
!scripts/maestro/maestro_real_jellyfin.py
+53 -21
View File
@@ -36,7 +36,7 @@ env:
libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev
libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev
libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev
libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
libdisplay-info-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
jobs:
@@ -271,13 +271,11 @@ jobs:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate to keychain
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
@@ -494,6 +492,11 @@ jobs:
"data/app.so"
"data/icudtl.dat"
"data/flutter_assets/NativeAssetsManifest.json"
# MSVC runtime bundled by InstallRequiredSystemLibraries in
# windows/CMakeLists.txt; without it the app crashes at startup on
# machines whose system-wide redist is older than the CI toolset.
"msvcp140.dll"
"vcruntime140.dll"
)
$forbiddenFiles = @(
"plezy.lib"
@@ -765,14 +768,17 @@ jobs:
fi
echo "BUNDLE_DIR=$bundle_dir" >> "$GITHUB_ENV"
- name: Build Linux Packages
shell: bash
run: |
BUILD_DIR="$BUNDLE_DIR" \
ARCH_SUFFIX=${{ matrix.arch }} \
OUTPUT_DIR="$GITHUB_WORKSPACE" \
python3 linux/packaging/build-packages.py
# Everything below resolves the bundle *before* packaging, so the packages
# and the tarball are cut from one identical tree.
#
# The plane needs the pinned Wayland-enabled libmpv: a distro libmpv still
# plays and still does HDR, but silently drops hwdec to vaapi-copy - which
# was measured, not assumed. So libmpv travels with us and no artifact
# depends on a host one. That removed the host `mpv` dependency, and with it
# the transitive pull of everything libmpv itself needs - libass, pulse,
# pipewire, fontconfig and the rest. bundle-libs.sh is what supplies those,
# so it has to run before packaging too, or the packages would carry libmpv
# and nothing it links.
- name: Copy libmpv into bundle
shell: bash
run: |
@@ -781,7 +787,7 @@ jobs:
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries for portable tarball
- name: Bundle shared libraries
shell: bash
run: bash linux/packaging/bundle-libs.sh "$BUNDLE_DIR"
@@ -789,17 +795,43 @@ jobs:
shell: bash
run: cp linux/packaging/plezy.sh "$BUNDLE_DIR/plezy.sh"
- name: Verify no missing dependencies
# Same check the package smoke build runs, against the artifact that
# actually ships: a library nobody declares is a broken install, and it is
# invisible until a user on a clean machine tries to launch.
#
# This also stands in for the `ldd ./plezy | grep "not found"` step that
# used to run after packaging. That one folded ldd's stderr into grep's
# input and dropped its exit status, so ldd failing outright - a missing
# loader, an exec-format mismatch, no ldd at all - left the match empty and
# printed "All dependencies resolved." This guard runs ldd over every
# object under lib/ as well as the executable, fails on an unresolved
# soname, fails when ldd cannot read an object, and refuses to pass when
# the walk found no host libraries at all. Packaging below only reads the
# bundle, so a second shell ldd afterwards could only restate a weaker
# subset of what this already proved about the very same tree.
- name: Verify every unbundled library the bundle needs is declared
shell: bash
run: python3 linux/packaging/check-bundle-host-deps.py "$BUNDLE_DIR"
# Last, from the fully resolved tree above. The host-dependency guard is
# skipped because the named step above just ran it against this same
# bundle; the internal run exists for by-hand packaging outside CI.
- name: Build Linux Packages
shell: bash
run: |
cd "$BUNDLE_DIR"
MISSING=$(LD_LIBRARY_PATH=lib ldd ./plezy 2>&1 | grep "not found" || true)
if [[ -n "$MISSING" ]]; then
echo "ERROR: Unresolved dependencies found:" >&2
echo "$MISSING" >&2
exit 1
fi
echo "All dependencies resolved."
BUILD_DIR="$BUNDLE_DIR" \
ARCH_SUFFIX=${{ matrix.arch }} \
OUTPUT_DIR="$GITHUB_WORKSPACE" \
PLEZY_SKIP_HOST_DEP_CHECK=1 \
python3 linux/packaging/build-packages.py
# The depends lists reached fpm above; only the packages it wrote can show
# they arrived. Same script the smoke build runs, so the two jobs cannot
# drift on what counts as declared - and unlike the smoke build, this one
# covers arm64 and the artifacts users actually install.
- name: Verify the declared dependencies reached the package metadata
shell: bash
run: python3 linux/packaging/check-package-deps.py "$GITHUB_WORKSPACE" --arch ${{ matrix.arch }}
- name: Create tarball
shell: bash
+174 -6
View File
@@ -9,6 +9,14 @@ on:
branches:
- main
workflow_dispatch:
inputs:
build_linux_packages:
description: >
Smoke-build the Linux deb/rpm/pacman packages. Off by default: it needs
a release build plus fpm, and build.yml only packages from main, so this
is the only way to exercise linux/packaging from a branch.
default: false
type: boolean
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
@@ -54,7 +62,7 @@ jobs:
run: scripts/codegen.sh --check
- name: Verify translation hygiene
run: python3 scripts/clean_translations.py --check --strict
run: python3 scripts/checks/clean_translations.py --check --strict
- name: Verify workflow and script guards
run: bash scripts/ci_guard_checks.sh
@@ -67,10 +75,10 @@ jobs:
xargs -0 -r dart format --output=none --set-exit-if-changed
- name: Verify icon consistency
run: dart run scripts/check_icon_consistency.dart
run: dart run scripts/checks/check_icon_consistency.dart
- name: Analyze code
run: dart run scripts/check_analyzer.dart
run: dart run scripts/checks/check_analyzer.dart
- name: Check for unused code
run: |
@@ -244,7 +252,7 @@ jobs:
- name: Verify native formatting
run: scripts/format_native.sh --check
- name: Verify Linux native acquisition integrity
- name: Verify Linux native acquisition and build plan
run: bash linux/packaging/build-libmpv_test.sh
linux-native-test:
@@ -279,7 +287,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
libwayland-dev libegl-dev
- name: Prepare Flutter Linux configuration
run: |
@@ -295,12 +304,20 @@ jobs:
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
# `plezy` is the runner itself. Without it nothing in CI ever compiles
# my_application.cc, mpv_plugin.cc or the Wayland video plane — the
# reliability test targets each pull in only a couple of translation units,
# so a break in the rest of linux/runner reached a release build unseen.
- name: Build Linux native reliability tests
run: |
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
plezy \
mpv_player_lifecycle_test \
mpv_player_hdr_output_test \
mpv_property_result_contract_test \
mpv_gpu_bootstrap_test
hdr_metadata_test \
plane_geometry_test \
video_params_test
- name: Run Linux native reliability tests
run: |
@@ -592,3 +609,154 @@ jobs:
- name: Run website checks
run: scripts/ci_website_checks.sh
# The only job that checks packaging against a real artifact. build.yml also
# packages Linux, but refuses any ref but refs/heads/main, and
# check_linux_package_deps.py can only compare a hand-written list against
# CMake - it cannot prove the list reaches the artifact, nor see the
# transitive libraries the bundled libmpv drags in. This can, by reading the
# built bundle back with ldd.
#
# Runs on every push to main and on request, but not on pull requests: it
# needs a release build plus fpm, which is minutes of runner time that most
# changes here have no reason to pay. That buys post-merge detection rather
# than pre-merge, which is the deliberate trade. Dispatch it from a branch
# with build_linux_packages when touching linux/packaging - which is the only
# way to exercise it before merging.
linux-packages:
name: Linux package smoke build
if: ${{ inputs.build_linux_packages || github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
# The packaging deps, minus libmpv: this job builds it from source below,
# because the distro's is a different version with different windowing
# backends, and a package smoke-built against it cannot show that
# build-libmpv.sh still works or that the bundle it produces is coherent.
- name: Install packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev liblzma-dev \
libstdc++-12-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev \
libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev \
libharfbuzz-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev \
libva-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev \
libdisplay-info-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev \
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
sudo gem install fpm --version 1.17.0 --no-document
# Keyed the same way build.yml keys it, so editing the script or its pinned
# inputs is what invalidates the cache - and this branch's whole point is
# that those edits get exercised somewhere.
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
run: bash linux/packaging/build-libmpv.sh
# The windowing backends the runner depends on, read off the library the
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
# in software; X11 and VDPAU are gone with the texture path.
- name: Check the built libmpv's backends
run: |
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
echo "== $LIB =="
ldd "$LIB" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
ldd "$LIB" | grep -q libwayland-client || {
echo "::error::the built libmpv does not link libwayland-client, so the video plane cannot work"
exit 1
}
- name: Build the release bundle
run: |
flutter pub get --enforce-lockfile --no-example
flutter build linux --release
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
# Mirrors build.yml: resolve the bundle completely, then package from it.
# libmpv travels with us, so nothing depends on a host one - which also
# removes the transitive pull of everything libmpv links, hence bundle-libs
# before packaging rather than after.
- name: Copy libmpv into the bundle
run: |
BUNDLE_LIB=build/linux/x64/release/bundle/lib
LIBMPV_DIR=$(dirname "$(find libmpv-prefix -name 'libmpv.so' | head -1)")
cp -a "$LIBMPV_DIR"/libmpv.so* "$BUNDLE_LIB/"
cp -a libmpv-prefix/lib/libshaderc_shared.so* "$BUNDLE_LIB/"
- name: Bundle shared libraries
run: bash linux/packaging/bundle-libs.sh build/linux/x64/release/bundle
- name: Copy wrapper script into the bundle
run: cp linux/packaging/plezy.sh build/linux/x64/release/bundle/plezy.sh
# Derives what the resolved bundle still needs from the host and proves
# every one of those libraries is declared. This is the check that would
# have caught bundling libmpv without also declaring what libmpv links.
- name: Verify every unbundled library the bundle needs is declared
run: python3 linux/packaging/check-bundle-host-deps.py build/linux/x64/release/bundle
# OUTPUT_DIR defaults to the repo root; name it so the paths below are not
# a guess about where fpm dropped things. The host-dependency guard is
# skipped because the named step above just ran it against this same
# bundle; the internal run exists for by-hand packaging outside CI.
- name: Build the packages
run: |
mkdir -p "$OUTPUT_DIR"
python3 linux/packaging/build-packages.py
env:
OUTPUT_DIR: ${{ github.workspace }}/packages
PLEZY_SKIP_HOST_DEP_CHECK: "1"
# The guard above reconciles the depends lists with the staged bundle; only
# the packages themselves can show that list survived fpm. Every name comes
# from build-packages.py, so adding a library there is verified here without
# a second edit - and this job is off by default, so a hand-copied list
# would rot unseen. The release job in build.yml runs the same script
# against the artifacts users install, so the assertions cannot drift.
- name: Verify the declared dependencies reached the package metadata
run: python3 linux/packaging/check-package-deps.py "${{ github.workspace }}/packages"
# Same resolved bundle the packages were cut from, so the tarball is not a
# second, differently-assembled artifact. It is the one to put on a USB for
# a foreign machine, because it needs nothing installed.
- name: Create the tarball
run: |
BUNDLE_DIR=build/linux/x64/release/bundle
mkdir -p "$OUTPUT_DIR"
tar -czf "$OUTPUT_DIR/plezy-linux-x64.tar.gz" -C "$BUNDLE_DIR" .
echo "=== libmpv travelling in every artifact ==="
ldd "$BUNDLE_DIR/lib/libmpv.so" | grep -iE 'wayland|libX11|vdpau' || echo '(none)'
env:
OUTPUT_DIR: ${{ github.workspace }}/packages
- name: Upload the packages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-packages-smoke
path: ${{ github.workspace }}/packages/plezy-linux-x64.*
if-no-files-found: error
retention-days: 7
-324
View File
@@ -1,324 +0,0 @@
name: E2E - Maestro
on:
push:
branches:
- main
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: maestro-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
android-maestro:
name: Android Maestro suites
runs-on: ubuntu-latest
timeout-minutes: 150
permissions:
contents: read
env:
FLUTTER_VERSION: "3.44.0"
MAESTRO_VERSION: "2.6.1"
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
MAESTRO_CLI_NO_ANALYTICS: "1"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: "21"
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Cache Pub dependencies
id: pub-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Cache Gradle
id: gradle-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-e2e-
${{ runner.os }}-gradle-
- name: Install dependencies
run: flutter pub get
- name: Build debug APK
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
- name: Cache Maestro CLI
id: maestro-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.maestro/bin
~/.maestro/lib
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
- name: Install verified Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
archive="$RUNNER_TEMP/maestro.zip"
staging="$RUNNER_TEMP/maestro-install"
curl --fail --location --retry 3 \
--output "$archive" \
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
mkdir -p "$staging" "$HOME/.maestro"
unzip -q "$archive" -d "$staging"
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
- name: Add Maestro to PATH
run: |
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
"$HOME/.maestro/bin/maestro" --version
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build deterministic Jellyfin image for pull request
if: github.event_name == 'pull_request'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: .maestro/jellyfin-demo/Dockerfile
load: true
tags: plezy-jellyfin-demo:local
cache-from: type=gha,scope=maestro-jellyfin
- name: Build and cache deterministic Jellyfin image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: .maestro/jellyfin-demo/Dockerfile
load: true
tags: plezy-jellyfin-demo:local
cache-from: type=gha,scope=maestro-jellyfin
cache-to: type=gha,mode=max,scope=maestro-jellyfin
- name: Cache Android 15 AVD
id: api35-avd-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
- name: Generate Android 15 AVD snapshot
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35
force-avd-creation: false
disable-animations: false
emulator-options: >-
-no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: echo "Generated Android 15 AVD snapshot."
- name: Cache Android 9 AVD
id: api28-avd-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
- name: Generate Android 9 AVD snapshot
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 28
target: google_apis_playstore
arch: x86
profile: pixel_2
avd-name: maestro-api28
force-avd-creation: false
disable-animations: false
emulator-options: >-
-no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: echo "Generated Android 9 AVD snapshot."
- name: Run Android 15 instrumentation
id: api35-instrumentation
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35-instrumentation
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
- name: Run Android R8 reachability
id: api35-r8
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35-instrumentation
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-r8-reachability
- name: Run Android 15 suites
id: api35
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
MAESTRO_SKIP_BUILD: "1"
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
MAESTRO_DEVICE_ID: emulator-5554
with:
api-level: 35
arch: x86_64
profile: pixel_6
avd-name: maestro-api35
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-15
- name: Run Android 9 legacy playback
id: api28
continue-on-error: true
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
MAESTRO_SKIP_BUILD: "1"
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
MAESTRO_DEVICE_ID: emulator-5554
with:
api-level: 28
target: google_apis_playstore
arch: x86
profile: pixel_2
avd-name: maestro-api28
force-avd-creation: false
disable-animations: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
-no-boot-anim -camera-back none
script: python3 scripts/run_maestro_ci.py android-9
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
- name: Save Pub dependency cache
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.pub-cache
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
- name: Save Gradle cache
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
- name: Save Maestro CLI cache
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.maestro/bin
~/.maestro/lib
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
- name: Save Android 15 AVD cache
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
- name: Save Android 9 AVD cache
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
- name: Upload Maestro diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: maestro-android-diagnostics
path: |
build/maestro
build/maestro-offline
build/maestro-real-jellyfin
build/maestro-media
build/maestro-tv
build/maestro-recovery
build/maestro-legacy
~/.maestro/tests
if-no-files-found: ignore
retention-days: 7
- name: Report suite failures
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
run: |
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}"
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
exit 1
+1 -3
View File
@@ -18,9 +18,7 @@ migrate_working_dir/
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
# Keep .vscode/ available for contributors' local configuration.
#.vscode/
# Flutter/Dart/Pub related
+1 -1
View File
@@ -10,7 +10,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/plezy-demo
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
COPY scripts/maestro/maestro_fixtures.py scripts/maestro/maestro_real_jellyfin.py scripts/maestro/prepare_maestro_media.py ./
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
@@ -22,8 +22,8 @@ tags:
- pressKey: "Remote Dpad Up"
- pressKey: "Remote Dpad Right"
- pressKey: "Remote Dpad Right"
# The grid row must be matchable before the sort sheet opens, otherwise the
# occlusion assertion below would pass for the wrong reason.
# Wait for the row before opening the sheet so occlusion is tested against the
# grid, not an absent or stale match.
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
@@ -31,9 +31,8 @@ tags:
- extendedWaitUntil:
visible: "Title"
timeout: 10000
# The host renders the grid, the barrier, and the sheet in one Stack and never
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
# an occluded row still read as visible to accessibility and to Maestro.
# The grid remains mounted under the Stack; BlockSemantics is required for
# occluded rows to disappear from Maestro's accessibility tree.
- assertNotVisible: "(?s).*Alpha Archive.*"
- pressKey: "Remote Dpad Center"
- pressKey: "Remote Dpad Left"
@@ -16,25 +16,24 @@ tags:
visible: "(?s)^(Play|Resume).*$"
commands:
- pressKey: "Remote Dpad Center"
# TV playback starts with chrome hidden (#1765), so wait for its loading label
# rather than treating the Pause button as readiness. A failed open reaches the
# transport assertions below.
- extendedWaitUntil:
visible: "Pause"
notVisible: "Overview"
timeout: 30000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
# Hardware transport keys drive playback without raising the chrome (#1676):
# the player answers with a centred disc (pause) or a side readout (seek), never the
# full chrome, so subtitles stay readable.
# Pause first — it also freezes the clip, so the seek assertions below cannot
# race the end of a short fixture item.
notVisible: "Loading video"
timeout: 30000
- assertNotVisible: "(?s)^(Play|Pause)$"
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
# the end of this short fixture.
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Paused"
timeout: 10000
- assertNotVisible: "(?s)^Play$"
# D-pad seeking reports through the skip badge, never the scrub bar, and
# consecutive presses in one direction stack into a running total.
# D-pad seeks use the cumulative skip badge, not the scrub bar.
- pressKey: "Remote Dpad Right"
- extendedWaitUntil:
visible: "(?s)^Seek forward 10 seconds$"
@@ -49,7 +48,7 @@ tags:
visible: "Playing"
timeout: 10000
- assertNotVisible: "(?s)^Pause$"
# Select stays the deliberate way to bring the chrome back.
# Select is the explicit way to restore the chrome.
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^(Play|Pause)$"
@@ -7,9 +7,21 @@ tags:
- playback
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- tapOn:
point: "6%,22%"
- tapOn: "Search"
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
# while percentage coordinates are not portable across TV sizes.
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
visible: "Search"
timeout: 15000
- repeat:
times: 12
while:
notVisible:
text: "Search"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
@@ -24,13 +36,19 @@ tags:
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "(?s)^Play S1E1$"
# The player starts with chrome hidden (#1765); wait for its loading label
# rather than using Pause as readiness.
- extendedWaitUntil:
visible: "Pause"
notVisible: "(?s)^Play S1E1$"
timeout: 30000
- extendedWaitUntil:
notVisible: "Loading video"
timeout: 30000
- pressKey: "Remote Media Fast Forward"
- pressKey: "Remote Media Fast Forward"
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
- extendedWaitUntil:
visible: "Next Episode"
visible: "(?s)^Cancel$"
timeout: 20000
- runFlow:
when:
@@ -42,7 +60,10 @@ tags:
platform: Android
commands:
- pressKey: "back"
- assertNotVisible: "Next Episode"
# Dismissal is animated, so give it a bounded wait rather than one instant look.
- extendedWaitUntil:
notVisible: "(?s)^Cancel$"
timeout: 10000
- assertNotVisible: "Overview"
- pressKey: "Remote Dpad Up"
- extendedWaitUntil:
@@ -8,10 +8,8 @@ tags:
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- tapOn: "(?s)^Libraries.*"
# Every seeded alphabet title carries the same dateadded, so which of them the
# "Recently Added" rail returns is not stable across scans. This flow only
# needs some playable movie, so accept any of them rather than pinning one and
# failing whenever the tie-break lands elsewhere.
# All seeded titles share dateadded, so accept any playable Recently Added movie
# instead of relying on an unstable tie-break.
- extendedWaitUntil:
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
timeout: 15000
@@ -7,20 +7,9 @@ tags:
- settings
- sheets
---
# Guards 8e1904dd and c48cbf70 together.
#
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
# answered the platform pop with a TV-only dedup marker that could already be
# spent, stranding the sheet open with no way out. Either defect fails the
# "Manage Libraries is still there after one Back" assertion below.
#
# The same commit blocks semantics behind the barrier, so the occlusion
# assertions pin that an open sheet actually hides the rows underneath.
# Pristine state on purpose. The TV regressions in the same CI group leave
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
# so it must not inherit whichever layout the previous flow left behind.
# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
# Start clean because preceding TV flows may leave Force TV mode enabled.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml
- assertVisible: "(?s)^Services.*"
@@ -29,10 +18,8 @@ tags:
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
# The barrier swallows every pointer event, so the rows behind it must leave
# the semantics tree too. Without BlockSemantics an occluded row still reads as
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
# excluded on purpose: it is also the sheet's own title.
# The barrier removes covered rows from semantics; the sheet title remains
# visible because it belongs to the sheet.
- assertNotVisible: "(?s)^Services.*"
- assertNotVisible: "(?s)^Video Playback.*"
- assertNotVisible: "(?s)^Appearance.*"
@@ -41,15 +28,13 @@ tags:
- waitForAnimationToEnd:
timeout: 3000
- assertNotVisible: "Maestro Movies"
# One Back closed only the sheet: Settings is still the current route.
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 10000
- assertVisible: "(?s)^Services.*"
- assertVisible: "(?s)^Video Playback.*"
- assertNotVisible: "Discover"
# Reopening proves the host did not strand the one-shot dedup marker: a second
# dismissal has to work exactly like the first.
# Reopening verifies the one-shot pop deduplication marker was not stranded.
- tapOn: "(?s)^Manage Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
@@ -7,20 +7,10 @@ tags:
- playback
- subtitles
---
# Guards 468d6804. When a source advertises tracks the native list has not
# produced yet, TrackManager arms an automatic selection pass with a 5s first
# attempt and a 25s deadline. A user pick made inside that window used to only
# persist the preference, leaving the pass armed, so the choice snapped back to
# the server/profile preferred track up to ~30s later. The pick now retires the
# pending pass, and the wait below is long enough to outlive both timers.
#
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
# streams, so both picks below are genuine changes away from the automatic one.
# Pristine state on purpose, for two reasons: the TV regressions in the same
# CI group leave "Force TV mode" enabled, and a remembered track selection
# from an earlier run would pre-select the very rows this flow picks, making
# every assertion below vacuous.
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
# selection pass, or the server preference can overwrite it up to 30s later.
# The codec fixture supplies non-default audio and subtitle choices, and clean
# state prevents remembered selections from making the assertions vacuous.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow:
file: ../subflows/open_codec_sample.yaml
@@ -7,16 +7,9 @@ tags:
- settings
- i18n
---
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
# locale whose JSON never reached the generator ships a picker entry that falls
# back to English. Selecting Turkish and reading root-level navigation labels
# proves the generated locale is wired end to end.
#
# This flow mutates a persisted pref, and the app language decides every
# selector after the tap, so it starts from a cleared install rather than
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
# hunting for English strings in a Turkish tree.
# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
# labels proves the generated locale is wired end to end. Start clean because
# the language preference persists and affects every later selector.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml
- tapOn: "(?s)^Appearance.*"
@@ -54,9 +47,7 @@ tags:
- assertVisible: "Keşfet"
- assertNotVisible: "(?s)^Libraries.*"
- assertNotVisible: "Discover"
# Walk back through the Turkish tree and restore English. The pref outlives a
# plain launchApp, so leaving the device in Turkish would break every later
# flow in the suite.
# Restore English before leaving the device; the locale preference outlives launchApp.
- repeat:
times: 3
while:
@@ -7,21 +7,11 @@ tags:
- tv
- settings
---
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
# settings navigation generally.
#
# It deliberately does NOT guard the 15b54e2e density change. That commit took
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
# the semantics tree either way, so this flow passes identically on both sides
# of it — measured, not assumed. A real guard would need a pixel `height`
# assertion, and Maestro has no DPR-relative form, so pinning one here would
# only hold for the box it was written on. Row geometry belongs to
# test/widgets/setting_tile_test.dart.
#
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
# mode on the first touch event and the TV number spinner only builds in
# keyboard mode, so a single tapOn anywhere above would silently swap the
# spinner for a plain text field.
# Guards ef310459 and the Apple-only gates touched by 6c14049e.
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
# stable across that change, and Maestro has no DPR-relative height assertion.
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
# the TV number spinner.
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
+4 -14
View File
@@ -1,20 +1,10 @@
appId: com.edde746.plezy
---
# Reach a signed-in Home without paying for onboarding that already happened.
#
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
# data and cold-starts straight back to Home, which measures ~16s.
#
# The guard keeps each flow runnable on its own: when no session is stored --
# a fresh install, or a run after the logout flow -- "Discover" is absent and
# this falls through to the real onboarding. Flows that must prove onboarding
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
# stored state (~16s versus ~59s for retyping credentials); fall back to full
# onboarding when no session exists so each flow remains runnable alone.
- launchApp
# Settle on a known screen first. Branching straight off `launchApp` races the
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
# every flow pays for a full re-onboarding it did not need.
# Wait for the splash to resolve before deciding whether onboarding is needed.
- extendedWaitUntil:
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
timeout: 30000
+2 -4
View File
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
- extendedWaitUntil:
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
timeout: 30000
# A miss on `tapOn ... optional: true` still runs Maestro's full element
# search before giving up, which measured 3s here and 7.8s for "Sign in"
# on every onboarding. These taps normally find nothing, so gate them on
# a single visibility check instead.
# Optional taps still pay Maestro's full search cost when they miss; guard them
# with one visibility check instead.
- runFlow:
when:
visible: "Wait"
@@ -1,12 +1,7 @@
appId: com.edde746.plezy
---
# Onboarding for a device that already reports itself as a TV.
#
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
# onboards through the handheld layout and then walks Settings to flip
# "Force TV mode". A real Android TV box takes the TV layout from the first
# frame, so that walk has no handheld chrome to tap and the toggle would only
# re-assert what PlatformDetector already reports.
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
# in TV layout and needs no Force TV mode walk.
- retry:
maxRetries: 1
commands:
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
- tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL}
- tapOn: "Find server"
# A TV-sized layout offers Quick Connect first because typing on a remote is
# slow. The code path needs a second device to approve, so fall back to the
# username form the phone flow already uses.
# Quick Connect is awkward on a remote and needs another device, so use the
# username form as fallback.
- extendedWaitUntil:
visible: "(?s)^(?:Username|Cancel)$"
timeout: 30000
+2 -4
View File
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
platform: Android
commands:
- hideKeyboard
# A card announces its watch state three ways, and the codec samples keep a
# resume position on the server once any earlier flow has played them, so the
# partial form has to be accepted for this subflow to be re-runnable against a
# fixture container that is not thrown away between suites.
# Codec cards retain watch state and resume position between flows, so accept
# any valid watch-state label to keep this subflow rerunnable.
- extendedWaitUntil:
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
timeout: 15000
+2 -6
View File
@@ -1,11 +1,7 @@
appId: com.edde746.plezy
---
# Reach Settings from a signed-in Home on a phone layout.
#
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
# issued while the previous route is still settling opens nothing. The guarded
# repeat re-opens the menu instead of failing the flow, matching the pattern
# already used by flows/07_profiles_settings.yaml.
# Reach phone Settings from signed-in Home. The profile menu is an overlay
# sheet; retry while the previous route settles.
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
+9 -10
View File
@@ -51,32 +51,31 @@ Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Androi
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
```bash
python3 scripts/run_maestro.py basic # Basic user flows
python3 scripts/run_maestro.py catalog # Catalog and music flows
python3 scripts/run_maestro.py media # Codec playback and track selection
python3 scripts/maestro/run_maestro.py basic # Basic user flows
python3 scripts/maestro/run_maestro.py catalog # Catalog and music flows
python3 scripts/maestro/run_maestro.py media # Codec playback and track selection
```
Run one flow with `--flow`:
```bash
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
python3 scripts/maestro/run_maestro.py basic --flow .maestro/flows/04_search.yaml
```
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
`.maestro/regression_flows/`. Automatic PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
`.maestro/regression_flows/`. Automatic groups are declared in `scripts/maestro/run_maestro_ci.py::GROUPS`. Every top-level
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
independent tests. A manual-only classification must state why the flow cannot run automatically and must not be
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
independent tests. A manual-only classification must state why the flow cannot run automatically.
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
manual target rather than an automatic group. Run them only against the pre-seeded Jellyfin fixture and a disposable
emulator, using the required opt-in:
```bash
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
python3 scripts/maestro/run_maestro_ci.py profile-regressions --disposable-emulator
```
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
@@ -103,7 +102,7 @@ Update a production image only through a reviewed change:
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
with the prior digest and pre-change backup, not by changing the image reference alone.
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
5. Run `python3 scripts/checks/check_container_image_pins.py`, `python3 scripts/checks/test_check_container_image_pins.py`, and
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
a digest is unavailable.
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.11.0"
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
version "2.14.0"
sha256 "35fcbc5bcad13691fda335f662af0e9e5ffd0d9d202ecae17705a25cda2f58a6"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+12 -11
View File
@@ -3,7 +3,7 @@
Plezy
</h1>
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
<p>
<a href="https://plezy.app">Website</a> ·
@@ -38,9 +38,9 @@ Package managers:
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
- [AUR](https://aur.archlinux.org/packages/plezy-bin) (Arch Linux) - Community maintained by [@jianglai](https://github.com/jianglai):
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
```bash
yay -S plezy-bin
sudo pacman -S plezy
```
- **WinGet** (Windows):
```bash
@@ -52,11 +52,11 @@ Package managers:
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
- Libraries, collections, and playlists — video and audio
- Discover hub — Continue Watching, Next Up, trending, and recommendations
- Cross-server search across every connected Plex and Jellyfin server
- Cross-server search across every connected Plex, Jellyfin, and Emby server
- Filtering, sorting, and alphabetical jump navigation
- Folder browsing and folder playback — home-video libraries open in folder view
- Resolution, HDR/Dolby Vision, and audio-format badges on cards and detail pages
- Favorites and unwatched library filters[^jf]
- Favorites and unwatched library filters[^mb]
- Extras — trailers, deleted scenes, behind-the-scenes
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
@@ -119,9 +119,9 @@ Package managers:
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Multiple servers at once — Plex and Jellyfin side by side
- Multiple servers at once — Plex, Jellyfin, and Emby side by side
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
- Jellyfin local-server discovery, multiple URLs per server, and Quick Connect sign-in
- Jellyfin and Emby local-server discovery and multiple URLs per server; Quick Connect sign-in[^jf]
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
- Customizable keyboard shortcuts[^desktop]
- Metadata and artwork editing
@@ -129,9 +129,10 @@ Package managers:
- Localized in English plus 21 translations
[^jf]: Jellyfin only.
[^mb]: Jellyfin and Emby only.
[^plex]: Plex only.
[^connect]: Requires connecting the service under Settings > Services.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. Dolby Vision on Android and Apple TV.
[^pass]: Desktop, Android TV, and Apple TV.
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
@@ -148,7 +149,7 @@ Package managers:
### Prerequisites
- Flutter SDK 3.44.0+
- A Plex account or Jellyfin server with user credentials
- A Plex account, or a Jellyfin or Emby server with user credentials
### Setup
@@ -189,7 +190,7 @@ scripts/setup_hooks.sh
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
```bash
python3 scripts/run_maestro.py basic
python3 scripts/maestro/run_maestro.py basic
```
## Contributing
@@ -203,5 +204,5 @@ Plezy is licensed under [GPL-3.0](LICENSE).
## Acknowledgments
- Built with [Flutter](https://flutter.dev)
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
+4 -5
View File
@@ -15,8 +15,7 @@ linter:
prefer_final_in_for_each: true
avoid_print: true
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
# on Linux. CI invokes the supported check-unused commands explicitly.
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
dart_code_linter:
rules-exclude:
- "test/**"
@@ -27,7 +26,7 @@ dart_code_linter:
- package:dart_code_linter/presets/recommended.yaml
rules:
# --- Flutter rules (on top of recommended) ---
# Flutter-specific rules.
- avoid-border-all
- avoid-shrink-wrap-in-lists
- avoid-expanded-as-spacer
@@ -37,7 +36,7 @@ dart_code_linter:
- prefer-define-hero-tag
- use-setstate-synchronously
# --- Additional useful Dart rules ---
# Additional Dart rules.
- avoid-cascade-after-if-null
- avoid-collection-methods-with-unrelated-types
- avoid-unnecessary-type-assertions
@@ -48,7 +47,7 @@ dart_code_linter:
- prefer-enums-by-name
- prefer-commenting-analyzer-ignores
# --- Disable noisy rules from recommended preset ---
# Disabled noisy rules.
- no-magic-number: false
- avoid-dynamic: false
- format-comment: false
+13 -3
View File
@@ -64,7 +64,7 @@ val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
val media3Version = "1.10.1"
val media3Version = "1.11.0"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
@@ -324,6 +324,11 @@ android {
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
// Android Automotive OS driver-distraction state (CarUxRestrictionsManager). This is a platform
// stub, not a shipped dependency: the classes exist only on AAOS images, so every use is guarded
// by FEATURE_AUTOMOTIVE and the manifest declares `uses-library android.car required=false`.
useLibrary("android.car")
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -331,8 +336,6 @@ android {
defaultConfig {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
@@ -513,6 +516,12 @@ dependencies {
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.1.0")
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
// removed ShelfRefreshWorker) that WorkManager persisted on updated
// devices. Same version background_downloader pins, so the merged
// classpath stays coherent.
implementation("androidx.work:work-runtime-ktx:2.11.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-decoder:$media3Version")
implementation("androidx.media3:media3-exoplayer:$media3Version")
@@ -532,6 +541,7 @@ dependencies {
// Real android.util.* implementations for tests exercising media3 classes
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("androidx.work:work-testing:2.11.0")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
}
@@ -0,0 +1,92 @@
package com.edde746.plezy.car
import android.content.pm.PackageManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Verifies the platform half of the driver-distraction gate on a real device.
*
* Everything here is invisible to the Dart unit tests: `android.car` is a compile-time stub, so a
* missing `useLibrary`, a wrong `uses-library` declaration, a car service that refuses to connect,
* or an unresolvable default display all compile fine and merely make [CarRestrictionsMonitor.start]
* return false — at which point Plezy silently falls back to lifecycle gating and parked background
* audio never works, with no crash to notice.
*
* On a non-automotive device the monitor must stay inert instead of throwing, which is the other
* half of the contract.
*/
@RunWith(AndroidJUnit4::class)
class CarRestrictionsMonitorTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val isCar = context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
@Test
fun reportsSupportOnlyOnACarAndNeverThrows() {
val monitor = CarRestrictionsMonitor(context)
val answered = CountDownLatch(1)
try {
val started = monitor.start { answered.countDown() }
if (isCar) {
assertTrue("android.car is present but the UX-restriction signal did not connect", started)
// The connect never blocks, so readiness arrives on the main thread while this test thread
// waits. A timeout here means the car service never handed over a verdict.
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
assertTrue("a verdict arrived without marking the monitor supported", monitor.supported)
} else {
assertFalse("a non-automotive device must not claim car restrictions", started)
assertFalse(monitor.supported)
}
} finally {
monitor.release()
}
assertFalse("release() must drop support so a stale verdict cannot leak", monitor.supported)
}
@Test
fun aParkedEmulatorReportsNoDistractionOptimization() {
if (!isCar) return
val monitor = CarRestrictionsMonitor(context)
val answered = CountDownLatch(1)
try {
assertTrue(monitor.start { answered.countDown() })
assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
// The suite runs on a parked vehicle (no VHAL driving injection), which is the state that
// must permit background audio. A restricted verdict here means the gate would keep music
// tied to the foreground exactly as before.
assertFalse(
"parked car reported that distraction optimization is required",
monitor.requiresDistractionOptimization
)
} finally {
monitor.release()
}
}
@Test
fun theServiceConnectionRouteUsedBelowAndroidElevenAlsoReportsTheVehicle() {
if (!isCar) return
// No Android 9 or 10 Automotive image is published, so the only way to exercise the path those
// head units take is to force it here: the deprecated API is present on every version.
val monitor = CarRestrictionsMonitor(context, forceLegacyConnect = true)
val answered = CountDownLatch(1)
try {
assertTrue("the ServiceConnection route failed to start", monitor.start { answered.countDown() })
assertTrue("car service never reported through the legacy route", answered.await(20, TimeUnit.SECONDS))
assertTrue(monitor.supported)
assertFalse(
"parked car reported that distraction optimization is required",
monitor.requiresDistractionOptimization
)
} finally {
monitor.release()
}
}
}
@@ -0,0 +1,299 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* The hardware half of the #1790 contract, and the half a JVM fake cannot reach.
*
* `DefaultAudioSink` charges a static, process-wide counter on every flush and only discharges it
* from `Listener::onReleased` — the same callback the player surfaces as
* [AnalyticsListener.onAudioTrackReleased]. Any lasting imbalance stops media3 escalating audio
* failures at all, which is how a failed `AudioTrack` turned into a permanent buffering hang.
*
* `onAudioTrackInitialized` fires once per acquisition and `onAudioTrackReleased` once per answered
* flush, so counting both against a real sink, across the reuse and eviction cycles the wrapper's
* cache actually creates, measures the invariant directly.
*/
@RunWith(AndroidJUnit4::class)
class AudioOutputReleaseAccountingTest {
private companion object {
const val SEEK_COUNT = 4
const val STATE_TIMEOUT_SECONDS = 20L
}
@Test
fun everyFlushIsAnsweredAcrossSeeksAndAConfigChange() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
// Different channel counts give the sink two different output configurations, so the second
// item cannot reuse the first one's parked AudioTrack and has to evict it.
val surround = copyFixture(context, "ffmpeg/surround_5_1.flac")
val stereo = copyFixture(context, "ffmpeg/stereo.flac")
val playbackThread = HandlerThread("plezy-release-accounting-test").apply { start() }
val handler = Handler(playbackThread.looper)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val initialized = AtomicInteger()
val released = AtomicInteger()
try {
handler.runAndWait {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
// Decoded PCM is the only output the cache parks, so it is the path whose reuse and
// eviction the accounting has to survive.
shouldBlockDirectAudioOutput = { format ->
format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
}
}
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
playerReference.set(player)
player.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
initialized.incrementAndGet()
}
override fun onAudioTrackReleased(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
released.incrementAndGet()
}
override fun onPlayerErrorChanged(
eventTime: AnalyticsListener.EventTime,
error: androidx.media3.common.PlaybackException?
) {
if (error != null) errorReference.set(error)
}
})
}
val player = playerReference.get()
play(handler, player, surround)
awaitReady(handler, player)
// Each seek flushes the sink: the output is parked and handed straight back, so every one of
// them has to settle its own flush or the counter drifts up for the rest of the process.
repeat(SEEK_COUNT) {
handler.runAndWait { player.seekTo(0) }
awaitReady(handler, player)
}
val afterSeeks = initialized.get() - released.get()
assertNull("playback failed before the config change", errorReference.get())
assertTrue("expected the seeks to rebuild the audio output", initialized.get() > 1)
assertEquals(
"after $SEEK_COUNT seeks only the live acquisition may be outstanding " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
afterSeeks
)
// Config change: the parked 5.1 track cannot serve stereo, so it is evicted for real.
play(handler, player, stereo)
awaitReady(handler, player)
assertNull("playback failed after the config change", errorReference.get())
assertEquals(
"an eviction must not leave a second acquisition outstanding " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
initialized.get() - released.get()
)
} finally {
handler.runAndWait { playerReference.get()?.release() }
playbackThread.quitSafely()
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
surround.delete()
stereo.delete()
}
}
/**
* The same invariant on a real bitstream route, which is the output the reporter's device was
* failing to build. Needs a live HDMI sink that advertises encoded surround, so it skips on a
* phone or a TV set to PCM rather than passing without having exercised anything.
*/
@Test
fun everyFlushIsAnsweredOnABitstreamRoute() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val movieAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val capabilities = AudioCapabilities.getCapabilities(context, movieAttributes, null)
val fixture = when {
capabilities.supportsEncoding(C.ENCODING_E_AC3) -> "ffmpeg/surround_5_1_eac3.mka"
capabilities.supportsEncoding(C.ENCODING_DTS) -> "ffmpeg/surround_5_1_dts.mka"
else -> null
}
assumeTrue("the current audio route does not advertise E-AC3 or DTS bitstream", fixture != null)
val media = copyFixture(context, fixture!!)
val playbackThread = HandlerThread("plezy-bitstream-accounting-test").apply { start() }
val handler = Handler(playbackThread.looper)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val initialized = AtomicInteger()
val released = AtomicInteger()
val sawEncodedOutput = AtomicReference(false)
try {
handler.runAndWait {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
shouldBlockDirectAudioOutput = { false }
}
val player = ExoPlayer.Builder(context, factory).setLooper(playbackThread.looper).build()
playerReference.set(player)
player.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
initialized.incrementAndGet()
if (!isPcmEncoding(audioTrackConfig.encoding)) sawEncodedOutput.set(true)
}
override fun onAudioTrackReleased(
eventTime: AnalyticsListener.EventTime,
audioTrackConfig: AudioSink.AudioTrackConfig
) {
released.incrementAndGet()
}
override fun onPlayerErrorChanged(
eventTime: AnalyticsListener.EventTime,
error: androidx.media3.common.PlaybackException?
) {
if (error != null) errorReference.set(error)
}
})
}
val player = playerReference.get()
play(handler, player, media)
awaitReady(handler, player)
repeat(SEEK_COUNT) {
handler.runAndWait { player.seekTo(0) }
awaitReady(handler, player)
}
assertNull("bitstream playback failed", errorReference.get())
assumeTrue(
"the route advertised encoded surround but the sink still decoded to PCM",
sawEncodedOutput.get()
)
assertEquals(
"a bitstream output must answer every flush too " +
"(initialized=${initialized.get()}, released=${released.get()})",
1,
initialized.get() - released.get()
)
} finally {
handler.runAndWait { playerReference.get()?.release() }
playbackThread.quitSafely()
playbackThread.join(TimeUnit.SECONDS.toMillis(5))
media.delete()
}
}
private fun play(handler: Handler, player: ExoPlayer, file: File) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
handler.runAndWait {
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(file)))
player.setMediaSource(source)
player.prepare()
player.play()
}
}
/** Waits for the player to reach READY, which is the point an AudioTrack has been acquired. */
private fun awaitReady(handler: Handler, player: ExoPlayer) {
val ready = CountDownLatch(1)
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED) ready.countDown()
}
}
handler.runAndWait {
if (player.playbackState == Player.STATE_READY || player.playbackState == Player.STATE_ENDED) {
ready.countDown()
} else {
player.addListener(listener)
}
}
val reached = ready.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
handler.runAndWait { player.removeListener(listener) }
assertTrue("timed out waiting for the player to become ready", reached)
// The AudioTrack is acquired on the first buffer handed to the sink, which lands just after
// READY; give the playback thread a beat to get there before counting.
handler.runAndWait { }
Thread.sleep(250)
handler.runAndWait { }
}
private fun Handler.runAndWait(block: () -> Unit) {
val done = CountDownLatch(1)
val failure = AtomicReference<Throwable>()
post {
try {
block()
} catch (error: Throwable) {
failure.set(error)
} finally {
done.countDown()
}
}
assertTrue("timed out running on the playback thread", done.await(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS))
failure.get()?.let { throw it }
}
private fun copyFixture(targetContext: Context, fixture: String): File {
val output = File.createTempFile("release-accounting-", null, targetContext.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets.open(fixture).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -0,0 +1,152 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Pins the side-loaded subtitle identity contract against the real media3 the
* app links, on a real device.
*
* `ExoPlayerCore` tags each `MediaItem.SubtitleConfiguration` with
* [ExternalSubtitleIds.idFor] and recovers it from the `Format` the track
* selector reports. media3 does not hand that id back verbatim:
* `DefaultMediaSourceFactory` merges side-loaded subtitles with the primary
* source, and `MergingMediaPeriod` prefixes every child format id with its
* period index. Matching the raw id therefore classifies every sidecar as an
* embedded track and drops its URI, which is what broke Plex sidecar
* subtitles (#1713).
*
* A JVM test can only assert the parsing rule. This asserts that the rule
* still matches what media3 actually emits.
*/
@RunWith(AndroidJUnit4::class)
class ExternalSubtitleIdentityTest {
private companion object {
const val TAG = "ExternalSubtitleIdentityTest"
const val SRT = "1\n00:00:00,500 --> 00:00:05,000\nplezy sidecar identity\n\n"
}
@Test
fun sideLoadedSubtitleIdSurvivesMedia3Merging() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val media = copyAsset(instrumentation.context, context, "ffmpeg/planar_5_1.m4a")
val subtitle = File.createTempFile("sidecar-", ".srt", context.cacheDir).apply {
writeText(SRT)
}
val thread = HandlerThread("plezy-sidecar-identity-test").apply { start() }
val handler = Handler(thread.looper)
val settled = CountDownLatch(1)
val playerRef = AtomicReference<ExoPlayer>()
val errorRef = AtomicReference<Throwable>()
val textFormatId = AtomicReference<String>()
val textGroupCount = AtomicReference(0)
handler.post {
try {
val player = ExoPlayer.Builder(context).setLooper(thread.looper).build()
playerRef.set(player)
player.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorRef.set(error)
settled.countDown()
}
override fun onTracksChanged(tracks: Tracks) {
val text = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (text.isEmpty()) return
textGroupCount.set(text.size)
textFormatId.set(text.first().mediaTrackGroup.getFormat(0).id)
settled.countDown()
}
})
// setMediaItem (not setMediaSource) so DefaultMediaSourceFactory owns
// the subtitle configuration exactly as ExoPlayerCore.open does.
player.setMediaItem(
MediaItem.Builder()
.setUri(Uri.fromFile(media))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subtitle))
.setId(ExternalSubtitleIds.idFor(0))
.setLabel("Plezy sidecar")
.setLanguage("en")
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
)
)
.build()
)
player.prepare()
} catch (error: Throwable) {
errorRef.set(error)
settled.countDown()
}
}
val finished = settled.await(30, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerRef.get()?.release()
thread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
thread.join(5_000)
media.delete()
subtitle.delete()
assertTrue("Player teardown timed out", teardownFinished)
assertNull("Playback failed", errorRef.get())
assertTrue("Timed out before any text track group was reported", finished)
assertEquals("Expected exactly one side-loaded text group", 1, textGroupCount.get())
val reportedId = textFormatId.get()
Log.i(TAG, "media3 reported side-loaded subtitle Format.id=$reportedId")
assertNotNull("Side-loaded subtitle reported a null Format.id", reportedId)
// The contract ExoPlayerCore depends on.
assertTrue(
"Side-loaded subtitle was not recognised as external (Format.id=$reportedId)",
ExternalSubtitleIds.isExternal(reportedId)
)
assertEquals(
"Side-loaded subtitle did not resolve to its configuration index (Format.id=$reportedId)",
0,
ExternalSubtitleIds.indexOf(reportedId)
)
}
private fun copyAsset(instrumentationContext: Context, targetContext: Context, asset: String): File {
val output = File.createTempFile("sidecar-primary-", null, targetContext.cacheDir)
instrumentationContext.assets.open(asset).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
@@ -0,0 +1,171 @@
package com.edde746.plezy.exoplayer
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
*
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
* be retired. The second test drives the production wrapper stack and requires the seek to
* actually land.
*/
class MatroskaLateTracksSeekDeviceTest {
private companion object {
const val TAG = "MkvLateTracksSeek"
const val SEEK_TARGET_MS = 500L
const val SETTLE_MS = 2_000L
}
private class Session(
val handler: Handler,
val thread: HandlerThread,
val player: AtomicReference<ExoPlayer?>,
val fixture: File
)
@Test
fun stockMatroskaExtractorSnapsSeeksToStart() {
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
assertFalse(
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
result.seekable
)
assertTrue(
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
)
}
@Test
fun wrappedExtractorKeepsSeekPosition() {
val result = runSeekScenario("wrapped") {
val assHandler = AssHandler()
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
}
assertTrue("wrapped extractor must report the item seekable", result.seekable)
assertTrue(
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
)
}
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
val session = openPaused(label, extractorFactory)
try {
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
// The masked position is the seek target; the snap surfaces once the period resolves the
// seek, so give the load a moment before sampling the settled position.
Thread.sleep(SETTLE_MS)
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
return ScenarioResult(seekable, positionAfterSeekMs)
} finally {
teardown(session)
}
}
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
val handler = Handler(thread.looper)
val player = AtomicReference<ExoPlayer?>(null)
val ready = CountDownLatch(1)
val failed = AtomicBoolean(false)
handler.post {
val exo = ExoPlayer.Builder(context).build()
player.set(exo)
exo.addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) ready.countDown()
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "$label player error", error)
failed.set(true)
ready.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
ExtractorsFactory { arrayOf(extractorFactory()) }
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.playWhenReady = false
exo.prepare()
}
val session = Session(handler, thread, player, fixture)
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
teardown(session)
error("$label playback never became ready")
}
return session
}
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
val value = AtomicReference<T>()
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.let { value.set(read(it)) }
done.countDown()
}
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
return checkNotNull(value.get())
}
private fun teardown(session: Session) {
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
session.thread.quitSafely()
session.thread.join(5_000)
session.fixture.delete()
}
private fun copyFixture(context: android.content.Context): File {
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open("ffmpeg/matroska_tracks_at_end.mkv")
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
}
@@ -0,0 +1,311 @@
package com.edde746.plezy.exoplayer
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Speed changes arrive while the carrier is already live (#1804). Nothing re-asks the sink unless
* capabilities are invalidated, so this drives the real path: play on the carrier, change speed,
* and require the renderer to actually move TrueHD onto a decoder and keep the clock advancing.
*
* Skips itself on hardware that never takes the carrier; there is no transition to observe there.
*/
class TrueHdSpeedTransitionTest {
private companion object {
const val TAG = "TrueHdSpeed"
const val SETTLE_MS = 4_000L
}
@Test
fun leavingUnitSpeedMovesTrueHdOffTheCarrierAndKeepsPlaying() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("truehd-speed-test").apply { start() }
val handler = Handler(thread.looper)
val playing = CountDownLatch(1)
val audioDecoder = AtomicReference<String?>(null)
val trackEncoding = AtomicReference<Int>(-1)
val player = AtomicReference<ExoPlayer?>(null)
handler.post {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
// Mirrors ExoPlayerCore: without this flag DefaultTrackSelector silently drops every
// renderer-capability invalidation, so the carrier could never be re-evaluated.
val selector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
)
}
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
player.set(exo)
exo.addAnalyticsListener(
object : AnalyticsListener {
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
audioDecoder.set(decoderName)
Log.i(TAG, "audio decoder: $decoderName")
}
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
config: AudioSink.AudioTrackConfig
) {
trackEncoding.set(config.sampleRate)
Log.i(TAG, "AudioTrack rate=${config.sampleRate} buffer=${config.bufferSize}")
}
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
if (isPlaying) playing.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.prepare()
exo.playWhenReady = true
}
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
Thread.sleep(SETTLE_MS)
val carrierEncoding = trackEncoding.get()
val decoderBefore = audioDecoder.get()
Log.i(TAG, "==== BEFORE: rate=$carrierEncoding decoder=$decoderBefore ====")
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
// therefore the 192kHz carrier rate with no decoder instantiated.
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
teardown(handler, player, thread, fixture)
return
}
val positionBefore = positionOf(handler, player)
handler.post { player.get()?.setPlaybackSpeed(1.5f) }
Thread.sleep(SETTLE_MS)
val encodingAfter = trackEncoding.get()
val decoderAfter = audioDecoder.get()
val positionAfter = positionOf(handler, player)
Log.i(
TAG,
"==== AFTER: rate=$encodingAfter decoder=$decoderAfter " +
"position=${positionBefore}ms -> ${positionAfter}ms ===="
)
// Returning to 1x has to re-offer the carrier, or one speed nudge costs Atmos for the session.
trackEncoding.set(-1)
handler.post { player.get()?.setPlaybackSpeed(1f) }
Thread.sleep(SETTLE_MS)
val rateRestored = trackEncoding.get()
Log.i(TAG, "==== RESTORED: rate=$rateRestored ====")
teardown(handler, player, thread, fixture)
assertEquals(
"returning to 1x must put TrueHD back on the carrier",
IecCarrier.SAMPLE_RATE,
rateRestored
)
assertNotEquals(
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
IecCarrier.SAMPLE_RATE,
encodingAfter
)
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
assertTrue("the clock must keep advancing after the switch", positionAfter > positionBefore)
// The whole point of the switch: the speed the user asked for has to actually apply. At 1.5x
// the media clock must outrun the 4s of wall time spent waiting.
assertTrue(
"playback must run faster than real time after the switch, advanced " +
"${positionAfter - positionBefore}ms in ${SETTLE_MS}ms",
positionAfter - positionBefore > SETTLE_MS * 6 / 5
)
}
private fun positionOf(handler: Handler, player: AtomicReference<ExoPlayer?>): Long {
val value = AtomicReference(0L)
val done = CountDownLatch(1)
handler.post {
value.set(player.get()?.currentPosition ?: 0L)
done.countDown()
}
done.await(5, TimeUnit.SECONDS)
return value.get()
}
private fun teardown(
handler: Handler,
player: AtomicReference<ExoPlayer?>,
thread: HandlerThread,
fixture: File
) {
val done = CountDownLatch(1)
handler.post {
player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
thread.quitSafely()
thread.join(5_000)
fixture.delete()
}
private fun copyFixture(
context: android.content.Context,
asset: String = "ffmpeg/truehd_speed_repro.mka"
): File {
val output = File.createTempFile("truehd-speed-", null, context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open(asset)
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
/**
* The container announces 48kHz, which selection trusts, but the bitstream is genuinely 44.1kHz
* TrueHD, which the carrier does not cover (#1804). The packer emits nothing in that state, so
* the sink has to hand the stream to the decoder rather than play silence.
*/
@Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsIecCarrier(context)) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return
}
val fixture = copyFixture(context, "ffmpeg/truehd_mismatch_repro.mka")
val thread = HandlerThread("truehd-mismatch-test").apply { start() }
val handler = Handler(thread.looper)
val playing = CountDownLatch(1)
val audioDecoder = AtomicReference<String?>(null)
val trackRate = AtomicReference(-1)
val rateSequence = java.util.Collections.synchronizedList(mutableListOf<Int>())
val diagnostics = java.util.Collections.synchronizedList(mutableListOf<String>())
val player = AtomicReference<ExoPlayer?>(null)
handler.post {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
audioDiagnosticsLogger = { _, _, message ->
diagnostics.add(message)
Log.i(TAG, "mismatch sink: $message")
}
}
val selector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
)
}
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
player.set(exo)
exo.addAnalyticsListener(
object : AnalyticsListener {
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
audioDecoder.set(decoderName)
Log.i(TAG, "mismatch audio decoder: $decoderName")
}
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
config: AudioSink.AudioTrackConfig
) {
trackRate.set(config.sampleRate)
rateSequence.add(config.sampleRate)
Log.i(TAG, "mismatch AudioTrack rate=${config.sampleRate}")
}
override fun onAudioInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: androidx.media3.common.Format,
decoderReuseEvaluation: androidx.media3.exoplayer.DecoderReuseEvaluation?
) {
Log.i(TAG, "mismatch INPUT format: mime=${format.sampleMimeType} rate=${format.sampleRate} ch=${format.channelCount}")
}
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
if (isPlaying) playing.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.prepare()
exo.playWhenReady = true
}
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
Thread.sleep(SETTLE_MS)
val positionFirst = positionOf(handler, player)
Thread.sleep(SETTLE_MS)
val positionSecond = positionOf(handler, player)
val decoder = audioDecoder.get()
val rate = trackRate.get()
Log.i(
TAG,
"==== MISMATCH RESULT: rates=$rateSequence decoder=$decoder rate=$rate " +
"position=${positionFirst}ms -> ${positionSecond}ms ===="
)
teardown(handler, player, thread, fixture)
// Without this the test would also pass on a build where the carrier was never selected at all:
// the mismatch fires on the first access unit, before the carrier writes a burst, so no 192kHz
// AudioTrack is ever opened and the rate sequence alone cannot tell the two apart.
assertTrue(
"the carrier must have been entered and then left at runtime, saw: $diagnostics",
diagnostics.any { it.contains("via IEC 61937 carrier") } &&
diagnostics.any { it.contains("44.1kHz-family rate its container did not") }
)
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
assertNotEquals(
"the stream must not still be riding the carrier",
IecCarrier.SAMPLE_RATE,
rate
)
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
}
}
+10 -14
View File
@@ -1,19 +1,22 @@
<!-- android:installLocation="auto" is what makes the app eligible to be moved to adoptable
storage (a USB drive adopted by an Android TV, an adopted SD card), which relocates the
private data directory — and therefore downloads — along with the APK. Plezy declares none
of the components this is unsafe for: no app widget, IME, live wallpaper, device admin,
accessibility service, sync adapter or account authenticator. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.REORDER_TASKS"/>
<!-- Background download notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
@@ -24,15 +27,11 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<!-- Android TV support (not required, but allow detection) -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android TV Watch Next integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Touchscreen not required for TV -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<!-- Android Automotive OS support -->
<uses-feature android:name="android.hardware.type.automotive" android:required="false" />
<application
@@ -44,6 +43,9 @@
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<!-- Driver-distraction state on Android Automotive OS. Optional: the platform library is
absent on every other form factor, and CarRestrictionsMonitor falls back silently. -->
<uses-library android:name="android.car" android:required="false" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -54,10 +56,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@@ -68,7 +66,6 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<!-- Allow app to appear in Android TV launcher -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
<!-- Deep link handler for Watch Next items -->
@@ -79,7 +76,6 @@
<data android:scheme="plezy" android:host="play"/>
</intent-filter>
</activity>
<!-- FileProvider for sharing downloaded files with external players -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.edde746.plezy.fileprovider"
@@ -10,18 +10,24 @@ import android.content.res.Configuration
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import com.edde746.plezy.car.CarRestrictionsMonitor
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
@@ -45,6 +51,20 @@ class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
// Flutter's TextInputPlugin issues showSoftInput before the FlutterView is
// the IMM's served view (the InputConnection restart is deferred to the
// next channel message), so on TV the D-pad-driven first open is dropped
// with "Ignoring showSoftInput() as view ... is not served" and never
// retried (flutter/flutter#177360). These bounded retries re-issue the
// show once the view is served; the restart budget repairs the sibling
// failure mode where the keyboard shows but its key session never bound
// ("Ignoring onBind: cur seq=-1"), leaving Gboard blind to D-pad
// (#1051, #1079).
private const val IME_SHOW_RETRY_LIMIT = 4
private const val IME_SHOW_RETRY_INTERVAL_MS = 300L
private const val IME_LEAK_RESTART_BUDGET = 2
private const val IME_LEAK_RESTART_MIN_INTERVAL_MS = 1000L
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
@@ -74,8 +94,18 @@ class MainActivity : FlutterActivity() {
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
private var watchNextPlugin: WatchNextPlugin? = null
private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null
private var nativeTextInputFocused = false
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
private var imeShowAttempts = 0
private var imeLeakRestartBudget = 0
private var imeRestartedOnShow = false
private var imeWasVisible = false
private var lastImeLeakRestartUptime = 0L
private var imeVisibilityListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
@@ -89,7 +119,6 @@ class MainActivity : FlutterActivity() {
}
}
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
@@ -159,6 +188,82 @@ class MainActivity : FlutterActivity() {
return forward
}
private fun inputMethodManager(): InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
private fun flutterView(): View? = findViewById(FLUTTER_VIEW_ID)
// Re-issues a soft-input show that the engine dropped because the
// FlutterView was not yet the IMM's served view when TextInput.show ran
// (flutter/flutter#177360). Flutter never retries on its own — its Dart
// side believes the keyboard is already up — so without this the first
// D-pad-driven open on TV can silently do nothing.
private val imeShowRetry = object : Runnable {
override fun run() {
if (!nativeTextInputFocused) return
if (isImeVisible()) return
val view = flutterView()
val imm = inputMethodManager()
if (view != null && imm.isActive(view)) {
logTextInputDiag { "imeShowRetry re-showing attempt=$imeShowAttempts ${describeImeState()}" }
imm.showSoftInput(view, 0)
} else {
logTextInputDiag { "imeShowRetry waiting attempt=$imeShowAttempts served=${view != null && imm.isActive(view)}" }
}
imeShowAttempts++
if (imeShowAttempts < IME_SHOW_RETRY_LIMIT) {
imeRecoveryHandler.postDelayed(this, IME_SHOW_RETRY_INTERVAL_MS)
}
}
}
private fun startNativeTextInputSession() {
imeShowAttempts = 0
imeLeakRestartBudget = IME_LEAK_RESTART_BUDGET
imeRestartedOnShow = false
imeRecoveryHandler.removeCallbacks(imeShowRetry)
imeRecoveryHandler.postDelayed(imeShowRetry, IME_SHOW_RETRY_INTERVAL_MS)
}
private fun endNativeTextInputSession() {
imeRecoveryHandler.removeCallbacks(imeShowRetry)
}
private fun restartNativeTextInput(reason: String) {
val view = flutterView() ?: return
logTextInputDiag { "restartInput reason=$reason ${describeImeState()}" }
inputMethodManager().restartInput(view)
}
// A visible IME owns D-pad navigation: a healthy Gboard consumes these keys
// at the ImeInputStage, before the app. One arriving here therefore means
// the IME's key session never bound ("Ignoring onBind: cur seq=-1") — the
// Chromecast/Google TV failure of #1051/#1079. Repair by rebinding, and eat
// the press so Flutter focus cannot wander behind the stuck keyboard. The
// bounded budget guarantees keys flow again (and Flutter can close the
// session) if rebinding cannot heal the device.
private fun consumeLeakedImeNavigationKey(event: KeyEvent): Boolean {
if (!nativeTextInputFocused || imeLeakRestartBudget <= 0) return false
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> Unit
else -> return false
}
if (!isImeVisible()) return false
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val now = SystemClock.uptimeMillis()
if (now - lastImeLeakRestartUptime >= IME_LEAK_RESTART_MIN_INTERVAL_MS) {
lastImeLeakRestartUptime = now
imeLeakRestartBudget--
restartNativeTextInput("leaked-dpad-while-ime-visible")
}
}
logTextInputDiag { "consuming leaked IME key ${describeKeyEvent(event)} budget=$imeLeakRestartBudget" }
return true
}
private fun getAndroidTvDetection(): Map<String, Any> {
val pm = packageManager
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
@@ -429,6 +534,24 @@ class MainActivity : FlutterActivity() {
)
)
// Watch IME visibility so a fresh session can be rebound the moment the
// keyboard first shows: on Chromecast-class devices the initial bind can
// land against a stale sequence, leaving the IME without a key session
// (D-pad dead, #1051/#1079). One restartInput at first-show — before the
// user has typed or moved the key highlight — repairs it invisibly.
val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
val visible = isImeVisible()
if (visible == imeWasVisible) return@OnGlobalLayoutListener
imeWasVisible = visible
logTextInputDiag { "ime visibility changed visible=$visible ${describeImeState()}" }
if (visible && nativeTextInputFocused && !imeRestartedOnShow) {
imeRestartedOnShow = true
restartNativeTextInput("first-show-rebind")
}
}
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
imeVisibilityListener = visibilityListener
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
}
@@ -443,6 +566,9 @@ class MainActivity : FlutterActivity() {
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
}
// Reaching the activity means the ImeInputStage already declined this
// key, so consumption below cannot starve a healthy IME.
if (consumeLeakedImeNavigationKey(event)) return true
val handled = super.dispatchKeyEvent(event)
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag {
@@ -460,6 +586,12 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
externalPlayerChannel.dispose()
endNativeTextInputSession()
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
imeVisibilityListener = null
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -474,6 +606,29 @@ class MainActivity : FlutterActivity() {
}
}
// Connects the car UX-restriction monitor on first use, retrying while the platform signal is
// unavailable: a car service that was not ready during startup can still answer later, and on a
// phone every attempt fails cheaply on the FEATURE_AUTOMOTIVE check. The connect itself never
// blocks, so this is safe on the main thread; readiness arrives through the callback below.
private fun startCarRestrictionsIfNeeded() {
val existing = carRestrictions
if (existing?.supported == true) return
val monitor = existing ?: CarRestrictionsMonitor(applicationContext).also { carRestrictions = it }
monitor.start { restricted ->
runOnUiThread {
// `supported` rides along because it can go false again when the car service dies, and Dart
// must then fall back to lifecycle gating rather than read a stale verdict.
carRestrictionsChannel?.invokeMethod(
"onChanged",
mapOf(
"supported" to monitor.supported,
"requiresDistractionOptimization" to restricted
)
)
}
}
}
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
selectedFlutterRenderer = selectFlutterRenderer()
@@ -602,6 +757,28 @@ class MainActivity : FlutterActivity() {
}
}
val carChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CAR_RESTRICTIONS_CHANNEL)
carRestrictionsChannel = carChannel
carChannel.setMethodCallHandler { call, result ->
when (call.method) {
"getState" -> {
startCarRestrictionsIfNeeded()
val monitor = carRestrictions
val supported = monitor?.supported == true
result.success(
mapOf(
"supported" to supported,
// Tells Dart the difference between "this device has no car service" and "the verdict
// is coming": only the latter is worth waiting for.
"pending" to (monitor?.pending == true),
"requiresDistractionOptimization" to (supported && monitor.requiresDistractionOptimization)
)
)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
handleDeviceAdjustmentCall(call.method, call.arguments, result)
}
@@ -614,6 +791,11 @@ class MainActivity : FlutterActivity() {
logTextInputDiag {
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
}
if (nativeTextInputFocused && !oldValue) {
startNativeTextInputSession()
} else if (!nativeTextInputFocused && oldValue) {
endNativeTextInputSession()
}
result.success(null)
}
else -> result.notImplemented()
@@ -0,0 +1,286 @@
package com.edde746.plezy.car
import android.car.Car
import android.car.drivingstate.CarUxRestrictions
import android.car.drivingstate.CarUxRestrictionsManager
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.view.Display
/**
* Reads Android Automotive OS driver-distraction state straight from the platform.
*
* Plezy previously derived "may audio play?" from the Flutter activity lifecycle, which conflates
* two different things: a car that is driving, and a car that is parked with Plezy simply not in
* the foreground. Only the first must silence playback (car app quality `DD-2`/`DD-3`); the second
* is ordinary background audio. [CarUxRestrictionsManager] is the signal the platform documents for
* apps where lifecycle reaction is not sufficient.
*
* Bound explicitly to [Display.DEFAULT_DISPLAY] — the driver's screen — by creating a display
* context for it. An application context would NOT do: it has no associated display, so
* `CarUxRestrictionsManager` falls back to the current user's assigned main display, which under
* multi-user Android Automotive OS can be a passenger screen. UX restrictions legitimately differ
* per display, but audio is not per-display, and `DD-2` requires audio to stop when the user starts
* driving unless the device declares `com.android.car.background_audio_while_driving`. Reading the
* driver display therefore keeps a passenger-display session stricter than it may need to be, which
* is the safe direction; do not rebind this without pairing it with that feature check. If the
* default display cannot be resolved, [start] fails closed and the caller keeps lifecycle gating.
*
* Connection uses the [Car.CarServiceLifecycleListener] overload with
* [Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT], which is not a preference:
*
* - `Car.createCar(Context)` blocks the calling thread polling for the car service (AOSP polls
* every 50 ms up to 100 times, so up to five seconds) and is reached from the main thread here.
* - Worse, a client created without a lifecycle listener is terminated when the car service dies:
* the library calls `finishClient()`, which for anything that is not literally an `Activity` or
* `Service` — a display context included — runs `Process.killProcess(myPid())`. A routine car
* service update would take the whole app down.
*
* With the listener, `createCar` never blocks and a service restart arrives as `ready = false`
* instead. Every `ready = true` re-fetches the manager and re-registers the listener, because the
* old manager holds a dead binder: its calls then return null and are swallowed, which would look
* like a permanently parked car rather than a failure.
*
* `android.car` is a platform library that only exists on Android Automotive OS images, hence the
* `useLibrary` compile-time stub, the `uses-library required=false` manifest entry, and the
* defensive [Throwable] catches: on a phone the classes are simply absent.
*/
class CarRestrictionsMonitor(
private val context: Context,
/**
* Forces the pre-Android-11 [ServiceConnection] route on a device that also has the newer one, so
* instrumentation can exercise it: no Android 9 or 10 Automotive system image is published.
*/
private val forceLegacyConnect: Boolean = false
) {
private var car: Car? = null
private var manager: CarUxRestrictionsManager? = null
private var onChanged: ((Boolean) -> Unit)? = null
private var connecting = false
private var legacyConnection = false
/**
* True while a live car connection can answer; until then callers must not trust
* [requiresDistractionOptimization]. Goes back to false if the car service dies.
*/
var supported: Boolean = false
private set
/**
* Latest platform verdict. Defaults to restricted so a half-connected car can never be read as
* "free to play"; [supported] is what gates whether this value is used at all.
*/
var requiresDistractionOptimization: Boolean = true
private set
/**
* A connection exists but has not produced a verdict yet — the car service is starting, or died
* and is expected back. Callers that gate behaviour on the verdict should wait rather than treat
* this like a phone, where no answer is ever coming.
*/
val pending: Boolean
get() = connecting && !supported
/**
* Begins connecting to the car service. Returns whether this device can have the signal at all,
* NOT whether it is ready: readiness arrives through [onChanged], and [supported] reports it.
* A false return leaves the caller on its previous (lifecycle-derived) behaviour for good.
*/
fun start(onChanged: (Boolean) -> Unit): Boolean {
if (supported) return true
val existing = car
if (existing != null) {
// A connection exists but is not observing the vehicle: the manager lookup or the listener
// registration failed. Retry it here, because the car service is already ready and will not
// announce itself again — without this the signal would stay dark for the whole process.
this.onChanged = onChanged
if (existing.isConnected) bind(existing) else reconnectLegacyIfNeeded()
return true
}
if (connecting) return true
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
return try {
// Fail closed when the driver display cannot be resolved: an unbound context would silently
// report some other display's restrictions (see the class doc).
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return false
val driverDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY) ?: return false
val displayContext = context.createDisplayContext(driverDisplay)
this.onChanged = onChanged
connecting = true
if (!forceLegacyConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
connectWithLifecycleListener(displayContext)
} else {
connectWithServiceConnection(displayContext)
}
} catch (error: Throwable) {
Log.w(TAG, "Car UX restrictions unavailable; falling back to lifecycle gating", error)
release()
false
}
}
/** Android 11 and newer: the car service announces readiness and death through one callback. */
private fun connectWithLifecycleListener(displayContext: Context): Boolean {
val created = Car.createCar(
displayContext,
null,
Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT
) { connectedCar: Car, ready: Boolean ->
// Use the Car handed to the listener: on the synchronous path — the car service is already
// up and this runs on the main thread — it fires inside createCar, before it returns.
if (ready) bind(connectedCar) else unbind()
}
// Cold service: the listener has not run, so this is the only reference to the pending
// connection and release() would otherwise leave it bound for the process's lifetime.
if (car == null) car = created
return true
}
/**
* Android 9 and 10 head units, which have no [Car.CarServiceLifecycleListener].
*
* A [ServiceConnection] is the safe alternative, not merely an older one: the process kill in
* `finishClient()` is reached only by a client that supplies neither callback, and binding here is
* asynchronous, so nothing blocks. Kept behind a version check because naming the newer overload
* on such a device raises `NoSuchMethodError`.
*/
@Suppress("DEPRECATION")
private fun connectWithServiceConnection(displayContext: Context): Boolean {
legacyConnection = true
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
car?.let { bind(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
unbind()
// Android 10 unbinds inside its own disconnect handling, so the binding that would have
// reconnected is gone; newer platforms keep it and race us harmlessly.
reconnectLegacyIfNeeded()
}
}
val created = Car.createCar(displayContext, connection)
if (created == null) {
// The platform returns null when the car service loader is unavailable. Clearing the
// connection state matters: leaving it set would report a pending verdict for the rest of the
// process, and every later start would short-circuit instead of trying again.
Log.w(TAG, "Car service loader unavailable; falling back to lifecycle gating for now")
release()
return false
}
car = created
created.connect()
return true
}
private fun reconnectLegacyIfNeeded() {
if (!legacyConnection) return
val existing = car ?: return
if (existing.isConnected || existing.isConnecting) return
try {
existing.connect()
} catch (error: IllegalStateException) {
// The platform's own reconnect won the race, which is the outcome we wanted anyway.
Log.w(TAG, "Car service reconnect already in progress", error)
}
}
/** Adopts a ready car service: fresh manager, fresh listener, fresh verdict. */
private fun bind(connectedCar: Car) {
car = connectedCar
try {
val uxManager = connectedCar.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager
if (uxManager == null) {
Log.w(TAG, "Car service is ready but has no UX restrictions manager")
return
}
// Register before reading, per the platform's own guidance: registration subscribes to future
// changes only, it does not replay the current one. Reading first would drop a transition that
// lands between the two binder calls, and publishing the stale "parked" afterwards would then
// override lifecycle gating and permit audio for the whole drive.
//
// Identity-guarded because a retry re-enters bind() with the same manager instance (Car caches
// managers until the service dies): a second registration is at best redundant and at worst a
// platform refusal. `manager` is assigned after the listener sticks, so it only ever names a
// manager that is actually observing the vehicle.
if (manager !== uxManager) {
uxManager.registerListener { restrictions: CarUxRestrictions -> adoptVerdict(restrictions.isRequiresDistractionOptimization) }
manager = uxManager
}
val current = uxManager.currentCarUxRestrictions
if (current == null) {
// The service holds no restrictions for this display (yet). Claiming a verdict here would
// publish the restricted default with `supported = true`, and on a car that then stays
// parked no transition ever arrives to correct it — playback would refuse to start for the
// whole session. Stay pending instead: Dart keeps lifecycle gating (parked, foregrounded
// video plays; while driving the platform blocks the activity), the listener above adopts
// the first real verdict, and every later getState retries this read.
Log.w(TAG, "Car service has no UX restrictions for this display; keeping lifecycle gating until it answers")
return
}
adoptVerdict(current.isRequiresDistractionOptimization)
} catch (error: Throwable) {
// Registration is the failure the caller's retry is meant to survive, so keep the connection
// owned (it is in `car`) and report nothing rather than half-observing the vehicle.
Log.w(TAG, "Failed to observe car UX restrictions", error)
manager = null
supported = false
}
}
/** A real verdict from the vehicle: only now may callers trust [requiresDistractionOptimization]. */
private fun adoptVerdict(restricted: Boolean) {
supported = true
publish(restricted)
}
/**
* Drops a dead car service. The [Car] itself is kept: its `BIND_AUTO_CREATE` binding is what
* reconnects, and disconnecting would end that for good.
*/
private fun unbind() {
Log.w(TAG, "Car service went away; falling back to lifecycle gating until it returns")
manager = null
supported = false
requiresDistractionOptimization = true
onChanged?.invoke(true)
}
private fun publish(restricted: Boolean) {
requiresDistractionOptimization = restricted
onChanged?.invoke(restricted)
}
/** Unregisters and disconnects. Safe to call when never started. */
fun release() {
try {
manager?.unregisterListener()
} catch (error: Throwable) {
Log.w(TAG, "Failed to unregister car UX restrictions listener", error)
}
try {
car?.disconnect()
} catch (error: Throwable) {
Log.w(TAG, "Failed to disconnect from the car service", error)
}
manager = null
car = null
onChanged = null
connecting = false
legacyConnection = false
supported = false
requiresDistractionOptimization = true
}
private companion object {
const val TAG = "CarRestrictions"
}
}
@@ -0,0 +1,36 @@
package com.edde746.plezy.exoplayer
/**
* Decides which audio outputs `RawPositionOutputProvider` may park in its reuse cache instead of
* releasing on a sink flush.
*
* The cache exists so a seek does not tear down and rebuild the hardware audio pipeline, which
* costs 7-10s of silence on Android TV boxes that reinitialize tunneled output slowly (Sony
* Bravia class). That win is real for decoded PCM, but parking a *bitstream* output is a
* different trade: a direct/passthrough `AudioTrack` occupies the platform's direct output for as
* long as it is held, and several HDMI HALs expose only one. A parked AC3 track therefore blocks
* the next AC3 track from being created at all, and the failure surfaces as
* `UnsupportedOperationException: Cannot create AudioTrack` rather than as a slow seek (#1790).
*
* Parking also has to stay honest about media3's process-wide pending-release accounting: a
* cached output is *not* releasing, so `RawPositionAudioOutput` signals `onReleased` immediately
* for it. That is only true because nothing scarce is being held, which is exactly what this
* policy guarantees.
*
* Offload outputs are excluded for the same reason plus their separate gapless/end-of-stream
* state machine, which the cache does not model.
*/
internal object AudioOutputCachePolicy {
/**
* `AudioTrack.flush()` only reliably resets a stopped track from N MR1 onwards; below that the
* cache would hand back a track carrying the previous playback's state.
*/
const val MIN_SDK_INT = 25
fun mayCache(encoding: Int, isOffload: Boolean, sdkInt: Int): Boolean {
if (sdkInt < MIN_SDK_INT) return false
if (isOffload) return false
return isPcmEncoding(encoding)
}
}
@@ -1,8 +1,14 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
@@ -25,16 +31,65 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
/**
* The DTS-family mimes the bundled FFmpeg decoder claims (`FfmpegLibrary` maps both to `dca`).
*
* DTS Express (`audio/vnd.dts.hd;profile=lbr`) and DTS:X (`audio/vnd.dts.uhd`) are deliberately
* excluded: FFmpeg does not claim them, so hiding their platform decoders would leave those
* streams with no decoder at all.
*/
internal fun isFfmpegDtsMimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_DTS || mimeType == MimeTypes.AUDIO_DTS_HD
/**
* Whether a DTS stream should decode in the app's FFmpeg decoder instead of a platform
* MediaCodec decoder.
*
* Platform DTS decoders cannot be trusted with decode. On Amlogic-based Google TV boxes (the
* Onn family) DTS decode is license-gated in firmware, so `c2.amlogic.audio.decoder.dtshd`
* initialises, drains and advances the playback position while rendering silence — and collapses
* 5.1 to stereo where it does produce sound (#1995). FFmpeg decodes the whole `dca` family to
* full multichannel PCM everywhere, which is what mpv and Kodi ship on the same hardware.
*
* Scoped to streams that are actually going to decode: [directOutputBlocked] (passthrough off,
* downmix, normalization, the AudioTrack-failure blocklist) or a route that cannot bitstream DTS
* in any shape ([routeCanBitstreamDts] false — the Android TV default leaves passthrough on, so
* the setting alone cannot identify the decode path). Bitstream-capable routes are left exactly
* alone: media3 selects direct output before it ever consults the decoder list, and leaving the
* platform decoder visible there keeps the hardware-decoder tunneling gate unchanged.
*/
internal fun shouldForceFfmpegDtsDecode(
mimeType: String,
directOutputBlocked: () -> Boolean,
routeCanBitstreamDts: () -> Boolean
): Boolean = isFfmpegDtsMimeType(mimeType) && (directOutputBlocked() || !routeCanBitstreamDts())
/**
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
*/
internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
*
* Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every
* spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to
* `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD
* (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one
* anyway force-passes the codec into a track that cannot carry it (#1991); those streams
* decode to PCM instead, like every other unnamed codec.
*/
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
"ac3" to C.ENCODING_AC3,
"eac3" to C.ENCODING_E_AC3,
"dts" to C.ENCODING_DTS,
"dts-hd" to C.ENCODING_DTS_HD,
"truehd" to C.ENCODING_DOLBY_TRUEHD
"dts" to C.ENCODING_DTS
)
/**
@@ -43,23 +98,36 @@ private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
* mpv force-passes through every codec named here and has no decode fallback, so an
* unsupported name leaves the file rendering video against a dead audio output (#1703).
*
* The gate is the exact encoding rather than media3's passthrough probe on purpose.
* That probe answers DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3)
* for receivers that decode only the base layer, and it also rejects channel counts
* above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv
* additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would
* name DTS-HD MA to a core-only receiver and lose DTS as well.
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
* channel counts above the route's PCM maximum, neither of which describes what an IEC
* 61937 track carries.
*/
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
.filter { (_, encoding) -> supportsEncoding(encoding) }
.joinToString(",") { (codec, _) -> codec }
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
/**
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
*
* Two conditions, both required:
* - The route must accept the exact track shape mpv opens for spdif output — stereo
* IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS
* encodings only says the receiver decodes them, not that the HAL takes an IEC 61937
* AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv
* spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable.
* - The receiver must decode the codec itself — the raw encoding on the current
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
*/
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
@Suppress("DEPRECATION")
@OptIn(UnstableApi::class)
internal fun supportedMpvSpdifCodecs(context: Context): String {
if (!supportsMpvIecShape(context)) {
Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming")
return ""
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
@@ -72,3 +140,146 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
}
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
private const val MPV_IEC_SAMPLE_RATE = 48_000
private const val MPV_IEC_CHANNEL_COUNT = 2
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
)
/**
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
*
* This is Kodi's test, and deliberately not media3's. Kodi asks the AudioTrack layer whether it can
* size a buffer for one exact tuple — `getMinBufferSize(rate, mask, encoding) > 0` — and gates the
* carrier on the 192kHz/7.1 IEC combination specifically. media3 instead asks the audio policy
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
*
* Both are consulted: `getMinBufferSize` proves a track can be built, and a route oracle proves
* the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
* initialise.
*
* The oracle is tiered by what the platform offers:
* - API 33+: `getDirectPlaybackSupport`, whose bitstream flag also rules out offload-only answers.
* - API 29–32: `AudioTrack.isDirectPlaybackSupported` for the same tuple. Coarser — it cannot tell
* bitstream from offload — but an IEC 61937 track is PCM-shaped by definition, so direct support
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
* - API 24–28: no runtime oracle exists, so the HDMI `AudioDeviceInfo` must explicitly advertise
* IEC 61937 at 192kHz/8ch. Shield Experience 8.x is API 28, and the previous flat `false` on
* this tier force-decoded TrueHD on routes that genuinely carry it (#1991). A route that
* advertises and still refuses the track fails AudioTrack initialisation into the same
* recovery path as the tier above.
*/
internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeIecBuffer(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
)
/**
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
* on 33+, [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32, and
* [hdmiRouteAdvertised] (explicit HDMI `AudioDeviceInfo` advertisement) on 24–28 (#1991).
*/
internal fun iecRouteSupported(
sdkInt: Int,
canSizeBuffer: () -> Boolean,
bitstreamSupported: () -> Boolean,
directPlaybackSupported: () -> Boolean,
hdmiRouteAdvertised: () -> Boolean
): Boolean = when {
// ENCODING_IEC61937 itself only exists from API 24.
sdkInt < Build.VERSION_CODES.N -> false
!canSizeBuffer() -> false
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported()
else -> hdmiRouteAdvertised()
}
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
} catch (error: Exception) {
false
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun iecBitstreamSupported(format: AudioFormat): Boolean = try {
val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes())
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
false
}
@RequiresApi(Build.VERSION_CODES.Q)
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
private fun iecDirectPlaybackSupported(format: AudioFormat): Boolean = try {
AudioTrack.isDirectPlaybackSupported(format, movieAudioAttributes())
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error)
false
}
/**
* Whether an HDMI output *explicitly* advertises IEC 61937 at [sampleRate]/[channelCount] —
* the only oracle below API 29 (#1991).
*
* Empty `AudioDeviceInfo` capability arrays mean "unspecified" and deliberately fail this
* check: an unvouched IEC track that initialises on a route that then renders it as PCM plays
* the carrier as full-scale noise, which the AudioTrack-init recovery path cannot catch.
*/
private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCount: Int): Boolean = try {
val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
(device.type == AudioDeviceInfo.TYPE_HDMI || device.type == AudioDeviceInfo.TYPE_HDMI_ARC) &&
device.encodings.contains(AudioFormat.ENCODING_IEC61937) &&
device.sampleRates.contains(sampleRate) &&
device.channelCounts.contains(channelCount)
}
} catch (error: Exception) {
Log.w(TAG, "HDMI route inspection failed; not offering IEC 61937 output", error)
false
}
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(channelMask)
.setSampleRate(sampleRate)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
.getPlatformAudioAttributes()
@@ -0,0 +1,112 @@
package com.edde746.plezy.exoplayer
/**
* Decision logic for the buffering stall watchdog (#1790).
*
* A renderer that can never become ready pins ExoPlayer in `STATE_BUFFERING` with a full buffer
* and a frozen clock. The reported case is an `AudioTrack` that fails to initialize: media3 logs a
* sink error, keeps retrying, and — when its process-wide pending-release accounting has been
* knocked out of balance — never converts the failure into a `PlaybackException`. Nothing else
* notices. The frame watchdog wants `STATE_READY` and zero rendered frames, the decoder-hang check
* is cancelled by the first frame, [ResumeStallPolicy] needs `isPlaying` and deliberately treats a
* frozen clock as "not a decoder stall", [EndOfStreamPolicy] needs the position past the duration,
* and media3's own stuck-buffering detector needs an *empty* buffer. So the user sits on a spinner
* with no error and no way out.
*
* This watchdog covers exactly that hole: buffering, intending to play, holding data, and not
* moving. A starved buffer is left alone — that is an ordinary network stall and the loader is the
* right thing to wait for.
*/
internal object BufferingStallPolicy {
/** Poll interval while buffering. */
const val CHECK_INTERVAL_MS = 2_000L
/**
* How long a buffering player may hold enough data to start and still not move before it is
* called stalled. Long enough to sit out a slow decoder handover or a track reselection, short
* enough that a user has not yet given up and force-quit.
*
* Only time spent above [MIN_BUFFER_AHEAD_MS] counts: a rebuffer that takes a minute to refill
* is the loader doing its job, and the clock restarts once it is done.
*/
const val STALL_TIMEOUT_MS = 12_000L
/** Position movement that counts as progress rather than clock jitter. */
const val PROGRESS_EPSILON_MS = 250L
/**
* Media buffered ahead of the position before a motionless player is anomalous at all.
*
* A fallback bar, used only until media3 has answered for itself (see [evaluate]). It has to
* clear `DefaultLoadControl`'s own play-start bar with room to spare, or the watchdog would
* indict the player for obeying it: below [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS]
* the load control deliberately keeps playback in `STATE_BUFFERING`, and the two evaluate on
* independent schedules.
*
* Measured in playout time, not media time, because `shouldStartPlayback` divides buffered media
* duration by the playback speed: at 2x the same bar needs twice the media, and comparing raw
* media duration would call a legitimately rebuffering fast-forward stalled.
*/
const val MIN_BUFFER_AHEAD_MS = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + 2_000L
enum class Verdict {
/** The position moved; re-baseline and keep watching. */
HEALTHY,
/** Enough data to start, not moving, but not for long enough yet. */
WAITING,
/** Not moving because there is not yet enough to play. An ordinary rebuffer; not ours. */
STARVED,
/** Enough data to start, playback is wanted, and nothing has moved. */
STALLED
}
/**
* Every signal here can only ever *permit* an indictment; none of them vetoes one, because a
* signal that can go stale would otherwise wedge this watchdog shut in exactly the failure it
* exists to catch.
*
* [loadControlReady] is media3's own `shouldStartPlayback` verdict when it has given one, which is
* the most faithful answer available — but it is only asked while the renderers report ready, and
* a renderer that never becomes ready is that failure. A `false` recorded before the renderer got
* stuck would never be revised, so it is treated as no answer rather than as a denial.
*
* [loading] false means the load control stopped asking for data, whether because its duration
* thresholds are met or because its byte target is full. On a high bitrate stream that byte cap is
* reached well below [MIN_BUFFER_AHEAD_MS] (see [LoadControlPolicy]), and without this signal a
* genuinely stuck player there would look like an ordinary rebuffer forever. A network stall is
* the opposite: the loader keeps wanting data it cannot get, so [loading] stays true and none of
* the three signals fires.
*/
fun evaluate(
elapsedMs: Long,
baselinePositionMs: Long,
currentPositionMs: Long,
bufferedPositionMs: Long,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
): Verdict {
val speed = if (playbackSpeed > 0f) playbackSpeed else 1f
val bufferedPlayoutMs = ((bufferedPositionMs - currentPositionMs) / speed).toLong()
val readyToPlay = loadControlReady == true || !loading || bufferedPlayoutMs >= MIN_BUFFER_AHEAD_MS
return when {
currentPositionMs - baselinePositionMs >= PROGRESS_EPSILON_MS -> Verdict.HEALTHY
!readyToPlay -> Verdict.STARVED
elapsedMs < STALL_TIMEOUT_MS -> Verdict.WAITING
else -> Verdict.STALLED
}
}
/**
* Whether [verdict] means the elapsed stall clock was measuring something the watchdog is not
* responsible for, and must restart.
*
* Without this a long rebuffer accrues the whole timeout while starved, and the first poll after
* the buffer refills reports a stall that never happened.
*/
fun resetsStallClock(verdict: Verdict): Boolean = verdict == Verdict.HEALTHY || verdict == Verdict.STARVED
}
@@ -8,14 +8,21 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackAwareSeekMap
import androidx.media3.extractor.TrackOutput
import java.util.concurrent.CopyOnWriteArrayList
/**
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
*
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
* has no Cues element), this wrapper replaces it with a proportional byte-position
* estimate and scans for the nearest Cluster boundary after seeking.
* Two repairs, in priority order:
* - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
* proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
* - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
* seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
* before any track is known — the primary seek track stays unset even though the Cues parsed):
* wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
* this output.
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
@@ -38,6 +45,9 @@ class CuelessSeekExtractorWrapper(
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
/** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
private val registeredTracks = CopyOnWriteArrayList<Pair<Int, Int>>()
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
@@ -125,7 +135,13 @@ class CuelessSeekExtractorWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun track(id: Int, type: Int): TrackOutput {
if (registeredTracks.none { it.first == id }) {
registeredTracks.add(id to type)
}
return delegate.track(id, type)
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
@@ -137,6 +153,11 @@ class CuelessSeekExtractorWrapper(
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
} else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
isApproximateSeeking = false
delegate.seekMap(TrackCueSeekMap(seekMap))
return
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
@@ -166,4 +187,40 @@ class CuelessSeekExtractorWrapper(
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
/**
* Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
* unseekable overall. The per-track queries read the live cue data (populated once the Cues
* element parsed), so they resolve correctly even when the map was constructed before the
* Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
*/
private inner class TrackCueSeekMap(
private val delegate: TrackAwareSeekMap
) : SeekMap {
override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
override fun getDurationUs(): Long = delegate.durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
return delegate.getSeekPoints(timeUs, trackId)
}
/** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
private fun seekableTrackId(): Int? {
var audio: Int? = null
var fallback: Int? = null
for ((id, type) in registeredTracks) {
if (!delegate.isSeekable(id)) continue
when (type) {
C.TRACK_TYPE_VIDEO -> return id
C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
else -> if (fallback == null) fallback = id
}
}
return audio ?: fallback
}
}
}
@@ -0,0 +1,308 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* Packs DTS-HD (Master Audio) access units into IEC 61937 "DTS type IV" bursts (#1988).
*
* Some HDMI routes advertise `ENCODING_DTS_HD` but cannot actually carry Master Audio: Amazon
* specifies the Fire TV Stick 4K Max as "DTS-HD passthrough, basic profile", and Android has one
* ambiguous encoding constant for both profiles until API 34. Handing media3's raw path full MA
* frames there initialises an AudioTrack that drains normally and renders silence. The same
* devices do bitstream the 192kHz/7.1 `ENCODING_IEC61937` carrier — the split Kodi models with
* its "AudioTrack (IEC)" sink, and the one TrueHD already rides here (#1804, #1863) — so DTS-HD
* is packed onto that carrier instead.
*
* The algorithm is a port of FFmpeg's `spdif_header_dts`/`spdif_header_dts4`
* (libavformat/spdifenc.c, n8.1) at `dtshd_rate=768000`, the rate that fills the 8-channel/192kHz
* carrier; Kodi's `CAEBitstreamPacker::PackDTSHD` produces the same bytes.
*
* Output is one complete IEC 61937 burst per access unit:
*
* - 8-byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x11|subtype<<8 Pd=aligned bytes`
* - 10-byte DTS-HD start code, 16-bit big-endian payload size, then the access unit, all 16-bit
* byte-swapped
* - zero padding to the burst repetition period the core frame duration maps to
* (512 samples at 48kHz — the shape of essentially all Master Audio — is 32768 bytes)
*
* Unlike MAT's fixed frames, a Master Audio peak can genuinely exceed the carrier. FFmpeg answers
* by stripping such units to the always-fitting core substream and holding that for
* `dtshd_fallback_time` (60s) so receivers do not flap between core and MA decoding; that
* behavior is ported as-is and pinned by the golden fixture.
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class DtsHdIecPacker : IecCarrierPacker {
internal companion object {
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
private const val IEC61937_DTSHD = 0x11
/** Core and extension-substream sync words, big-endian raw framing. */
private const val SYNC_CORE = 0x7FFE8001
private const val SYNC_EXSS = 0x64582025
/**
* Little-endian and 14-bit core framings, from S/PDIF and DTS-in-WAV captures. They have no
* period mapping here (FFmpeg's HD path refuses them too), so they latch the stream
* unsupported and it decodes.
*/
private const val SYNC_CORE_LE = 0xFE7F0180.toInt()
private const val SYNC_CORE_14B_BE = 0x1FFFE800
private const val SYNC_CORE_14B_LE = 0xFF1F00E8.toInt()
/**
* The carrier's IEC 60958 frame rate as FFmpeg's two-channel model counts it: the burst
* repetition period is `period = 768000 * coreSamples / coreRate` IEC 60958 frames of 4 bytes,
* which is the same byte rate as [IecCarrier]'s 8 channels at 192kHz.
*/
private const val CARRIER_IEC958_RATE = 768_000
/** Core SFREQ index to Hz (`ff_dca_sample_rates`); zero marks invalid indices. */
private val CORE_SAMPLE_RATES = intArrayOf(
0, 8000, 16000, 32000, 0, 0, 11025, 22050, 44100, 0, 0, 12000, 24000, 48000, 96000, 192000
)
/** Precedes every burst payload; `spdifenc.c`'s `dtshd_start_code`. */
private val START_CODE = byteArrayOf(0x01, 0, 0, 0, 0, 0, 0, 0, 0xFE.toByte(), 0xFE.toByte())
/** Seconds of core-only output after an overflow; FFmpeg's `dtshd_fallback_time` default. */
private const val HD_STRIP_SECONDS = 60
/** Minimum bytes needed to read a core header through its SFREQ field. */
private const val MIN_CORE_HEADER_LENGTH = 9
/** IEC 61937-11 subtype for a burst repetition period in IEC 60958 frames, or -1. */
private fun subtypeForPeriod(period: Long): Int = when (period) {
512L -> 0
1024L -> 1
2048L -> 2
4096L -> 3
8192L -> 4
16384L -> 5
else -> -1
}
private fun readSyncWord(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 24) or
((data[offset + 1].toInt() and 0xFF) shl 16) or
((data[offset + 2].toInt() and 0xFF) shl 8) or
(data[offset + 3].toInt() and 0xFF)
/** Reads [count] (≤ 24) bits big-endian starting at absolute bit [bitPosition]. */
private fun readBits(data: ByteArray, bitPosition: Int, count: Int): Int {
var result = 0
var position = bitPosition
var remaining = count
while (remaining > 0) {
val byte = data[position ushr 3].toInt() and 0xFF
val bitsLeftInByte = 8 - (position and 7)
val take = minOf(bitsLeftInByte, remaining)
result = (result shl take) or ((byte shr (bitsLeftInByte - take)) and ((1 shl take) - 1))
position += take
remaining -= take
}
return result
}
/** `NBLKS + 1`: PCM blocks of 32 samples in the core frame at [offset]. */
private fun coreBlocks(data: ByteArray, offset: Int): Int {
val word = ((data[offset + 4].toInt() and 0xFF) shl 8) or (data[offset + 5].toInt() and 0xFF)
return ((word shr 2) and 0x7F) + 1
}
/** `FSIZE + 1`: the core frame's byte length. */
private fun coreFrameSize(data: ByteArray, offset: Int): Int {
val bits = ((data[offset + 5].toInt() and 0xFF) shl 16) or
((data[offset + 6].toInt() and 0xFF) shl 8) or
(data[offset + 7].toInt() and 0xFF)
return ((bits shr 4) and 0x3FFF) + 1
}
/** The core `SFREQ` field mapped to Hz; 0 for reserved indices. */
private fun coreSampleRate(data: ByteArray, offset: Int): Int = CORE_SAMPLE_RATES[((data[offset + 8].toInt() and 0xFF) shr 2) and 0x0F]
/**
* Total byte length of the extension substream at [offset] (`nuBits4ExSSFsize + 1`), or 0
* when the header does not fit in [limit].
*
* Header layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
* bHeaderSizeType(1), then a header-size field of 8 or 12 bits and this size field of 16 or
* 20 bits — at most 75 bits, so 10 bytes cover every shape.
*/
private fun extensionSubstreamSize(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 10 > limit) return 0
val base = offset shl 3
val wide = readBits(data, base + 42, 1) == 1
val sizeFieldPosition = base + 43 + if (wide) 12 else 8
return readBits(data, sizeFieldPosition, if (wide) 20 else 16) + 1
}
}
/** Burst geometry learned from the stream's core framing; zero until the first unit packs. */
private var burstBytes = 0
private var burstBacking = Array(2) { ByteArray(0) }
private var burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]) }
private var burstIndex = 0
/** Bytes each reusable buffer was dirtied to, so padding only clears what a prior burst wrote. */
private val dirtyEnd = intArrayOf(0, 0)
/** Assembles start code + size + access unit before the byte swap; sized with the bursts. */
private var payloadScratch = ByteArray(0)
/** Access units still to strip to their core after an overflow. */
private var hdStripRemaining = 0
/**
* Latched when a burst had to be stripped to the core substream, so the sink can log the
* downgrade once. Cleared by [reset].
*/
var strippedToCore = false
private set
override var unsupportedStream = false
private set
override fun reset() {
// Buffers and the learned burst geometry survive; only per-stream state drops. The flag is
// re-learned from the next unit; leaving it latched would silence every later stream.
hdStripRemaining = 0
strippedToCore = false
unsupportedStream = false
}
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 4 > limit) return 0
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
SYNC_EXSS -> {
// A stray HD frame without its core, seen at stream starts. Its size is walkable, so it
// is consumed as a unit and dropped by packAccessUnit; FFmpeg discards these too.
val size = extensionSubstreamSize(data, offset, limit)
return if (size < 4 || offset + size > limit) 0 else size
}
SYNC_CORE_LE, SYNC_CORE_14B_BE, SYNC_CORE_14B_LE -> {
unsupportedStream = true
return 0
}
else -> return 0
}
if (offset + MIN_CORE_HEADER_LENGTH > limit) return 0
val coreSize = coreFrameSize(data, offset)
var end = offset + coreSize
if (coreSize < MIN_CORE_HEADER_LENGTH || end > limit) return 0
// Master Audio glues one or more extension substreams to the core; they belong to this unit.
while (end + 4 <= limit && readSyncWord(data, end) == SYNC_EXSS) {
val size = extensionSubstreamSize(data, end, limit)
if (size < 4 || end + size > limit) return 0
end += size
}
return end - offset
}
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_CORE_HEADER_LENGTH) return null
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
// The stray leading HD frame accessUnitLength admitted; there is no core to derive a
// period from, so it is dropped rather than carried.
SYNC_EXSS -> return null
else -> return null
}
val blocks = coreBlocks(data, offset)
val coreSize = coreFrameSize(data, offset)
val sampleRate = coreSampleRate(data, offset)
if (sampleRate == 0) {
unsupportedStream = true
return null
}
val coreSamples = blocks shl 5
val periodProduct = CARRIER_IEC958_RATE.toLong() * coreSamples
val period = periodProduct / sampleRate
val newSubtype = if (periodProduct % sampleRate != 0L) -1 else subtypeForPeriod(period)
if (newSubtype < 0) {
// 44.1kHz-family cores and exotic frame lengths map to no IEC 61937-11 repetition period at
// this carrier rate; FFmpeg refuses them the same way, so the stream decodes instead.
unsupportedStream = true
return null
}
setBurstGeometry((period * 4).toInt())
// FFmpeg's overflow answer: a Master Audio peak the carrier cannot hold strips this and the
// next ~60 seconds of units to the always-fitting core substream, so the receiver does not
// flap between core and MA decoding.
if (START_CODE.size + 2 + length > burstBytes - BURST_HEADER_SIZE) {
hdStripRemaining = sampleRate * HD_STRIP_SECONDS / coreSamples
}
var payloadSize = length
if (hdStripRemaining > 0 && coreSize <= length) {
payloadSize = coreSize
hdStripRemaining--
strippedToCore = true
}
val payloadBytes = START_CODE.size + 2 + payloadSize
if (payloadBytes > burstBytes - BURST_HEADER_SIZE) {
// Even the bare core overflows this period (only possible for very short core frames);
// nothing can be carried.
unsupportedStream = true
return null
}
START_CODE.copyInto(payloadScratch, 0)
payloadScratch[START_CODE.size] = (payloadSize ushr 8).toByte()
payloadScratch[START_CODE.size + 1] = (payloadSize and 0xFF).toByte()
data.copyInto(payloadScratch, START_CODE.size + 2, offset, offset + payloadSize)
// A final lone byte goes out MSB-aligned; its swap partner must be zero, not stale scratch.
if (payloadBytes and 1 == 1) payloadScratch[payloadBytes] = 0
burstIndex = burstIndex xor 1
val backing = burstBacking[burstIndex]
putLittleEndianShort(backing, 0, SYNCWORD1)
putLittleEndianShort(backing, 2, SYNCWORD2)
putLittleEndianShort(backing, 4, IEC61937_DTSHD or (newSubtype shl 8))
// Aligned so (Pd & 0xF) == 0x8, which some receivers reportedly require; FFmpeg and Kodi
// both apply the same quirk.
putLittleEndianShort(backing, 6, ((payloadBytes + 0x17) and 0x0F.inv()) - BURST_HEADER_SIZE)
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
var source = 0
var destination = BURST_HEADER_SIZE
val swappedPayload = payloadBytes + (payloadBytes and 1)
while (source < swappedPayload) {
backing[destination] = payloadScratch[source + 1]
backing[destination + 1] = payloadScratch[source]
source += 2
destination += 2
}
if (destination < dirtyEnd[burstIndex]) {
java.util.Arrays.fill(backing, destination, dirtyEnd[burstIndex], 0)
}
dirtyEnd[burstIndex] = destination
val burst = burstBuffers[burstIndex]
burst.limit(burstBytes)
burst.position(0)
return burst
}
private fun setBurstGeometry(newBurstBytes: Int) {
if (newBurstBytes == burstBytes) return
burstBytes = newBurstBytes
burstBacking = Array(2) { ByteArray(newBurstBytes) }
burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) }
dirtyEnd[0] = 0
dirtyEnd[1] = 0
payloadScratch = ByteArray(newBurstBytes)
}
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
}
}
@@ -215,8 +215,12 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var assVideoLatencyFrames = 0
private var subtitlePositionPercent: Int = 100
private var subtitleFontSize: Float = 55f
private var subtitleAnchorToScreen: Boolean = false
private var lastSubtitleCues: List<Cue> = emptyList()
// Retain the inferred composition-plane aspect across empty cue groups.
private var bitmapSubtitlePlaneAspect: Float? = null
// Tracks whether a text track was selected on the previous onTracksChanged so we
// can detect the transition to "no subtitle" and clear the painted overlays (#1387).
private var hadSelectedTextTrack: Boolean = false
@@ -275,6 +279,29 @@ class ExoPlayerCore(private val activity: Activity) :
private var resumeStallRecoveryCount = 0
private var loggedResumeStallCap = false
// Buffering stall watchdog (#1790): a renderer that never becomes ready pins the player in
// STATE_BUFFERING with a full buffer and a frozen clock, and — when media3 absorbs the
// underlying failure instead of raising it — nothing else ever notices. Armed on every
// transition into buffering; see BufferingStallPolicy.
private var bufferingStallRunnable: Runnable? = null
private var bufferingStallSinceMs = 0L
private var bufferingStallBaselinePositionMs = 0L
/**
* The live load control, so the buffering watchdog can ask whether media3 itself considers the
* buffer sufficient to start rather than re-deriving that from durations.
*/
private var observingLoadControl: ObservingLoadControl? = null
/**
* The read-ahead limits this session actually resolved to, kept so [getStats] can report them.
* Both ceilings matter and the smaller one binds: with `prioritizeTimeOverSizeThresholds`
* disabled the byte target stops the loader even below `minBufferMs`, so a raised Maximum
* Buffer that changes nothing on a UHD remux is explained by these two numbers side by side.
*/
private var resolvedTargetBufferBytes: Int? = null
private var resolvedBufferDurations: LoadControlPolicy.BufferDurations? = null
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -287,6 +314,7 @@ class ExoPlayerCore(private val activity: Activity) :
private var lastAudioRecoveryReason: String? = null
private var lastAudioSinkError: String? = null
private var loggedEwasteEac3Workaround: Boolean = false
private val loggedDtsAppDecoderMimes = mutableSetOf<String>()
private var lastTrueHdDirectOutputLogKey: String? = null
private var loggedDecodedPcmTunnelingGuard: Boolean = false
private var hasRenderedVideoFrameForMedia: Boolean = false
@@ -314,6 +342,12 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var detectedFrameRate: Float = -1f
private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)
// Frame rate the media server reported for the open item, or -1 when unknown.
// Supplied per open because the extractors media3 uses for direct play (Matroska,
// MP4) never populate Format.frameRate, and the tunneled path renders no frames
// back to the app for detectedFrameRate to derive one from.
@Volatile private var contentFrameRate: Float = -1f
@Volatile private var fpsTimestampCount = 0
private var assSyncFrameCount = 0L
@@ -323,8 +357,17 @@ class ExoPlayerCore(private val activity: Activity) :
// Track state for event emission
private var lastPosition: Long = 0
/** Position to use for fallback: max of current position and pending start position. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs)
/**
* Highest position actually reached in the current media generation, or the last explicit seek
* target. [lastPosition] tracks the emitted timeline and follows the player down as well as up,
* so a renderer that reports 0 while its clock is dead would otherwise hand recovery a 0ms
* resume point — which is how an audio recovery restarted a resumed episode from the top
* (#1790). Recovery reads this instead.
*/
private var lastKnownGoodPositionMs: Long = 0L
/** Position to use for fallback: the furthest of the tracked positions. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs, lastKnownGoodPositionMs)
private var lastDuration: Long = 0
private var lastBufferedPosition: Long = 0
private var positionUpdateRunnable: Runnable? = null
@@ -525,7 +568,11 @@ class ExoPlayerCore(private val activity: Activity) :
bufferSizeBytes: Int? = null,
bufferSizeAuto: Boolean = false,
tunnelingEnabled: Boolean = true,
audioPassthroughEnabled: Boolean = false
audioPassthroughEnabled: Boolean = false,
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
// is internal and this function is not; unrecognised names resolve to Auto, which is also
// the default (#1816).
bufferTier: String = "auto"
): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -684,7 +731,9 @@ class ExoPlayerCore(private val activity: Activity) :
val handler = AssHandler()
assHandler = handler
val assParserFactory = AssSubtitleParserFactory(handler)
// PGS goes through PgsCompositionParser; media3's parser drops all but one
// composition object and blanks palette-only fade updates (#1953).
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
// wrap MP4 extractors with DV converter when enabled.
@@ -696,7 +745,7 @@ class ExoPlayerCore(private val activity: Activity) :
extractorsFactory.createExtractors().map { extractor ->
when {
extractor is MatroskaExtractor -> {
val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler)
val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
val inner = if (doviEnabled) {
DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
@@ -722,7 +771,7 @@ class ExoPlayerCore(private val activity: Activity) :
}
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
.setSubtitleParserFactory(assParserFactory)
.setSubtitleParserFactory(subtitleParserFactory)
playbackMediaSourceFactory = mediaSourceFactory
// Wrap text renderers with subtitle delay support
@@ -748,20 +797,32 @@ class ExoPlayerCore(private val activity: Activity) :
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
}
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
resolvedTargetBufferBytes = targetBufferBytes
resolvedBufferDurations = bufferDurations
val loadControl = DefaultLoadControl.Builder().apply {
setTargetBufferBytes(targetBufferBytes)
setPrioritizeTimeOverSizeThresholds(false)
if (availableMB <= 2048) {
setBufferDurationsMs(15_000, 50_000, 1_000, 5_000)
} else {
setBufferDurationsMs(30_000, 60_000, 1_000, 5_000)
}
}.build()
// Generic setter only. media3 1.9 added ...ForStreaming/...ForLocalPlayback variants and a
// latch that stops mirroring these into the local-playback fields the moment either is
// called, which would silently give file:// playback its own defaults.
setBufferDurationsMs(
bufferDurations.minBufferMs,
bufferDurations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}.build().let { ObservingLoadControl(it).also { observing -> observingLoadControl = observing } }
emitLog(
"info",
"init",
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
"heap=${largeHeapMB}MB, available=${availableMB}MB), tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
"(${resolvedTier.name.lowercase()}), " +
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
)
exoPlayer = ExoPlayer.Builder(activity)
@@ -963,6 +1024,7 @@ class ExoPlayerCore(private val activity: Activity) :
// classifies an EOF by position against duration.
if (currentPosition != lastPosition && terminalErrorGeneration != currentMediaGeneration) {
lastPosition = currentPosition
if (currentPosition > lastKnownGoodPositionMs) lastKnownGoodPositionMs = currentPosition
delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
}
@@ -1004,6 +1066,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun resetPlaybackProgress(startPositionMs: Long) {
lastPosition = startPositionMs
lastKnownGoodPositionMs = startPositionMs
lastDuration = 0L
lastBufferedPosition = 0L
// Dart already seeds the visible timeline before open. Emitting native
@@ -1053,11 +1116,31 @@ class ExoPlayerCore(private val activity: Activity) :
private fun renderSubtitleCues(cues: List<Cue>) {
val textCues = cues.filter { it.bitmap == null }
val bitmapCues = cues.filter { it.bitmap != null }
updateBitmapSubtitlePlaneAspect(bitmapCues)
val outgoing = SubtitleCueLayout.layout(textCues, subtitlePositionPercent, subtitleFontSize)
subtitleView?.setCues(outgoing)
bitmapSubtitleView?.setCues(bitmapCues)
}
private fun updateBitmapSubtitlePlaneAspect(bitmapCues: List<Cue>) {
val inferredAspect = bitmapCues.firstNotNullOfOrNull { cue ->
val bitmap = cue.bitmap ?: return@firstNotNullOfOrNull null
SubtitleViewLayout.bitmapPlaneAspect(
bitmap.width,
bitmap.height,
cue.size,
cue.bitmapHeight
)
} ?: return
val previousAspect = bitmapSubtitlePlaneAspect
if (previousAspect != null && kotlin.math.abs(inferredAspect / previousAspect - 1f) <= 0.001f) return
bitmapSubtitlePlaneAspect = inferredAspect
lastVideoSize?.let {
updateSubtitleViewSize(it.width, it.height, it.pixelWidthHeightRatio)
}
}
private fun handleIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged: $isPlaying")
// A pause/resume can occur entirely between watchdog polls while the
@@ -1083,8 +1166,10 @@ class ExoPlayerCore(private val activity: Activity) :
when (state) {
Player.STATE_BUFFERING -> {
delegate?.onPropertyChange("paused-for-cache", true)
armBufferingStallWatchdog()
}
Player.STATE_READY -> {
cancelBufferingStallWatchdog()
// Restore start position if it was lost during track reselection
// (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
if (pendingStartPositionMs > 0L) {
@@ -1120,6 +1205,7 @@ class ExoPlayerCore(private val activity: Activity) :
}
Player.STATE_ENDED -> {
stopFrameWatchdog()
cancelBufferingStallWatchdog()
delegate?.onPropertyChange("eof-reached", true)
delegate?.onEvent("end-file", mapOf("reason" to "eof"))
}
@@ -1170,12 +1256,13 @@ class ExoPlayerCore(private val activity: Activity) :
}
}
// Disabling the text track produces no trailing empty CueGroup, and no new
// video frame re-renders the libass overlay while paused, so the last SRT/VTT
// cue stays painted on the SubtitleViews and the last ASS frame stays on the
// overlay. AssHandler (registered before this listener) has already nulled the
// libass track by now, so re-rendering the last position clears it. Gate on the
// transition to avoid redundant clears on every track change. (#1387)
// Disabling the text track produces no trailing empty CueGroup, so the last
// SRT/VTT cue stays painted on the SubtitleViews. AssHandler (registered
// before this listener) has already nulled the libass track by now, so the
// invalidate pushes one request through the atlas pipeline, which swaps an
// explicit blank frame for a trackless render — required while paused, where
// no video frame triggers one. Gate on the transition to avoid redundant
// clears on every track change. (#1387, #1884)
val hasSelectedText = hasSelectedTextTrack(tracks)
if (!hasSelectedText && hadSelectedTextTrack) {
lastSubtitleCues = emptyList()
@@ -1206,6 +1293,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
emitSeekable(false, force = true)
// If native DV7 failed, retry with conversion before falling to MPV
@@ -1346,7 +1434,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousDecoder = decoderInitName
pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
@@ -1368,6 +1456,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "video decoder recovery",
forceSelector = true,
@@ -1387,6 +1476,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -1413,33 +1504,51 @@ class ExoPlayerCore(private val activity: Activity) :
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
if (!isAudioTrackError(error.errorCode)) return false
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
return recoverAudioOutputInPlace(
label = PlaybackException.getErrorCodeName(error.errorCode),
reason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}",
fallbackFormat = errorFormat,
blockDirectOutput = true
)
}
/**
* Re-prepares the current media with the audio output forced onto a path that has not just
* failed. Shared by the [PlaybackException] route and by the buffering stall watchdog, which
* reaches the same failure without an exception ever being raised (#1790).
*/
private fun recoverAudioOutputInPlace(
label: String,
reason: String,
fallbackFormat: Format?,
blockDirectOutput: Boolean
): Boolean {
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
if (audioRecoveryAttempts >= MAX_AUDIO_RECOVERY_ATTEMPTS) {
emitLog(
"warn",
"audio-recovery",
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for ${PlaybackException.getErrorCodeName(error.errorCode)}"
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for $label"
)
return false
}
val selectedFormat = selectedAudioFormat()
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
val recoveryFormat = selectedFormat ?: errorFormat
val recoveryFormat = selectedAudioFormat() ?: fallbackFormat
val actions = mutableListOf<String>()
recoveryFormat?.sampleMimeType
?.takeIf { isEncodedAudioMimeType(it) }
?.let { mimeType ->
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
actions.add("force-decoded-pcm($mimeType)")
if (blockDirectOutput) {
recoveryFormat?.sampleMimeType
?.takeIf { isEncodedAudioMimeType(it) }
?.let { mimeType ->
if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
actions.add("force-decoded-pcm($mimeType)")
}
}
}
}
if (!tunnelingDisabledForAudioRecovery) {
tunnelingDisabledForAudioRecovery = true
actions.add("disable-tunneling")
@@ -1447,7 +1556,7 @@ class ExoPlayerCore(private val activity: Activity) :
if (actions.isEmpty()) actions.add("reload")
audioRecoveryAttempts++
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousAudioTrackConfig = lastAudioTrackConfig
pendingStartPositionMs = savedPosition
@@ -1455,11 +1564,12 @@ class ExoPlayerCore(private val activity: Activity) :
audioDecoderInitName = null
lastAudioTrackConfig = null
lastAudioRecoveryAction = actions.joinToString(",")
lastAudioRecoveryReason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}"
lastAudioRecoveryReason = reason
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)
emitLog(
@@ -1473,6 +1583,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -1673,7 +1785,8 @@ class ExoPlayerCore(private val activity: Activity) :
videoHeight,
pixelRatio,
resizeMode,
videoZoomScale
videoZoomScale,
subtitleAnchorToScreen
)
val bitmapDimensions = SubtitleViewLayout.bitmapDimensions(
containerWidth,
@@ -1681,8 +1794,7 @@ class ExoPlayerCore(private val activity: Activity) :
videoWidth,
videoHeight,
pixelRatio,
resizeMode,
videoZoomScale
bitmapSubtitlePlaneAspect
)
activity.runOnUiThread {
@@ -1877,14 +1989,15 @@ class ExoPlayerCore(private val activity: Activity) :
subtitleTrackGroupMap[trackId] = trackGroup
val isSelected = group.isSelected
// Detect external (side-loaded) subtitle by the ID prefix set in open()
val isExternal = format.id?.startsWith("external_") == true
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
// Detect external (side-loaded) subtitle by the ID set in open(). media3
// rewrites merged child ids, so the tag is not the whole id.
val isExternal = ExternalSubtitleIds.isExternal(format.id)
val externalIndex = ExternalSubtitleIds.indexOf(format.id)
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
Log.d(TAG, "Subtitle track $groupIndex: formatId=${format.id}, codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
val track = mutableMapOf<String, Any?>(
"type" to "sub",
@@ -2206,7 +2319,20 @@ class ExoPlayerCore(private val activity: Activity) :
}
return true
}
return false
// DTS that is going to decode must not decode in a platform codec: on license-gated
// Amlogic boxes (the Onn family) the platform decoder drains normally while rendering
// silence (#1995). Bitstream-capable routes are untouched — media3 selects direct output
// before consulting the decoder list, and the visible platform decoder keeps the
// tunneling gate as it was.
val forceDts = shouldForceFfmpegDtsDecode(
mimeType,
directOutputBlocked = { shouldBlockDirectAudioOutput(dtsProbeFormat(mimeType), "decoder selection") },
routeCanBitstreamDts = { routeCanBitstreamDts(mimeType) }
)
if (forceDts && loggedDtsAppDecoderMimes.add(mimeType)) {
emitLog("info", "decoder", "Using app decoder for $mimeType; the stream will decode and platform DTS decoders render silence on license-gated devices")
}
return forceDts
}
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
@@ -2315,6 +2441,33 @@ class ExoPlayerCore(private val activity: Activity) :
.build()
}
/**
* Whether the current route can bitstream [mimeType] at all: media3's raw direct path
* ([AudioCapabilities]) or, for DTS-HD, the IEC 61937 carrier ([IecCarrierSink]). When this
* is false the stream decodes regardless of the passthrough setting.
*/
private fun routeCanBitstreamDts(mimeType: String): Boolean {
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
val audioAttributes = buildMovieAudioAttributes()
return try {
AudioCapabilities
.getCapabilities(activity, audioAttributes, null)
.isPassthroughPlaybackSupported(dtsProbeFormat(mimeType), audioAttributes)
} catch (e: Exception) {
// An unanswerable probe biases toward FFmpeg decode. A wrong "can't bitstream" is benign
// (bypass still wins before decoder selection); a wrong "can" leaves the silent platform
// decode path reachable.
false
}
}
/** DTS selection probe at the family's common shape; decoder selection only knows the mime. */
private fun dtsProbeFormat(mimeType: String): Format = Format.Builder()
.setSampleMimeType(mimeType)
.setChannelCount(6)
.setSampleRate(48_000)
.build()
@RequiresApi(Build.VERSION_CODES.Q)
@Suppress("DEPRECATION")
private fun isDirectPlaybackSupportedApi29(
@@ -2536,6 +2689,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return null
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
return tunnelingUserEnabled &&
!DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate) &&
(player.playbackParameters.speed == 1f) &&
!tunnelingDisabledForCodec &&
!tunnelingDisabledForAssSubtitles &&
@@ -2631,15 +2785,6 @@ class ExoPlayerCore(private val activity: Activity) :
return selectedAudioGroup.mediaTrackGroup.getFormat(0)
}
private fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
private fun formatAudioSummary(format: Format): String {
val parts = mutableListOf<String>()
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
@@ -3123,6 +3268,114 @@ class ExoPlayerCore(private val activity: Activity) :
}
}
// Buffering stall watchdog (#1790) — see BufferingStallPolicy for the coverage argument.
// Main-thread only, like the watchdogs above.
private fun armBufferingStallWatchdog() {
cancelBufferingStallWatchdog()
val player = exoPlayer ?: return
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = player.currentPosition
// Fresh window: do not judge it on a verdict media3 gave for the previous one.
observingLoadControl?.reset()
val mediaGeneration = currentMediaGeneration
bufferingStallRunnable = object : Runnable {
override fun run() {
if (mediaGeneration != currentMediaGeneration) return
if (disposing || !isInitialized) return
val current = exoPlayer ?: return
if (current.playbackState != Player.STATE_BUFFERING) {
cancelBufferingStallWatchdog()
return
}
val now = System.currentTimeMillis()
// Paused mid-buffer: nothing is meant to progress, so the clock does not run.
if (!current.playWhenReady) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
return
}
val elapsedMs = now - bufferingStallSinceMs
val verdict = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = bufferingStallBaselinePositionMs,
currentPositionMs = current.currentPosition,
bufferedPositionMs = current.bufferedPosition,
// The load control's bar is in playout time, so a fast-forward legitimately needs
// proportionally more media. Only used when media3 has not answered for itself yet.
playbackSpeed = current.playbackParameters.speed,
// media3's own verdict, which also covers the byte-target release no duration comparison
// can express — but it is only asked once the renderers are ready.
loadControlReady = observingLoadControl?.startPlaybackVerdict,
// A loader that has stopped asking for data has all it wants, whatever the duration says.
// This is the signal that survives a renderer which never becomes ready.
loading = current.isLoading
)
if (verdict == BufferingStallPolicy.Verdict.STALLED) {
cancelBufferingStallWatchdog()
recoverFromBufferingStall(current, mediaGeneration, elapsedMs)
return
}
// Moving again, or still short of the load control's play-start threshold: either way the
// clock was not measuring a stall this watchdog owns, so it restarts.
if (BufferingStallPolicy.resetsStallClock(verdict)) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
}
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
}
handler.postDelayed(bufferingStallRunnable!!, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
private fun cancelBufferingStallWatchdog() {
bufferingStallRunnable?.let { handler.removeCallbacks(it) }
bufferingStallRunnable = null
}
private fun recoverFromBufferingStall(player: ExoPlayer, mediaGeneration: Int, stalledMs: Long) {
val positionMs = player.currentPosition
val playWhenReady = player.playWhenReady
val sinkError = lastAudioSinkError
emitLog(
"warn",
"buffering-stall",
"No progress for ${stalledMs}ms at ${positionMs}ms while buffering " +
"(buffered=${player.bufferedPosition}ms, audio=${selectedAudioFormat()?.let { formatAudioSummary(it) } ?: "unknown"}, " +
"tunneling=$currentTunneledPlayback, lastSinkError=${sinkError ?: "none"})"
)
// A sink error with no exception behind it is the shape media3 absorbs, so take the audio
// path off bitstream. Without one, the reload alone is the unstick.
if (recoverAudioOutputInPlace(
label = "buffering stall",
reason = "buffering stall after ${stalledMs}ms: ${sinkError ?: "no sink error reported"}",
fallbackFormat = null,
blockDirectOutput = sinkError != null
)
) {
// recoverAudioOutputInPlace re-arms the watchdog itself, so the retry is watched and can
// escalate to the handover below.
return
}
// In-place recovery is spent. Hand over to the MPV backend, which has its own audio path,
// rather than leaving the user on a spinner.
val uri = currentMediaUri ?: return
requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri,
// Not the raw position: a failed sink can report 0, which would restart a resumed episode from
// the beginning. Every other fallback path hands over the tracked position for the same reason.
positionMs = maxOf(positionMs, effectivePosition),
playWhenReady = playWhenReady,
errorMessage = "Playback stalled while buffering for ${stalledMs}ms"
)
}
// Public API
fun open(
@@ -3132,18 +3385,21 @@ class ExoPlayerCore(private val activity: Activity) :
autoPlay: Boolean,
mediaGeneration: Int,
isLive: Boolean = false,
externalSubtitleList: List<Map<String, Any?>>? = null
externalSubtitleList: List<Map<String, Any?>>? = null,
contentFrameRate: Float = -1f
) {
if (!isInitialized) return
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
resumeStallRecoveryCount = 0
loggedResumeStallCap = false
// Reset FPS detection for new content
detectedFrameRate = -1f
this.contentFrameRate = contentFrameRate
fpsTimestampCount = 0
assSyncFrameCount = 0
@@ -3191,6 +3447,7 @@ class ExoPlayerCore(private val activity: Activity) :
externalSubtitleUris.clear()
externalSubtitleContainerUris.clear()
lastSubtitleCues = emptyList()
bitmapSubtitlePlaneAspect = null
hadSelectedTextTrack = false
audioTrackGroupMap.clear()
subtitleTrackGroupMap.clear()
@@ -3222,7 +3479,7 @@ class ExoPlayerCore(private val activity: Activity) :
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
.setId("external_$index")
.setId(ExternalSubtitleIds.idFor(index))
.setLabel(title ?: "External")
.setLanguage(language)
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
@@ -3254,14 +3511,30 @@ class ExoPlayerCore(private val activity: Activity) :
)
emitSeekable(false, force = true)
// Only here: this is the one caller that is a genuinely new item. The recovery, DV-mode and
// subtitle reloads all reuse setCurrentMediaSource for the *same* stream, and clearing
// per-stream audio decisions there would undo them and loop.
renderersFactory?.beginMediaItem()
exoPlayer?.apply {
setCurrentMediaSource(this, uri, startPositionMs)
prepare()
playWhenReady = autoPlay
// Same reason as the recovery reloads: replacing the source while the previous item was
// already buffering produces no state change, so nothing would arm the watchdog for this
// generation. The runnable cancels itself as soon as the player is not buffering.
armBufferingStallWatchdog()
}
val sourceLabel = if (isLive) "live HLS" else "media"
emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
emitLog(
"info",
"media",
"Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, " +
"contentFps=${if (contentFrameRate > 0f) contentFrameRate.toString() else "unknown"}, " +
"sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled, " +
"tunnelingStatus=${exoPlayer?.let(::getTunnelingStatus) ?: "n/a"}"
)
}
fun setAudioDelay(seconds: Double) {
@@ -3450,7 +3723,7 @@ class ExoPlayerCore(private val activity: Activity) :
val uri = currentMediaUri ?: return false
if (currentMediaIsLive) return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
pendingTrackRestore = captureTrackRestore()?.also { restore ->
emitLog(
@@ -3478,6 +3751,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "DV reload",
@@ -3489,6 +3763,10 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Replacing the source while the player is already buffering produces no state change, and that
// change is the only thing that arms the stall watchdog — so arm it here or a reload that never
// becomes ready has nothing left watching it.
armBufferingStallWatchdog()
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
return true
}
@@ -3507,6 +3785,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
exoPlayer?.stop()
emitSeekable(false, force = true)
setVisible(false)
@@ -3526,6 +3805,18 @@ class ExoPlayerCore(private val activity: Activity) :
pendingStartPositionMs = 0L
player.seekTo(clampedPositionMs)
lastPosition = clampedPositionMs
lastKnownGoodPositionMs = clampedPositionMs
// A seek discards whatever the stall watchdog was measuring: it compares against a baseline
// position, and the policy reads a lower position as stalled, so a backward seek during a
// legitimate buffered wait would inherit the pre-seek stall time and trip recovery on the next
// poll. Re-baseline here so the new position gets the full timeout.
if (bufferingStallRunnable != null) {
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = clampedPositionMs
// The load control's recorded verdict belongs to the buffer this seek just discarded, and the
// policy prefers it over the live loader state, so a stale answer would decide the new window.
observingLoadControl?.reset()
}
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
}
@@ -3585,7 +3876,7 @@ class ExoPlayerCore(private val activity: Activity) :
val existingIndex = externalSubtitleUris.indexOf(uri)
val isNew = existingIndex < 0
val index = if (isNew) externalSubtitles.size else existingIndex
val formatId = "external_$index"
val formatId = ExternalSubtitleIds.idFor(index)
if (isNew) {
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
@@ -3631,10 +3922,14 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, mediaUri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Same-state source replacement: media3 reports no change when the player was already
// buffering, so arm here or this window keeps the previous one's watchdog and verdict.
armBufferingStallWatchdog()
} else {
// Already attached — select the existing track via override.
// Already attached — select the existing track via override. The
// reported id carries media3's merge prefixes, so compare the tag.
val trackId = subtitleTrackGroupMap.entries
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
.firstOrNull { (_, group) -> ExternalSubtitleIds.indexOf(group.getFormat(0).id) == index }
?.key
if (trackId != null) {
selectSubtitleTrack(trackId)
@@ -3685,7 +3980,8 @@ class ExoPlayerCore(private val activity: Activity) :
bgOpacity: Int,
subtitlePosition: Int = 100,
bold: Boolean = false,
italic: Boolean = false
italic: Boolean = false,
anchorToScreen: Boolean = false
) {
activity.runOnUiThread {
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
@@ -3733,6 +4029,18 @@ class ExoPlayerCore(private val activity: Activity) :
subtitlePositionPercent = clampedPosition
subtitleFontSize = fontSize
// Anchor-to-screen (#1730): resize the text SubtitleView to the full
// container so default-placed cues land in the letterbox bars.
val anchorChanged = subtitleAnchorToScreen != anchorToScreen
subtitleAnchorToScreen = anchorToScreen
if (anchorChanged) {
lastVideoSize?.let { vs ->
if (vs.width > 0 && vs.height > 0) {
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
}
}
}
// Cue-level positioning handles default VTT/SRT placement, whose line
// numbers bypass SubtitleView bottom padding. Authored VTT line positions
// are preserved in applySubtitlePosition().
@@ -3754,7 +4062,7 @@ class ExoPlayerCore(private val activity: Activity) :
Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
}
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale")
Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, anchorToScreen=$anchorToScreen, assScale=$scale")
}
}
@@ -3926,6 +4234,10 @@ class ExoPlayerCore(private val activity: Activity) :
// Buffer metrics
"bufferedPositionMs" to player.bufferedPosition,
"currentPositionMs" to player.currentPosition,
// Both read-ahead ceilings. The smaller binds, so a Maximum Buffer that appears to do
// nothing on a high bitrate file is explained by the byte target sitting beside it.
"bufferTargetBytes" to resolvedTargetBufferBytes,
"bufferMaxMs" to resolvedBufferDurations?.maxBufferMs,
"totalBufferedDurationMs" to player.totalBufferedDuration,
// Playback state
"playbackSpeed" to player.playbackParameters.speed,
@@ -3971,6 +4283,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun getTunnelingStatus(player: ExoPlayer): String {
if (currentTunneledPlayback) return "Active"
if (!tunnelingUserEnabled) return "Disabled by user"
if (DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate)) return "Off (unreliable on this device)"
if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
if (audioNormalizationEnabled) return "Off (loudness normalization)"
if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
@@ -4010,6 +4323,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
stopPositionUpdates()
handler.removeCallbacksAndMessages(null)
// releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
@@ -61,9 +61,16 @@ class ExoPlayerPlugin :
val headers: Map<String, String>?,
val startPositionMs: Long,
val hasStartPosition: Boolean,
val autoPlay: Boolean,
/**
* Whether to start playing once open. Mutable because a play or pause arriving while this
* request is queued behind a backend handover has no player to reach — the outgoing core is
* being disposed and the replacement does not exist yet — so the latest intent is recorded here
* instead. Without that, a pause the caller was told succeeded is silently undone by the open.
*/
var autoPlay: Boolean,
val isLive: Boolean,
val externalSubtitles: List<Map<String, Any?>>?,
val contentFrameRate: Float,
private val result: MethodChannel.Result?
) {
private val completed = AtomicBoolean(false)
@@ -294,6 +301,9 @@ class ExoPlayerPlugin :
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
configuredBufferSizeBytes = bufferSizeBytes
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
@@ -319,6 +329,14 @@ class ExoPlayerPlugin :
fallbackInProgress = false
}
// An initialize without an intervening dispose would otherwise orphan the previous core
// along with its ExoPlayer, audio sink, codecs and surface views — nothing else holds a
// reference, so they would never be released.
playerCore?.let { stale ->
playerCore = null
stale.dispose()
}
try {
val core = ExoPlayerCore(currentActivity).apply {
delegate = this@ExoPlayerPlugin
@@ -329,7 +347,8 @@ class ExoPlayerPlugin :
bufferSizeBytes = bufferSizeBytes,
bufferSizeAuto = bufferSizeAuto,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled
audioPassthroughEnabled = audioPassthroughEnabled,
bufferTier = bufferTier
)
if (!success) {
if (playerCore === core) playerCore = null
@@ -371,6 +390,8 @@ class ExoPlayerPlugin :
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
// Server-reported frame rate for this item; -1 when the metadata did not carry one.
val contentFrameRate = call.argument<Number>("contentFrameRate")?.toFloat() ?: -1f
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
@@ -391,6 +412,7 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
isLive = isLive,
externalSubtitles = externalSubtitles?.map { it.toMap() },
contentFrameRate = contentFrameRate,
result = result
)
terminalEventGeneration = null
@@ -450,7 +472,8 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
mediaGeneration = request.mediaGeneration,
isLive = isLive,
externalSubtitleList = request.externalSubtitles
externalSubtitleList = request.externalSubtitles,
contentFrameRate = request.contentFrameRate
)
request.success()
}
@@ -811,6 +834,9 @@ class ExoPlayerPlugin :
}
private fun handlePlay(result: MethodChannel.Result) {
// A backend handover leaves nothing to command: record the intent on the queued open instead,
// so the replacement starts the way the caller last asked.
pendingOpen?.autoPlay = true
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "no", result)
return
@@ -822,6 +848,10 @@ class ExoPlayerPlugin :
}
private fun handlePause(result: MethodChannel.Result) {
// See handlePlay. This direction matters more: the caller is told the pause succeeded, and a
// queued open that still carried autoPlay would start playing anyway — on a car, that is
// playback after the vehicle said no.
pendingOpen?.autoPlay = false
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "yes", result)
return
@@ -1039,6 +1069,7 @@ class ExoPlayerPlugin :
val subtitlePosition = call.argument<Number>("subtitlePosition")?.toInt() ?: 100
val bold = call.argument<Boolean>("bold") ?: false
val italic = call.argument<Boolean>("italic") ?: false
val anchorToScreen = call.argument<Boolean>("anchorToScreen") ?: false
if (usingMpvFallback) {
// MPV fallback handles styling via setProperty, no-op here
@@ -1046,7 +1077,7 @@ class ExoPlayerPlugin :
return
}
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic)
playerCore?.setSubtitleStyle(fontSize, textColor, borderSize, borderColor, bgColor, bgOpacity, subtitlePosition, bold, italic, anchorToScreen)
result.success(null)
}
@@ -1282,7 +1313,7 @@ class ExoPlayerPlugin :
val bufferSize = configuredBufferSizeBytes
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("vo", "gpu-next,gpu")
core.setProperty("ao", "audiotrack")
if (bufferSize != null && bufferSize > 0) {
@@ -1386,6 +1417,8 @@ class ExoPlayerPlugin :
autoPlay = playWhenReady,
isLive = false,
externalSubtitles = currentExternalSubtitles?.map { it.toMap() },
// The mpv fallback core paces frames itself; the rate only gates ExoPlayer tunneling.
contentFrameRate = -1f,
result = null
)
fallbackInProgress = true
@@ -0,0 +1,40 @@
package com.edde746.plezy.exoplayer
/**
* Identity of a side-loaded subtitle across media3's media-source merging.
*
* Each `MediaItem.SubtitleConfiguration` is tagged with [idFor], and the tag is
* read back off the `Format` the track selector reports. The tag does not
* survive verbatim: `DefaultMediaSourceFactory.createMediaSource` always wraps
* the primary source plus one source per subtitle configuration in a
* `MergingMediaSource`, and since media3 1.3.0 `MergingMediaPeriod.onPrepared`
* rewrites every child format id to `"<periodIndex>:<originalId>"`. A second
* merge — the one this player builds for container sidecars — prefixes it
* again. So the same configuration can surface as `external_0`,
* `1:external_0`, or `0:1:external_0`.
*
* Only the final `:`-separated segment carries the tag, which is why matching
* on the whole id silently classifies every sidecar as an embedded track.
*/
internal object ExternalSubtitleIds {
private const val PREFIX = "external_"
/** Tag written onto the [index]th side-loaded subtitle configuration. */
fun idFor(index: Int): String = "$PREFIX$index"
/** Whether [formatId] identifies a side-loaded subtitle. */
fun isExternal(formatId: String?): Boolean = tagOf(formatId) != null
/**
* Index passed to [idFor], or null when [formatId] is not a side-loaded
* subtitle or carries a tag this build did not write.
*/
fun indexOf(formatId: String?): Int? = tagOf(formatId)?.toIntOrNull()
private fun tagOf(formatId: String?): String? {
// substringAfterLast returns the whole string when no ':' is present, which
// is the unmerged case.
val segment = formatId?.substringAfterLast(':') ?: return null
return if (segment.startsWith(PREFIX)) segment.removePrefix(PREFIX) else null
}
}
@@ -0,0 +1,50 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* The IEC 61937 carrier tuple every packed bitstream rides: 192kHz, 7.1, PCM-16 shaped.
*
* This is the high-bitrate HDMI shape Kodi uses for both TrueHD/MAT and DTS-HD Master Audio, and
* the one [supportsIecCarrier] probes. The 44.1kHz family would need a 176.4kHz sibling, which is
* deliberately not built; streams from that family decode instead.
*/
internal object IecCarrier {
const val SAMPLE_RATE = 192_000
const val CHANNEL_COUNT = 8
const val BYTES_PER_FRAME = CHANNEL_COUNT * 2
}
/**
* Splits one codec's bitstream into access units and packs them into IEC 61937 bursts for
* [IecCarrierSink].
*
* Implementations are not thread safe; the sink drives them from the playback thread only.
*/
internal interface IecCarrierPacker {
/**
* Length in bytes of the access unit starting at [offset], or 0 when no unit boundary is
* recognised there. May latch [unsupportedStream] when the boundary is recognisable but names a
* framing this carrier cannot ride.
*/
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int
/**
* Packs one access unit, returning a completed burst or null when this unit did not finish one.
*
* Returned buffers are owned by the packer and reused, alternating between two so a burst handed
* downstream stays valid while the next one fills. Callers must submit or copy it before the
* second following call.
*/
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer?
/**
* True when the bitstream announced a shape this carrier cannot ride. The packer emits nothing
* in that state; the sink latches the stream onto the decoder instead.
*/
val unsupportedStream: Boolean
/** Drops all carrier state. Called on flush/seek: bursts must not straddle a discontinuity. */
fun reset()
}
@@ -0,0 +1,463 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioSink
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicInteger
/**
* Routes Dolby TrueHD (#1804) and DTS-HD Master Audio (#1988) through an IEC 61937 carrier, and
* everything else through the normal sink.
*
* Android will not bitstream raw TrueHD on the TV routes measured for #1804: the platform reports
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
* bitstream-capable. Raw `ENCODING_DTS_HD` is worse on Fire OS: the route advertises it, the
* AudioTrack initialises and drains, and the receiver hears silence, because the encoding only
* means "basic profile" there (#1988). Kodi models the same split and packs the carrier itself;
* media3 only ever hands Android the raw encodings, at the stream rate. This sink adds the
* missing path, with one [IecCarrierPacker] per codec.
*
* **Why two delegates rather than one sink with the processors held inactive.** The carrier is a
* bit-exact byte stream that happens to be shaped like PCM. Any sample mutation — downmix, Sonic,
* silence skipping, float conversion — turns it into full-scale noise at the receiver. Keeping the
* carrier on a delegate built with an empty [AudioProcessorChain] makes that impossible by
* construction. The alternative, asserting the normal chain stays inactive, puts the guarantee in a
* different class from the thing it protects, so adding a processor later would silently break
* playback in the loudest possible way.
*
* [carrierSink] must therefore be built with no audio processors and with an output provider whose
* `AudioTrack` is forced to `ENCODING_IEC61937`; see [PlezyRenderersFactory].
*
* Selection happens only in [configure]. Persistent controls and the listener are mirrored to both
* delegates so either can be activated later; per-stream calls go to the active one alone.
*/
@OptIn(UnstableApi::class)
internal class IecCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
private val carrierRouteAvailable: () -> Boolean,
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
private val directOutputBlocked: (Format) -> Boolean,
private val log: ((String, String, String) -> Unit)? = null
) : AudioSink {
private val matPacker = TrueHdMatPacker()
private val dtsHdPacker = DtsHdIecPacker()
/** The packer for the configured stream; chosen by mime type in [configure]. */
private var activePacker: IecCarrierPacker = matPacker
private var active: AudioSink = defaultSink
private var carrierActive = false
private var loggedCoreStrip = false
/** A burst the delegate refused; it must be placed before any further input is consumed. */
private var pendingBurst: ByteBuffer? = null
private var pendingBurstTimeUs: Long = 0
/**
* Anchor for carrier timestamps, and how many carrier frames have been emitted since it.
*
* Every burst is a whole number of carrier frames, so timestamps are derived from the cadence
* rather than from whichever access unit happened to close the burst. Handing the sink the
* closing unit's own presentation time drifts against the time it derives from written frames,
* which it reports as a discontinuity on nearly every burst. Counting frames rather than bursts
* keeps the arithmetic exact for burst durations that are not whole microseconds (a DTS-HD
* burst is 10666.67us).
*/
private var carrierAnchorUs: Long = C.TIME_UNSET
private var carrierFramesSinceAnchor: Long = 0
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var sinkListener: AudioSink.Listener? = null
/**
* Latched when a stream's bitstream contradicts the shape its container announced, which
* selection was made from.
*
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
* audio renderers share this sink, so the outgoing renderer's reset lands here in the middle of
* the very handover this latch exists to cause. Clearing it there would re-offer the carrier and
* loop straight back into the mismatch.
*
* The real boundary is a new media item, which only the caller knows and which happens before
* selection asks anything. [beginMediaItem] is that hook; without it one malformed item would
* cost bitstreaming for every later item sharing the player.
*
* It is held as a generation rather than a flag because that hook is called on the app thread
* while the mismatch is discovered on the playback thread. A late buffer from the outgoing stream
* can land after the new item began; latching the generation the carrier was configured for makes
* that write name the stream it belongs to instead of poisoning its successor.
*/
private val mediaGeneration = AtomicInteger(0)
/** Generation [mediaGeneration] held when the carrier was last configured; playback thread. */
private var configuredGeneration = 0
@Volatile
private var mismatchGeneration = -1
/**
* True when this format should ride the carrier.
*
* Speed changes are excluded deliberately: a bitstream cannot be resampled, so anything other
* than 1.0x has to decode. The same is true of the existing downmix/normalization blocks, which
* [directOutputBlocked] already reports.
*
* The rate family is decided from the format rather than from the packer, which only learns it
* from the bitstream once buffers are already flowing — far too late for a selection that
* happens before configure.
*/
private fun shouldUseCarrier(format: Format): Boolean {
if (!isTrueHd(format) && !isDtsHd(format)) return false
if (mismatchGeneration == mediaGeneration.get()) return false
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false
return carrierRouteAvailable()
}
/**
* The 48kHz family rides the 192kHz carrier this path builds. The 44.1kHz family needs a 176.4kHz
* carrier, which would be a different AudioTrack tuple throughout, so it decodes instead.
*/
private fun isCarrierRateFamily(sampleRate: Int): Boolean = sampleRate == 48_000 || sampleRate == 96_000 || sampleRate == 192_000
/** The PCM-shaped format the carrier delegate is configured with. */
private fun carrierFormat(): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setPcmEncoding(C.ENCODING_PCM_16BIT)
.setChannelCount(IecCarrier.CHANNEL_COUNT)
.setSampleRate(IecCarrier.SAMPLE_RATE)
.build()
/**
* TrueHD is deliberately binary: the carrier, or decoded PCM. Never media3's own raw TrueHD path.
*
* That path builds an `ENCODING_DOLBY_TRUEHD` track at the *stream* rate, which is the
* configuration #1804 is about — one box takes a single write and never advances its
* playback head, another freezes for ten seconds, and the third declines it and decodes anyway.
* Even Kodi's raw fallback is a different thing: it only offers raw TrueHD after verifying it at
* 192kHz, which media3 never requests. So when the carrier is unavailable — no IEC route, a speed
* change, downmix, normalization, or a 44.1kHz-family stream — report unsupported and let the
* bundled FFmpeg decoder take it, exactly as the existing decoded-PCM fallback does.
*/
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
/**
* DTS-HD is binary only while the carrier route exists: the carrier, or decoded PCM. Falling
* through to media3's raw `ENCODING_DTS_HD` path on such a route would land on exactly the
* silent configuration #1988 is about — the route that advertises the carrier and raw DTS-HD at
* once is the one whose raw path renders silence. Without a carrier route the raw path is the
* pre-carrier behavior and is left alone: those routes never had the carrier to lose, and some
* of them bitstream raw DTS-HD genuinely.
*
* DTS Express deliberately stays off the carrier: its mime carries a `;profile=lbr` suffix, so
* the equality below excludes it and it keeps decoding as before.
*/
private fun isDtsHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_DTS_HD
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> false
isDtsHd(format) && carrierRouteAvailable() -> false
else -> defaultSink.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int = when {
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
else -> defaultSink.getFormatSupport(format)
}
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
val inputFormat = audioSinkConfig.format
val useCarrier = shouldUseCarrier(inputFormat)
if (useCarrier != carrierActive) {
log?.invoke(
"info",
"audio",
if (useCarrier) {
"${carrierCodecName(inputFormat)} via IEC 61937 carrier at ${IecCarrier.SAMPLE_RATE}Hz/" +
"${IecCarrier.CHANNEL_COUNT}ch"
} else {
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
}
)
}
carrierActive = useCarrier
configuredGeneration = mediaGeneration.get()
activePacker = if (isDtsHd(inputFormat)) dtsHdPacker else matPacker
active = if (useCarrier) carrierSink else defaultSink
loggedCoreStrip = false
discardCarrierState()
if (useCarrier) {
val carrierConfig = AudioSink.AudioSinkConfig.Builder(carrierFormat())
.setPreferredBufferSizeOverride(audioSinkConfig.preferredBufferSizeOverride)
.setTimeline(audioSinkConfig.timeline)
.setMediaPeriodId(audioSinkConfig.mediaPeriodId)
.build()
carrierSink.configure(carrierConfig)
} else {
defaultSink.configure(audioSinkConfig)
}
}
private fun carrierCodecName(format: Format): String = if (isDtsHd(format)) "DTS-HD" else "TrueHD (MAT)"
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
// A refused burst blocks everything: placing it must come before consuming more input, or the
// carrier would lose a frame.
pendingBurst?.let { burst ->
if (!carrierSink.handleBuffer(burst, pendingBurstTimeUs, 1)) return false
pendingBurst = null
}
if (!buffer.hasRemaining()) return true
if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs
// One media3 sample holds many access units. The buffer position advances per unit, so a burst
// the delegate refuses leaves the units behind it in the buffer for the retry rather than
// dropping them — media3 re-delivers the same buffer whenever this returns false.
val remaining = ByteArray(buffer.remaining())
buffer.duplicate().get(remaining)
var offset = 0
while (offset < remaining.size) {
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
if (activePacker.unsupportedStream) return latchMismatch()
if (length == 0) {
// Not a unit boundary we recognise. Consuming the tail keeps the stream moving; trying to
// resynchronise mid-carrier would splice a frame.
buffer.position(buffer.limit())
return true
}
val burst = activePacker.packAccessUnit(remaining, offset, length)
if (activePacker.unsupportedStream) return latchMismatch()
offset += length
buffer.position(buffer.position() + length)
if (burst == null) continue
if (activePacker === dtsHdPacker && dtsHdPacker.strippedToCore && !loggedCoreStrip) {
loggedCoreStrip = true
log?.invoke(
"warn",
"audio",
"DTS-HD MA bitrate exceeds the IEC 61937 carrier; sending the DTS core substream for ~60s"
)
}
val burstTimeUs = carrierAnchorUs + carrierFramesSinceAnchor * 1_000_000L / IecCarrier.SAMPLE_RATE
carrierFramesSinceAnchor += burst.remaining().toLong() / IecCarrier.BYTES_PER_FRAME
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst
pendingBurstTimeUs = burstTimeUs
return false
}
}
return true
}
/**
* The bitstream announced a shape the carrier cannot ride, which selection could not see in the
* container's Format. The packer emits nothing in that state; consuming here would turn the
* stream into silence. Leave the unit in the buffer, latch the carrier off, and ask for
* reselection so the decoder takes over and receives it.
*/
private fun latchMismatch(): Boolean {
if (mismatchGeneration != configuredGeneration) {
mismatchGeneration = configuredGeneration
log?.invoke(
"warn",
"audio",
if (activePacker === dtsHdPacker) {
"DTS-HD bitstream cannot ride the 192kHz carrier (44.1kHz-family core or non-standard framing); " +
"leaving the carrier so it decodes"
} else {
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
"leaving the carrier so it decodes"
}
)
sinkListener?.onAudioCapabilitiesChanged()
}
return false
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long = active.getCurrentPositionUs(sourceEnded)
override fun playToEndOfStream() {
// A partially filled burst cannot be emitted; up to one burst is dropped at the end of a stream.
active.playToEndOfStream()
}
override fun isEnded(): Boolean = active.isEnded()
override fun hasPendingData(): Boolean = pendingBurst != null || active.hasPendingData()
override fun handleDiscontinuity() {
discardCarrierState()
active.handleDiscontinuity()
}
override fun play() = active.play()
override fun pause() = active.pause()
override fun flush() {
discardCarrierState()
active.flush()
}
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
private fun discardCarrierState() {
matPacker.reset()
dtsHdPacker.reset()
pendingBurst = null
// Re-anchor on the next burst: after a seek the carrier restarts from a new media time.
carrierAnchorUs = C.TIME_UNSET
carrierFramesSinceAnchor = 0
}
// --- Persistent state: mirrored, so either delegate can be activated later ---
override fun setListener(listener: AudioSink.Listener) {
sinkListener = listener
defaultSink.setListener(listener)
carrierSink.setListener(listener)
}
override fun setPlayerId(playerId: PlayerId?) {
defaultSink.setPlayerId(playerId)
carrierSink.setPlayerId(playerId)
}
override fun setClock(clock: Clock) {
defaultSink.setClock(clock)
carrierSink.setClock(clock)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
val wasUnitSpeed = this.playbackParameters.speed == 1f
val isUnitSpeed = playbackParameters.speed == 1f
this.playbackParameters = playbackParameters
defaultSink.setPlaybackParameters(playbackParameters)
// A bitstream cannot be resampled, and the carrier delegate has no processor chain to do it
// with, so it is never handed a speed nothing can apply.
carrierSink.setPlaybackParameters(
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
)
// Crossing 1x changes whether a bitstream may ride the carrier, but nothing re-asks on its
// own: the renderer only consults the sink when capabilities are invalidated. Rebuilding the
// track selector parameters is not enough either — DefaultTrackSelector skips invalidation
// when the rebuilt parameters compare equal. This is the path media3 itself uses for a route
// change, and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and the
// stream moves between the carrier and the decoder.
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
log?.invoke(
"info",
"audio",
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the bitstream carrier"
)
sinkListener?.onAudioCapabilitiesChanged()
}
}
/** Whether the configured stream is currently riding the carrier. */
val isCarrierActive: Boolean
get() = carrierActive
/**
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
* reported instead; the reselection triggered above then moves the stream onto the decoder, which
* really can apply them.
*/
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
// Only the normal sink: the carrier delegate is fed a fixed-rate bitstream,
// and dropping "silent" carrier bytes would break the frame cadence.
defaultSink.setSkipSilenceEnabled(skipSilenceEnabled)
}
override fun getSkipSilenceEnabled(): Boolean = defaultSink.getSkipSilenceEnabled()
override fun setAudioAttributes(audioAttributes: AudioAttributes) {
defaultSink.setAudioAttributes(audioAttributes)
carrierSink.setAudioAttributes(audioAttributes)
}
override fun getAudioAttributes(): AudioAttributes? = active.audioAttributes
override fun setAudioSessionId(audioSessionId: Int) {
defaultSink.setAudioSessionId(audioSessionId)
carrierSink.setAudioSessionId(audioSessionId)
}
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {
defaultSink.setAuxEffectInfo(auxEffectInfo)
carrierSink.setAuxEffectInfo(auxEffectInfo)
}
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) {
defaultSink.setPreferredDevice(audioDeviceInfo)
carrierSink.setPreferredDevice(audioDeviceInfo)
}
override fun setOutputStreamOffsetUs(outputStreamOffsetUs: Long) {
defaultSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
carrierSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
}
override fun setVolume(volume: Float) {
defaultSink.setVolume(volume)
carrierSink.setVolume(volume)
}
override fun enableTunnelingV21() {
// Tunneling is a decoded-PCM/video-sync arrangement; the carrier never uses it.
defaultSink.enableTunnelingV21()
}
override fun disableTunneling() {
defaultSink.disableTunneling()
}
override fun reset() {
discardCarrierState()
defaultSink.reset()
carrierSink.reset()
}
/**
* Called by the owner immediately before a new media item is set, which is the only point where
* "this stream lied about its shape" stops being true and the carrier can be offered again.
*/
fun beginMediaItem() {
mediaGeneration.incrementAndGet()
}
override fun release() {
mediaGeneration.incrementAndGet()
discardCarrierState()
defaultSink.release()
carrierSink.release()
}
}
@@ -31,12 +31,100 @@ internal object LoadControlPolicy {
*/
const val MIN_TARGET_BYTES = 32 * MIB
/** `DefaultLoadControl` play-start threshold on a first buffer. */
const val BUFFER_FOR_PLAYBACK_MS = 1_000
/**
* `DefaultLoadControl` play-start threshold after a rebuffer. Below this the player is
* *supposed* to stay in `STATE_BUFFERING`, so anything judging a buffering player has to clear
* this bar before it can call the wait anomalous — see [BufferingStallPolicy].
*/
const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5_000
/**
* Fraction of a memory budget the allocator may claim. Matches the threshold the Buffer
* Size setting already warns at (`value > heapMB / 4`).
*/
private const val BUDGET_DIVISOR = 4
/**
* Memory tier below which the shipped Auto durations stay conservative, in
* `ActivityManager.MemoryInfo.availMem` MiB.
*/
private const val LOW_MEMORY_MB = 2048
/** Auto durations, by memory tier. These are the values that shipped before #1816. */
private const val AUTO_MIN_BUFFER_LOW_MEMORY_MS = 15_000
private const val AUTO_MAX_BUFFER_LOW_MEMORY_MS = 50_000
private const val AUTO_MIN_BUFFER_MS = 30_000
private const val AUTO_MAX_BUFFER_MS = 60_000
/**
* How deep the loader may read ahead.
*
* Named tiers rather than a duration, because a duration would be a promise this cannot keep:
* `prioritizeTimeOverSizeThresholds` is disabled, so `targetBufferBytes` stops the loader even
* below `minBufferMs`, and effective read-ahead is `min(maxBufferMs, 8 * bytes / bitrate)`. On a
* high bitrate stream the byte term wins whatever is chosen here. The tiers therefore describe
* an intent — how much network variance to absorb — and the Buffer Size setting bounds the
* memory it may cost.
*
* Values match jellyfin-androidtv and jellyfin-android so a user moving between Jellyfin clients
* gets the same behaviour from the same words.
*/
enum class BufferTier(val minBufferMs: Int, val maxBufferMs: Int) {
/** Memory-tiered defaults, resolved per device in [bufferDurations]. */
AUTO(0, 0),
/** For moderate or variable connections. */
LARGE(50_000, 120_000),
/** For slow or high-latency links, where a stall costs more than the memory does. */
EXTRA_LARGE(80_000, 240_000);
companion object {
/**
* Wire values are the Dart enum's `nativeValue`. An unknown string means a Dart/Kotlin
* skew, and [AUTO] is the only safe reading of "I do not know what this user asked for".
*/
fun fromWire(value: String?): BufferTier = when (value) {
"large" -> LARGE
"extra_large" -> EXTRA_LARGE
else -> AUTO
}
}
}
/** The min/max pair handed to `DefaultLoadControl.Builder.setBufferDurationsMs`. */
data class BufferDurations(val minBufferMs: Int, val maxBufferMs: Int)
/**
* Read-ahead duration bounds for this session.
*
* The returned pair always satisfies the ordering media3 asserts on
* (`bufferForPlayback* <= minBufferMs <= maxBufferMs`). That is enforced here rather than
* trusted, because media3 validates with Guava `Preconditions` — an unconditional throw that
* R8 does not elide — so a bad pair is an `IllegalArgumentException` out of
* `DefaultLoadControl.Builder.build()` on a user's device, not a degraded buffer.
*
* @param availableMB `ActivityManager.MemoryInfo.availMem`. Non-positive when unknown, which
* is treated as the low-memory tier.
*/
fun bufferDurations(tier: BufferTier, availableMB: Int): BufferDurations {
val playStartFloor = maxOf(BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
val (rawMin, rawMax) = if (tier == BufferTier.AUTO) {
if (availableMB <= LOW_MEMORY_MB) {
AUTO_MIN_BUFFER_LOW_MEMORY_MS to AUTO_MAX_BUFFER_LOW_MEMORY_MS
} else {
AUTO_MIN_BUFFER_MS to AUTO_MAX_BUFFER_MS
}
} else {
tier.minBufferMs to tier.maxBufferMs
}
val minBufferMs = rawMin.coerceAtLeast(playStartFloor)
return BufferDurations(minBufferMs, rawMax.coerceAtLeast(minBufferMs))
}
/**
* @param largeHeapMB `ActivityManager.largeMemoryClass` — the hard Java-heap ceiling for
* this process, which is what bounds `DefaultAllocator` (it hands out `byte[]`).
@@ -0,0 +1,91 @@
package com.edde746.plezy.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.exoplayer.upstream.Allocator
/**
* A [LoadControl] that remembers whether it last said playback may start.
*
* The buffering stall watchdog ([BufferingStallPolicy]) needs to tell apart a player that is
* buffering because the load control is holding it back — an ordinary rebuffer, not its business —
* from one the load control has released that still refuses to move, which is the failure it exists
* to catch.
*
* Re-deriving that condition cannot be done faithfully: `DefaultLoadControl.shouldStartPlayback`
* releases playback on buffered *playout* duration (media duration divided by playback speed) or,
* with `prioritizeTimeOverSizeThresholds` disabled, as soon as its byte target is full — which on a
* high bitrate stream happens well below any duration bar, and depends on allocator accounting that
* no public API exposes. So ask the object ExoPlayer asks, and record its answer.
*
* Note what this does *not* reach: `ExoPlayerImplInternal.shouldTransitionToReadyState` returns
* early when the renderers are not ready, so it never asks at all in the case the watchdog was
* written for — a renderer stuck forever. [BufferingStallPolicy] therefore also takes
* `Player.isLoading`, which stays observable throughout.
*
* Every member is forwarded explicitly, and that is not a style choice: Kotlin's `by` delegation
* only generates forwarders for *abstract* interface members, so a Java interface like this one —
* where all but `getAllocator` carry default implementations that throw
* `IllegalStateException("... not implemented")` — would inherit those throwing defaults and blow up
* inside `ExoPlayer.Builder.build()`. [ObservingLoadControlTest] builds a real player through this
* wrapper so a media3 upgrade that adds another such method fails a test rather than a device.
*/
@OptIn(UnstableApi::class)
class ObservingLoadControl(private val delegate: LoadControl) : LoadControl {
/**
* The delegate's most recent verdict, or null if it has not been asked since [reset].
*
* ExoPlayer asks on every playback-loop iteration while buffering, so a reader running on the
* watchdog's schedule sees a current answer; null only means "not yet asked".
*/
@Volatile
var startPlaybackVerdict: Boolean? = null
private set
/** Forgets the recorded verdict, for a watchdog arming a fresh buffering window. */
fun reset() {
startPlaybackVerdict = null
}
// The Parameters overloads are the ones ExoPlayer 1.10 calls; the older signatures are deprecated
// shims that route through them.
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean {
val verdict = delegate.shouldStartPlayback(parameters)
startPlaybackVerdict = verdict
return verdict
}
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = delegate.shouldContinueLoading(parameters)
override fun shouldContinuePreloading(
playerId: PlayerId,
timeline: Timeline,
mediaPeriodId: MediaSource.MediaPeriodId,
bufferedDurationUs: Long
): Boolean = delegate.shouldContinuePreloading(playerId, timeline, mediaPeriodId, bufferedDurationUs)
override fun onPrepared(playerId: PlayerId) = delegate.onPrepared(playerId)
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: TrackGroupArray,
trackSelections: Array<out ExoTrackSelection>
) = delegate.onTracksSelected(parameters, trackGroups, trackSelections)
override fun onStopped(playerId: PlayerId) = delegate.onStopped(playerId)
override fun onReleased(playerId: PlayerId) = delegate.onReleased(playerId)
override fun getAllocator(playerId: PlayerId): Allocator = delegate.getAllocator(playerId)
override fun getBackBufferDurationUs(playerId: PlayerId): Long = delegate.getBackBufferDurationUs(playerId)
override fun retainBackBufferFromKeyframe(playerId: PlayerId): Boolean = delegate.retainBackBufferFromKeyframe(playerId)
}
@@ -0,0 +1,310 @@
package com.edde746.plezy.exoplayer
import android.graphics.Bitmap
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.text.Cue
import androidx.media3.common.util.Consumer
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.extractor.text.CuesWithTiming
import androidx.media3.extractor.text.SubtitleParser
import java.util.zip.Inflater
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Delegates to [delegate] for every subtitle format except PGS, which is handled by
* [PgsCompositionParser] because media3's PgsParser drops all but one composition object (#1953).
*/
@UnstableApi
class PgsSubtitleParserFactory(private val delegate: SubtitleParser.Factory) : SubtitleParser.Factory {
override fun supportsFormat(format: Format): Boolean = delegate.supportsFormat(format)
override fun getCueReplacementBehavior(format: Format): Int = delegate.getCueReplacementBehavior(format)
override fun create(format: Format): SubtitleParser = if (format.sampleMimeType == MimeTypes.APPLICATION_PGS) PgsCompositionParser() else delegate.create(format)
}
/**
* PGS subtitle parser that renders every composition object in a display set.
*
* media3's PgsParser keeps one bitmap buffer and only the first composition object's
* coordinates, so a display set with two simultaneous images (dialogue plus a sign or
* song caption) draws the last object at the first object's position and loses the rest.
* It is also stateless across display sets, so Normal-state sets that only update the
* palette (fades) or reuse cached objects blank the screen.
*
* This parser follows FFmpeg's pgssubdec model, which Kodi/mpv/VLC share: objects and
* palettes are cached per epoch keyed by id, any non-Normal composition state releases
* the caches, palette sections update entries in place, and the display-end section
* composes one cue per object reference in the presentation. Colors use limited-range
* BT.709 for HD planes and BT.601 otherwise, matching FFmpeg output.
*/
@UnstableApi
class PgsCompositionParser : SubtitleParser {
companion object {
private const val SECTION_TYPE_PALETTE = 0x14
private const val SECTION_TYPE_OBJECT = 0x15
private const val SECTION_TYPE_PRESENTATION = 0x16
private const val SECTION_TYPE_END = 0x80
// Blu-ray spec limits, as enforced by FFmpeg's pgssubdec.
private const val MAX_OBJECT_REFS = 2
private const val MAX_EPOCH_OBJECTS = 64
private const val MAX_EPOCH_PALETTES = 8
// Bounds object dimensions when a damaged stream declares them before any
// presentation section has established the composition plane.
private const val MAX_PLANE_DIMENSION = 4096
}
private class PgsObject {
var width = 0
var height = 0
var rle = ByteArray(0)
var received = 0
val complete: Boolean
get() = rle.isNotEmpty() && received == rle.size
}
private class ObjectRef(val objectId: Int, val x: Int, val y: Int)
private val buffer = ParsableByteArray()
private val inflatedBuffer = ParsableByteArray()
private var inflater: Inflater? = null
// Epoch state: objects and palettes persist across display sets until a
// non-Normal composition state starts a new epoch or [reset] follows a seek.
private val objects = HashMap<Int, PgsObject>()
private val palettes = HashMap<Int, IntArray>()
// Latest presentation composition.
private var planeWidth = 0
private var planeHeight = 0
private var paletteId = 0
private var objectRefs = emptyList<ObjectRef>()
override fun getCueReplacementBehavior(): Int = Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
override fun reset() {
objects.clear()
palettes.clear()
objectRefs = emptyList()
planeWidth = 0
planeHeight = 0
paletteId = 0
}
override fun parse(
data: ByteArray,
offset: Int,
length: Int,
outputOptions: SubtitleParser.OutputOptions,
output: Consumer<CuesWithTiming>
) {
buffer.reset(data, offset + length)
buffer.setPosition(offset)
val inflater = this.inflater ?: Inflater().also { this.inflater = it }
if (Util.maybeInflate(buffer, inflatedBuffer, inflater)) {
buffer.reset(inflatedBuffer.data, inflatedBuffer.limit())
}
val cues = ArrayList<Cue>()
while (buffer.bytesLeft() >= 3) {
readNextSection(cues)
}
output.accept(
CuesWithTiming(
cues,
C.TIME_UNSET, // startTimeUs
C.TIME_UNSET // durationUs
)
)
}
private fun readNextSection(cues: MutableList<Cue>) {
val sectionType = buffer.readUnsignedByte()
val sectionLength = buffer.readUnsignedShort()
val nextSectionPosition = buffer.position + sectionLength
if (nextSectionPosition > buffer.limit()) {
buffer.setPosition(buffer.limit())
return
}
when (sectionType) {
SECTION_TYPE_PALETTE -> parsePalette(sectionLength)
SECTION_TYPE_OBJECT -> parseObject(sectionLength)
SECTION_TYPE_PRESENTATION -> parsePresentation(sectionLength)
SECTION_TYPE_END -> composeCues(cues)
// Window sections (0x17) carry no information the composition needs.
}
buffer.setPosition(nextSectionPosition)
}
private fun parsePresentation(sectionLength: Int) {
if (sectionLength < 11) return
planeWidth = buffer.readUnsignedShort()
planeHeight = buffer.readUnsignedShort()
buffer.skipBytes(3) // Frame rate (1), composition number (2).
val compositionState = buffer.readUnsignedByte()
if (compositionState and 0xC0 != 0) {
// Epoch start/acquisition point display sets carry complete data; previous
// epoch objects and palettes are released.
objects.clear()
palettes.clear()
}
buffer.skipBytes(1) // Palette update flag.
paletteId = buffer.readUnsignedByte()
val objectCount = buffer.readUnsignedByte()
var remaining = sectionLength - 11
val refs = ArrayList<ObjectRef>(min(objectCount, MAX_OBJECT_REFS))
for (i in 0 until min(objectCount, MAX_OBJECT_REFS)) {
if (remaining < 8) break
val objectId = buffer.readUnsignedShort()
buffer.skipBytes(1) // Window id.
val flags = buffer.readUnsignedByte()
val x = buffer.readUnsignedShort()
val y = buffer.readUnsignedShort()
remaining -= 8
if (flags and 0x80 != 0) {
// Cropped object: skip the crop rectangle. Like FFmpeg, the crop itself
// is not applied, but the following reference must parse correctly.
if (remaining < 8) break
buffer.skipBytes(8)
remaining -= 8
}
refs.add(ObjectRef(objectId, x, y))
}
objectRefs = refs
}
private fun parsePalette(sectionLength: Int) {
if (sectionLength % 5 != 2) return
val id = buffer.readUnsignedByte()
buffer.skipBytes(1) // Palette version.
if (palettes.size >= MAX_EPOCH_PALETTES && id !in palettes) return
// Entries update in place: fade ramps resend only the entries that changed.
val palette = palettes.getOrPut(id) { IntArray(256) }
val bt709 = planeHeight <= 0 || planeHeight > 576
repeat(sectionLength / 5) {
val index = buffer.readUnsignedByte()
val y = buffer.readUnsignedByte()
val cr = buffer.readUnsignedByte()
val cb = buffer.readUnsignedByte()
val alpha = buffer.readUnsignedByte()
palette[index] = argbColor(y, cb, cr, alpha, bt709)
}
}
private fun parseObject(sectionLength: Int) {
if (sectionLength < 4) return
val id = buffer.readUnsignedShort()
if (objects.size >= MAX_EPOCH_OBJECTS && id !in objects) return
buffer.skipBytes(1) // Object version.
val isFirstFragment = buffer.readUnsignedByte() and 0x80 != 0
var remaining = sectionLength - 4
val obj = objects.getOrPut(id) { PgsObject() }
if (isFirstFragment) {
if (remaining < 7) return
val rleLength = buffer.readUnsignedInt24() - 4 // Length includes the width/height fields.
val width = buffer.readUnsignedShort()
val height = buffer.readUnsignedShort()
remaining -= 7
val maxWidth = if (planeWidth > 0) planeWidth else MAX_PLANE_DIMENSION
val maxHeight = if (planeHeight > 0) planeHeight else MAX_PLANE_DIMENSION
if (rleLength <= 0 || width !in 1..maxWidth || height !in 1..maxHeight) {
obj.rle = ByteArray(0)
obj.received = 0
return
}
obj.width = width
obj.height = height
obj.rle = ByteArray(rleLength)
obj.received = 0
}
val bytesToRead = min(remaining, obj.rle.size - obj.received)
if (bytesToRead > 0) {
buffer.readBytes(obj.rle, obj.received, bytesToRead)
obj.received += bytesToRead
}
}
private fun composeCues(cues: MutableList<Cue>) {
if (planeWidth <= 0 || planeHeight <= 0) return
val palette = palettes[paletteId] ?: return
for (ref in objectRefs) {
// Skip references to missing or incomplete objects (damaged stream, or a
// post-seek Normal set whose epoch data was never observed).
val obj = objects[ref.objectId] ?: continue
if (!obj.complete) continue
cues.add(
Cue.Builder()
.setBitmap(decodeBitmap(obj, palette))
.setPosition(ref.x.toFloat() / planeWidth)
.setPositionAnchor(Cue.ANCHOR_TYPE_START)
.setLine(ref.y.toFloat() / planeHeight, Cue.LINE_TYPE_FRACTION)
.setLineAnchor(Cue.ANCHOR_TYPE_START)
.setSize(obj.width.toFloat() / planeWidth)
.setBitmapHeight(obj.height.toFloat() / planeHeight)
.build()
)
}
}
private fun decodeBitmap(obj: PgsObject, palette: IntArray): Bitmap {
val pixels = IntArray(obj.width * obj.height)
val rle = obj.rle
var input = 0
var output = 0
while (input < rle.size && output < pixels.size) {
val first = rle[input++].toInt() and 0xFF
if (first != 0) {
pixels[output++] = palette[first]
continue
}
if (input >= rle.size) break
val flags = rle[input++].toInt() and 0xFF
if (flags == 0) continue // End-of-line marker.
var run = flags and 0x3F
if (flags and 0x40 != 0) {
if (input >= rle.size) break
run = run shl 8 or (rle[input++].toInt() and 0xFF)
}
val color = if (flags and 0x80 != 0) {
if (input >= rle.size) break
palette[rle[input++].toInt() and 0xFF]
} else {
palette[0]
}
val end = min(output + run, pixels.size)
pixels.fill(color, output, end)
output = end
}
return Bitmap.createBitmap(pixels, obj.width, obj.height, Bitmap.Config.ARGB_8888)
}
private fun argbColor(y: Int, cb: Int, cr: Int, alpha: Int, bt709: Boolean): Int {
val yc = (y - 16) * (255f / 219f)
val cbc = (cb - 128) * (255f / 224f)
val crc = (cr - 128) * (255f / 224f)
val r: Float
val g: Float
val b: Float
if (bt709) {
r = yc + 1.5747f * crc
g = yc - 0.1873f * cbc - 0.4682f * crc
b = yc + 1.8556f * cbc
} else {
r = yc + 1.402f * crc
g = yc - 0.34414f * cbc - 0.71414f * crc
b = yc + 1.772f * cbc
}
return (alpha shl 24) or
(r.roundToInt().coerceIn(0, 255) shl 16) or
(g.roundToInt().coerceIn(0, 255) shl 8) or
b.roundToInt().coerceIn(0, 255)
}
}
@@ -8,6 +8,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.ChannelMixingAudioProcessor
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.util.Clock
@@ -150,17 +151,31 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
)
}
private var iecCarrierSink: IecCarrierSink? = null
/**
* Clears per-stream carrier state that must survive renderer resets but not a new media item.
* Call before setting a new source; see [IecCarrierSink.beginMediaItem].
*/
fun beginMediaItem() {
iecCarrierSink?.beginMediaItem()
}
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
// Media3 1.11's replacement is fixed at 500ms; preserve the dynamic 500–1000ms PCM policy.
@Suppress("DEPRECATION")
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
// Media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms. Some
// HDMI routes reject a buffer that short (#1790). Ask for a second up front; Media3 1.11
// now uses the same one-second floor for its last-resort retry (#3207).
.setPassthroughBufferDurationUs(500_000)
.build()
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
@@ -180,7 +195,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
.build()
return PositionFixAudioSink(
val processedSink = PositionFixAudioSink(
defaultSink,
rawPositionUs,
audioDelayUs,
@@ -188,7 +203,89 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
onAudioCapabilitiesChanged,
audioDiagnosticsLogger
)
return IecCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsIecCarrier(context) },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { iecCarrierSink = it }
}
/**
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
*
* Deliberately separate from the processed sink, and deliberately barren: an empty
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
* float conversion can ever touch the bytes. The carrier only looks like PCM; a single mutated
* sample reaches the receiver as full-scale noise rather than as a glitch.
*
* The output provider keeps `OutputConfig.encoding` at PCM 16-bit so every position, pending-data
* and release accounting in media3 stays in its mature PCM path — which is correct here, because
* after packing the stream genuinely is a fixed-rate 192kHz 8-channel carrier. Only the
* `AudioTrack` itself is switched to `ENCODING_IEC61937`, via the builder modifier that upstream
* applies immediately before `AudioTrack.Builder.build()`.
*/
private fun buildCarrierSink(
context: Context,
bufferSizeProvider: DefaultAudioTrackBufferSizeProvider
): AudioSink {
val provider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setAudioTrackBuilderModifier { builder, config ->
builder.setAudioFormat(
android.media.AudioFormat.Builder()
.setEncoding(android.media.AudioFormat.ENCODING_IEC61937)
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(config.sampleRate)
.build()
)
// The AudioTrackConfig media3 reports upstream is built from OutputConfig, which stays
// PCM so the accounting stays in the PCM domain — so it cannot show the real encoding.
// This is the only place that can confirm what the AudioTrack was actually built as.
audioDiagnosticsLogger?.invoke(
"info",
"audio",
"Carrier AudioTrack: encoding=IEC61937 rate=${config.sampleRate} " +
"mask=0x${android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND.toString(16)} " +
"buffer=${config.bufferSize}"
)
}
}
}
.build()
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false)
// Not DefaultAudioProcessorChain: that one appends silence-skipping and Sonic whatever it is
// constructed with, and either would rewrite carrier bytes. This chain has nothing in it, so
// AudioProcessingPipeline is a straight passthrough. Speed is reported as applied without
// resampling because the carrier delegate routes playback parameters to the AudioTrack, and
// the sink refuses the carrier outright at any speed other than 1.0x.
.setAudioProcessorChain(EmptyAudioProcessorChain())
.setAudioOutputProvider(provider)
.build()
}
}
/**
* An [AudioProcessorChain] that owns no processors, for the MAT carrier delegate (#1804).
*
* `DefaultAudioProcessorChain` always contributes silence-skipping and Sonic; both rewrite samples,
* which is fatal to a bit-exact IEC 61937 carrier. Speed is reported back unchanged because the
* carrier delegate applies playback parameters at the AudioTrack, and the routing sink declines the
* carrier entirely at any speed other than 1.0x.
*/
@OptIn(UnstableApi::class)
private class EmptyAudioProcessorChain : DefaultAudioSink.AudioProcessorChain {
override fun getAudioProcessors(): Array<AudioProcessor> = emptyArray()
override fun applyPlaybackParameters(playbackParameters: PlaybackParameters): PlaybackParameters = playbackParameters
override fun applySkipSilenceEnabled(skipSilenceEnabled: Boolean): Boolean = false
override fun getMediaDuration(playoutDuration: Long): Long = playoutDuration
override fun getSkippedOutputFrameCount(): Long = 0L
}
/**
@@ -514,17 +611,46 @@ internal class DvSanitizingVideoRenderer(
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
//
// Two invariants keep that safe (#1790):
//
// 1. Only outputs [AudioOutputCachePolicy] admits are parked, so the cache never holds a
// scarce direct/passthrough route hostage while the next one is being created.
// 2. Every flush is answered by exactly one onReleased, promptly.
//
// DefaultAudioSink increments a *static, process-wide* counter on every flush and decrements it
// only from `Listener::onReleased`, which media3 delivers by posting to the playback looper — a
// looper that is already dead by the time a player-release actually completes. A dropped callback
// pins the counter above zero, and any nonzero value disables media3's escalation of *both* init
// and write failures: `PendingExceptionHolder` refuses to arm its throw deadline and every retry
// short-circuits, so a sink error never becomes a PlaybackException and playback hangs in
// STATE_BUFFERING with no recovery. The wrapper therefore owns the listener set and answers the
// flush itself when it parks the track, because a parked track is never going to release.
//
// Deferring that answer until the parked track is really evicted is tempting — it would let
// media3 stay patient through the eviction, whose replacement is built while the old AudioTrack
// is still going away — but it holds the counter above zero for the whole live track after the
// first seek, which is the very hang above. So the eviction overlap is tolerated instead, as
// upstream tolerates it: if the replacement does fail to allocate, media3 escalates on its own
// 200ms deadline into the audio recovery ladder, and the buffering stall watchdog backs that up.
//
// Everything below is confined to the ExoPlayer playback thread: sink flush/release, provider
// lookups, and media3's own onReleased delivery all run there.
@OptIn(UnstableApi::class)
private class RawPositionOutputProvider(
internal class RawPositionOutputProvider(
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong,
private val log: ((String, String, String) -> Unit)?
private val log: ((String, String, String) -> Unit)?,
private val sdkInt: Int = Build.VERSION.SDK_INT
) : AudioOutputProvider {
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
/** Outputs whose real release was started but whose completion has not been observed yet. */
private val unsettledReleases = LinkedHashSet<RawPositionAudioOutput>()
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
@@ -533,14 +659,24 @@ private class RawPositionOutputProvider(
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
cached.markReacquired()
return cached
}
cached?.forceRelease()
cachedOutput = null
cachedConfig = null
// The replacement is built while the evicted track is still going away. Upstream does the
// same; the alternatives are worse (see the note above this class).
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
return RawPositionAudioOutput(
delegate = realOutput,
rawPositionUs = rawPositionUs,
provider = this,
mayCache = AudioOutputCachePolicy.mayCache(config.encoding, config.isOffload, sdkInt),
log = log
)
}
fun returnToCache(output: RawPositionAudioOutput) {
@@ -551,6 +687,14 @@ private class RawPositionOutputProvider(
cachedOutput = output
}
fun onRealReleaseStarted(output: RawPositionAudioOutput) {
unsettledReleases.add(output)
}
fun onReleaseSettled(output: RawPositionAudioOutput) {
unsettledReleases.remove(output)
}
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
@@ -561,15 +705,21 @@ private class RawPositionOutputProvider(
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
// Player teardown: media3 schedules the real AudioTrack release with a delay and posts the
// completion back to the playback looper this call is in the middle of quitting, so nothing
// will ever deliver it. Settle here rather than leak the accounting for the whole process.
for (output in unsettledReleases.toList()) output.settleReleaseNow()
unsettledReleases.clear()
delegate.release()
}
}
@OptIn(UnstableApi::class)
private class RawPositionAudioOutput(
internal class RawPositionAudioOutput(
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong,
private val provider: RawPositionOutputProvider,
private val mayCache: Boolean,
private val log: ((String, String, String) -> Unit)?
) : AudioOutput {
@@ -578,6 +728,46 @@ private class RawPositionAudioOutput(
private var writtenBytes = 0L
private var failed = false
/**
* DefaultAudioSink registers here instead of on the real output, so the wrapper can report the
* releases media3 will not: a parked output never really releases, and a released output's
* completion callback is posted to a playback looper that may already be gone. The set is
* cleared on every release report and repopulated by the sink on the next acquisition, which
* also stops one stale listener per reuse from piling up on the real output.
*/
private val listeners = mutableListOf<AudioOutput.Listener>()
private var releaseSignalled = false
private val currentListener: AudioOutput.Listener?
get() = listeners.lastOrNull()
private val forwarder = object : AudioOutput.Listener {
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) {
currentListener?.onPositionAdvancing(playoutStartSystemTimeMs)
}
override fun onOffloadDataRequest() {
currentListener?.onOffloadDataRequest()
}
override fun onOffloadPresentationEnded() {
currentListener?.onOffloadPresentationEnded()
}
override fun onUnderrun() {
currentListener?.onUnderrun()
}
override fun onReleased() {
provider.onReleaseSettled(this@RawPositionAudioOutput)
signalReleased()
}
}
init {
delegate.addListener(forwarder)
}
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
@@ -626,24 +816,50 @@ private class RawPositionAudioOutput(
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (failed) {
delegate.release()
return
}
if (Build.VERSION.SDK_INT >= 25) {
if (!failed && mayCache) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
// A parked track is never going to release, so answer the flush now. Holding the sink's
// pending-release count open for it would disable media3's escalation of every later sink
// error, for as long as the track stays parked or live — the #1790 hang, re-armed by an
// ordinary seek.
signalReleased()
return
}
startRealRelease()
}
/** Releases for real even when the output would otherwise be cacheable. */
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
startRealRelease()
}
/** Reports a release whose completion callback can no longer be delivered. */
fun settleReleaseNow() {
provider.onReleaseSettled(this)
signalReleased()
}
/** Re-arms the wrapper for a fresh acquisition out of the provider's cache. */
fun markReacquired() {
releaseSignalled = false
}
private fun startRealRelease() {
provider.onRealReleaseStarted(this)
delegate.release()
}
private fun signalReleased() {
if (releaseSignalled) return
releaseSignalled = true
val notified = listeners.toList()
listeners.clear()
for (listener in notified) listener.onReleased()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
@@ -651,8 +867,12 @@ private class RawPositionAudioOutput(
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
@@ -13,12 +13,18 @@ internal object SubtitleViewLayout {
videoHeight: Int,
pixelRatio: Float,
resizeMode: Int,
zoomScale: Float
zoomScale: Float,
anchorToScreen: Boolean = false
): SubtitleViewDimensions? {
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
if (containerWidth <= 0 || containerHeight <= 0) return null
if (resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
// Anchor-to-screen (#1730): size the text view to the full container so
// media3's fractional text size and bottom-anchored cue placement compute
// against the physical screen, letting subtitles render in the letterbox
// bars instead of inside the video rect. Same geometry the non-FIT modes
// below already use.
if (anchorToScreen || resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT) {
return SubtitleViewDimensions(containerWidth, containerHeight)
}
@@ -36,16 +42,35 @@ internal object SubtitleViewLayout {
videoWidth: Int,
videoHeight: Int,
pixelRatio: Float,
resizeMode: Int,
zoomScale: Float
planeAspect: Float?
): SubtitleViewDimensions? {
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
if (containerWidth <= 0 || containerHeight <= 0) return null
val videoAspect = videoAspect(videoWidth, videoHeight, pixelRatio) ?: return null
val visibleVideoBounds = fit(containerWidth, containerHeight, videoAspect)
val layoutAspect = planeAspect?.takeIf { it.isFinite() && it > 0f }
?: return visibleVideoBounds
// PGS/VOB cues are authored in video coordinates, but users expect them to
// remain readable and fully visible when the video is cropped or zoomed.
// Fit to the visible container instead of following the cropped video rect.
return fit(containerWidth, containerHeight, videoAspect)
// Media3 expresses bitmap cue positions and dimensions relative to the
// subtitle composition plane, which can differ from a cropped video's
// dimensions. Fit that plane inside the visible video bounds so cues keep
// their authored geometry without entering the letterbox bars.
return fit(visibleVideoBounds.width, visibleVideoBounds.height, layoutAspect)
}
fun bitmapPlaneAspect(
bitmapWidth: Int,
bitmapHeight: Int,
cueWidthFraction: Float,
cueHeightFraction: Float
): Float? {
if (bitmapWidth <= 0 || bitmapHeight <= 0) return null
if (!cueWidthFraction.isFinite() || cueWidthFraction <= 0f) return null
if (!cueHeightFraction.isFinite() || cueHeightFraction <= 0f) return null
// cueWidth = bitmapWidth / planeWidth and cueHeight = bitmapHeight / planeHeight.
val aspect = bitmapWidth.toDouble() * cueHeightFraction.toDouble() /
(bitmapHeight.toDouble() * cueWidthFraction.toDouble())
return aspect.toFloat().takeIf { it.isFinite() && it > 0f }
}
private fun videoAspect(videoWidth: Int, videoHeight: Int, pixelRatio: Float): Float? {
@@ -0,0 +1,258 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* Packs Dolby TrueHD access units into MAT frames carried in IEC 61937 bursts (#1804).
*
* Android will not bitstream raw TrueHD on the TV routes we have measured — the platform reports
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
* bitstream-capable. That is also the split Kodi models: it offers an "AudioTrack (IEC)" sink where
* it packs the carrier itself, and treats handing raw TrueHD to Android as the fallback. Media3 only
* ever does the latter, and at the stream rate rather than the carrier rate, which is why playback
* wedges where other players work.
*
* The algorithm is a port of FFmpeg's `spdif_header_truehd` (libavformat/spdifenc.c, n8.0). Kodi's
* `CAEBitstreamPacker` is *not* an equivalent reference: it wraps an already-assembled buffer,
* whereas the timing-driven padding and MAT code placement live in FFmpeg's stateful packer.
*
* Output is a complete IEC 61937 burst per MAT frame:
*
* - 8 byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x0016 Pd=61424`
* - 61424 bytes of MAT frame, 16-bit byte-swapped
* - 8 bytes of zero padding
* - 61440 bytes total, which is 20ms at 192kHz/8ch/16-bit
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class TrueHdMatPacker : IecCarrierPacker {
internal companion object {
/** Payload bytes in one MAT frame. */
const val MAT_FRAME_SIZE = 61424
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
const val MAT_PKT_OFFSET = 61440
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
private const val IEC61937_TRUEHD = 0x16
/** Minimum bytes needed to read an access unit header plus its major-sync probe. */
private const val MIN_ACCESS_UNIT_LENGTH = 10
private val MAT_START_CODE = byteArrayOf(
0x07, 0x9E.toByte(), 0x00, 0x03, 0x84.toByte(), 0x01, 0x01, 0x01, 0x80.toByte(), 0x00,
0x56, 0xA5.toByte(), 0x3B, 0xF4.toByte(), 0x81.toByte(), 0x83.toByte(), 0x49, 0x80.toByte(),
0x77, 0xE0.toByte()
)
private val MAT_MIDDLE_CODE = byteArrayOf(
0xC3.toByte(), 0xC1.toByte(), 0x42, 0x49, 0x3B, 0xFA.toByte(), 0x82.toByte(), 0x83.toByte(),
0x49, 0x80.toByte(), 0x77, 0xE0.toByte()
)
private val MAT_END_CODE = byteArrayOf(
0xC3.toByte(), 0xC2.toByte(), 0xC0.toByte(), 0xC4.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x97.toByte(), 0x11
)
private val MAT_CODES = arrayOf(MAT_START_CODE, MAT_MIDDLE_CODE, MAT_END_CODE)
private val MAT_CODE_POSITIONS = intArrayOf(0, 30708, MAT_FRAME_SIZE - MAT_END_CODE.size)
/**
* Length of the access unit starting at [offset], in bytes.
*
* The first 16-bit big-endian word carries the length in units of 16-bit words in its low 12
* bits. Returns 0 when the buffer is too short or the length is nonsensical, which the caller
* treats as "stop walking".
*/
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 2 > limit) return 0
val words = ((data[offset].toInt() and 0x0F) shl 8) or (data[offset + 1].toInt() and 0xFF)
val length = words * 2
return if (length < 4 || offset + length > limit) 0 else length
}
/** True when the access unit at [offset] carries a major sync, which announces the rate. */
private fun hasMajorSync(data: ByteArray, offset: Int, limit: Int): Boolean {
if (offset + 8 > limit) return false
return (data[offset + 4].toInt() and 0xFF) == 0xF8 &&
(data[offset + 5].toInt() and 0xFF) == 0x72 &&
(data[offset + 6].toInt() and 0xFF) == 0x6F
}
}
private val matBuffers = Array(2) { ByteArray(MAT_FRAME_SIZE) }
private val burstBacking = Array(2) { ByteArray(MAT_PKT_OFFSET) }
private val burstBuffers = Array(2) {
ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN)
}
private var burstIndex = 0
private var matBufferIndex = 0
private var matBufferFilled = 0
private var previousTiming = 0
private var previousSize = 0
private var samplesPerFrame = 0
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
override fun reset() {
matBufferIndex = 0
matBufferFilled = 0
previousTiming = 0
previousSize = 0
samplesPerFrame = 0
// The family is re-learned from the next major sync. Leaving it latched here would make every
// later stream on this packer emit nothing.
unsupportedStream = false
java.util.Arrays.fill(matBuffers[0], 0)
java.util.Arrays.fill(matBuffers[1], 0)
}
/**
* True when the stream announced a 44.1kHz-family rate.
*
* That family rides a 176.4kHz carrier rather than 192kHz, which changes the AudioTrack tuple the
* whole path is built around. Rather than carry a second carrier configuration for a combination
* that is essentially absent from real media, the sink reads this and falls back to decoding.
*/
override var unsupportedStream: Boolean = false
private set
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int = Companion.accessUnitLength(data, offset, limit)
/**
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
*
* The returned buffer is owned by the packer and reused, alternating between two so a burst
* handed downstream stays valid while the next frame fills. Callers must submit or copy it before
* the second following call. This is the audio path on low-power TV hardware — a fresh 61,440
* byte array every 20ms would be roughly 3MB/s of garbage.
*
* Faithful to `spdif_header_truehd`: padding between units is derived from the delta of their
* `input_timing` fields against the previous unit's on-carrier size, so the carrier keeps a fixed
* rate even though the source frames vary in length.
*
* At most one burst can complete per access unit: padding is bounded below half a MAT frame and
* an access unit is far smaller, so a single unit cannot span two frame boundaries.
*/
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_ACCESS_UNIT_LENGTH) return null
if (hasMajorSync(data, offset, offset + length)) {
// Rate is announced in the major sync; 40 samples per frame at 48kHz, doubling with the rate.
val rateBits = when (data[offset + 7].toInt() and 0xFF) {
0xBA -> (data[offset + 8].toInt() and 0xFF) shr 4
0xBB -> (data[offset + 9].toInt() and 0xFF) shr 4
else -> return null
}
// Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build.
unsupportedStream = (rateBits and 8) != 0
if (unsupportedStream) return null
samplesPerFrame = 40 shl (rateBits and 3)
}
if (unsupportedStream || samplesPerFrame == 0) return null
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
var paddingRemaining = 0
if (previousSize != 0) {
val deltaSamples = (inputTiming - previousTiming) and 0xFFFF
// One 48kHz-family frame is 1/1200s; the carrier runs at 768000*4 bytes/s, so the nominal
// space per frame is 2560 bytes, which divides evenly by samplesPerFrame.
val deltaBytes = deltaSamples * 2560 / samplesPerFrame
paddingRemaining = deltaBytes - previousSize
if (paddingRemaining < 0 || paddingRemaining >= MAT_FRAME_SIZE / 2) {
// Timing we do not model; better a momentary rate wobble than a corrupt frame.
paddingRemaining = 0
}
}
var totalFrameSize = length
var dataCursor = offset
var dataRemaining = length
var completed: ByteBuffer? = null
var nextCodeIndex = MAT_CODE_POSITIONS.indexOfFirst { matBufferFilled <= it }
if (nextCodeIndex < 0) return null
while (paddingRemaining > 0 || dataRemaining > 0 || MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
if (MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) {
val code = MAT_CODES[nextCodeIndex]
var codeLengthRemaining = code.size
code.copyInto(matBuffers[matBufferIndex], matBufferFilled)
matBufferFilled += code.size
nextCodeIndex++
if (nextCodeIndex == MAT_CODES.size) {
nextCodeIndex = 0
// Last code of the frame: this buffer is complete, continue filling the other one.
completed = toIec61937Burst(matBuffers[matBufferIndex])
matBufferIndex = matBufferIndex xor 1
matBufferFilled = 0
// The inter-frame gap occupies carrier space too.
codeLengthRemaining += MAT_PKT_OFFSET - MAT_FRAME_SIZE
}
if (paddingRemaining > 0) {
val countedAsPadding = minOf(paddingRemaining, codeLengthRemaining)
paddingRemaining -= countedAsPadding
codeLengthRemaining -= countedAsPadding
}
if (codeLengthRemaining > 0) totalFrameSize += codeLengthRemaining
}
if (paddingRemaining > 0) {
val padding = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, paddingRemaining)
java.util.Arrays.fill(matBuffers[matBufferIndex], matBufferFilled, matBufferFilled + padding, 0)
matBufferFilled += padding
paddingRemaining -= padding
if (paddingRemaining > 0) continue
}
if (dataRemaining > 0) {
val toCopy = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, dataRemaining)
data.copyInto(matBuffers[matBufferIndex], matBufferFilled, dataCursor, dataCursor + toCopy)
matBufferFilled += toCopy
dataCursor += toCopy
dataRemaining -= toCopy
}
}
previousSize = totalFrameSize
previousTiming = inputTiming
return completed
}
/**
* Wraps a finished MAT frame in its IEC 61937 burst, into the next reusable buffer.
*
* Alternating between two keeps a burst already handed downstream intact while the following
* frame is assembled.
*/
private fun toIec61937Burst(matFrame: ByteArray): ByteBuffer {
burstIndex = burstIndex xor 1
val burst = burstBuffers[burstIndex]
val backing = burstBacking[burstIndex]
putLittleEndianShort(backing, 0, SYNCWORD1)
putLittleEndianShort(backing, 2, SYNCWORD2)
putLittleEndianShort(backing, 4, IEC61937_TRUEHD)
putLittleEndianShort(backing, 6, MAT_FRAME_SIZE)
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
var source = 0
var destination = BURST_HEADER_SIZE
while (source < MAT_FRAME_SIZE) {
backing[destination] = matFrame[source + 1]
backing[destination + 1] = matFrame[source]
source += 2
destination += 2
}
// Trailing bytes stay zero for the inter-frame gap; the backing array is never re-dirtied
// beyond MAT_FRAME_SIZE, so they remain zero for the life of the packer.
burst.limit(MAT_PKT_OFFSET)
burst.position(0)
return burst
}
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
}
}
@@ -96,7 +96,6 @@ class MpvPlayerCore private constructor(
// output (#1482).
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
// Frame rate matching
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
@@ -110,7 +109,6 @@ class MpvPlayerCore private constructor(
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
// Audio focus
private var audioFocusManager: AudioFocusManager? = null
@Volatile private var cachedPaused: Boolean = true
@@ -275,7 +273,6 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "SurfaceView added to content view")
}
// Create MpvPlayer on background thread via coroutine
scope.launch {
try {
if (disposing) {
@@ -296,10 +293,17 @@ class MpvPlayerCore private constructor(
setOption("audio-display", "no")
setOption("gapless-audio", "weak")
} else {
setOption("vo", "gpu")
// gpu-next (libplacebo) is mpv 0.41's default VO and the only
// Android path that applies Dolby Vision RPU reshaping — vo=gpu
// renders the raw base layer, which for DV Profile 5 means
// pink/purple IPTPQc2 output (#1902). Keep the explicit chain so
// a device where gpu-next cannot initialize still gets the
// legacy renderer instead of no video output. Film grain is left
// on its `auto` default: applied by the VO under gpu-next, by
// the decoder on the fallback path.
setOption("vo", "gpu-next,gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
@@ -1041,6 +1045,7 @@ class MpvPlayerCore private constructor(
"estimated-vf-fps" to getProperty("estimated-vf-fps"),
"video-bitrate" to getProperty("video-bitrate"),
"hwdec-current" to getProperty("hwdec-current"),
"current-vo" to getProperty("current-vo"),
"audio-codec-name" to getProperty("audio-codec-name"),
"audio-params/samplerate" to getProperty("audio-params/samplerate"),
"audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
@@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import com.edde746.plezy.exoplayer.supportedMpvSpdifCodecs
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -74,8 +75,6 @@ open class MpvPlayerPlugin(
private var initAttemptCounter = 0
private var activeInitAttempt: Int? = null
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
channels.attach(binding)
@@ -104,8 +103,6 @@ open class MpvPlayerPlugin(
takeCoreForTeardown()?.dispose()
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
@@ -141,8 +138,6 @@ open class MpvPlayerPlugin(
Log.d(tag, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
channels.listen(events)
}
@@ -151,8 +146,6 @@ open class MpvPlayerPlugin(
channels.cancel()
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
@@ -167,6 +160,7 @@ open class MpvPlayerPlugin(
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"getAudioSpdifCodecs" -> handleGetAudioSpdifCodecs(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"closeContentFd" -> handleCloseContentFd(call, result)
@@ -176,6 +170,16 @@ open class MpvPlayerPlugin(
}
}
/**
* Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs
* every codec named there with no decode fallback, so with no context to inspect the
* route the conservative answer is the empty list — decode everything (#1703, #1991).
*/
private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) {
val context: Context? = activity ?: applicationContext
result.success(context?.let(::supportedMpvSpdifCodecs) ?: "")
}
private fun handleInitialize(result: MethodChannel.Result) {
// Video cores need the Activity (surface/view hierarchy); the audio-only
// core is built on the application context so it can outlive it.
@@ -6,6 +6,43 @@ object DeviceQuirks {
val isEWaste: Boolean
get() = isGooglePixelDevice || isGoogleTensorDevice
/**
* Highest content frame rate for which [hasUnreliableTunneledPlayback] applies.
*
* Tunneling exists to meet the scheduling deadlines of high-frame-rate 4K, which Amazon
* documents and recommends for exactly that workload, so the cut-off keeps 4K50/60
* tunneled. Withdrawing tunneling is not free even below it — the device pipeline saves
* CPU and power at any rate — but ~33ms per frame is well inside what media3's own
* release path handles, and a juddering picture is the worse trade.
*/
const val MAX_UNRELIABLE_TUNNELED_FPS = 30f
/**
* Fire TV Stick 4K (AFTMM) judders continuously on 23.976p direct play while tunneled.
* The #1802 reporter A/B-tested it: tunneling off, every other setting unchanged, and
* the judder goes away. Untunneled, media3's VideoFrameReleaseHelper owns frame release
* and vsync-aligns it; tunneled, the device owns it and media3 notes it "may do frame
* rate conversion".
*
* The mechanism is unconfirmed. Tunneling fires no VideoFrameMetadataListener and stops
* media3 counting frames in the codec, so nothing app-side can measure the cadence or
* drops. Only the trigger is established.
*
* Deliberately scoped to the one model and the one rate class that were tested: AFTMM
* keeps tunneling for high-frame-rate content, and an unknown rate changes nothing.
* Widen only on further reports.
*/
fun hasUnreliableTunneledPlayback(contentFrameRate: Float): Boolean = hasUnreliableTunneledPlayback(Build.MANUFACTURER, Build.MODEL, contentFrameRate)
internal fun hasUnreliableTunneledPlayback(
manufacturer: String,
model: String,
contentFrameRate: Float
): Boolean = manufacturer.equals("Amazon", ignoreCase = true) &&
model.equals("AFTMM", ignoreCase = true) &&
contentFrameRate > 0f &&
contentFrameRate <= MAX_UNRELIABLE_TUNNELED_FPS
private val isGooglePixelDevice: Boolean
get() = (
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
@@ -3,6 +3,8 @@ package com.edde746.plezy.watchnext
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.WorkManager
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -15,6 +17,19 @@ class SystemShelfUpdateReceiver private constructor(
constructor() : this(Executors.newSingleThreadExecutor(), true)
internal constructor(executor: Executor) : this(executor, false)
companion object {
private const val TAG = "SystemShelfUpdateReceiver"
/**
* Unique name of the periodic ShelfRefreshWorker job 2.13.0 shipped and
* enqueued (KEEP, persisted by WorkManager). The worker is gone, so on an
* updated device the persisted job would wake the process once more, fail
* to instantiate the deleted class, and linger as a permanently failed
* record; cancel it on the first update instead.
*/
internal const val LEGACY_SHELF_REFRESH_WORK = "plezy_shelf_refresh"
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
@@ -23,6 +38,7 @@ class SystemShelfUpdateReceiver private constructor(
try {
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
cancelLegacyShelfRefreshWork(context.applicationContext)
provider.migrateShelfSchema()
} else {
provider.restoreReadGrants()
@@ -33,4 +49,13 @@ class SystemShelfUpdateReceiver private constructor(
}
}
}
/** Best-effort by design: shelf maintenance must not die on a WorkManager failure. */
private fun cancelLegacyShelfRefreshWork(context: Context) {
try {
WorkManager.getInstance(context).cancelUniqueWork(LEGACY_SHELF_REFRESH_WORK)
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel legacy shelf refresh work", e)
}
}
}
@@ -24,7 +24,7 @@ class WatchNextPlugin() :
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
internal const val SCHEMA_VERSION = 3
private var pendingDeepLink: String? = null
fun handleIntent(intent: Intent?): String? {
@@ -0,0 +1,85 @@
package com.edde746.plezy.exoplayer
import android.media.AudioFormat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AudioOutputCachePolicyTest {
@Test
fun decodedPcmIsCacheable() {
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = 34
)
)
}
@Test
fun everyPcmWidthIsCacheable() {
val pcmEncodings = listOf(
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT,
AudioFormat.ENCODING_PCM_FLOAT
)
for (encoding in pcmEncodings) {
assertTrue(
"expected $encoding to be cacheable",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
/** Parking a bitstream track holds the platform's direct output, which is often single-instance. */
@Test
fun bitstreamOutputIsNeverCacheable() {
val passthroughEncodings = listOf(
AudioFormat.ENCODING_AC3,
AudioFormat.ENCODING_E_AC3,
AudioFormat.ENCODING_E_AC3_JOC,
AudioFormat.ENCODING_DTS,
AudioFormat.ENCODING_DTS_HD,
AudioFormat.ENCODING_DOLBY_TRUEHD
)
for (encoding in passthroughEncodings) {
assertFalse(
"expected $encoding to be excluded from the cache",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
@Test
fun offloadOutputIsNeverCacheable() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = true,
sdkInt = 34
)
)
}
@Test
fun cachingIsOffBelowTheFlushableApiLevel() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT - 1
)
)
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT
)
)
}
}
@@ -48,38 +48,160 @@ class AudioOutputPolicyTest {
}
@Test
fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() {
assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true })
fun dtsDecodeIsForcedToFfmpegWhenDirectOutputIsBlocked() {
// Passthrough disabled (the #1995 report), downmix, normalization, or a failure block:
// the stream decodes, and platform DTS decoders render silence on license-gated devices.
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { true }, { true }))
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { true }, { true }))
}
@Test
fun dtsDecodeIsForcedToFfmpegWhenTheRouteCannotBitstream() {
// Onn 4K Plus over HDMI to a Dolby-only TV: passthrough enabled (the Android TV default)
// still decodes because the route carries no DTS shape (#1995).
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { false }))
assertTrue(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { false }))
}
@Test
fun bitstreamCapableDtsRoutesKeepThePlatformDecoderVisible() {
// A bitstreaming session never engages a decoder; leaving the platform decoder visible
// keeps the hardware-decoder tunneling gate exactly as it was.
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts", { false }, { true }))
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd", { false }, { true }))
}
@Test
fun dtsVariantsFfmpegCannotClaimAreLeftAlone() {
// DTS Express and DTS:X are not in FfmpegLibrary's mime map; hiding their platform
// decoders would leave those streams with no decoder at all.
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.hd;profile=lbr", { true }, { false }))
assertFalse(shouldForceFfmpegDtsDecode("audio/vnd.dts.uhd", { true }, { false }))
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
}
@Test
fun nonDtsMimesNeverConsultTheDtsProbes() {
// Decoder selection runs this predicate for every mime, video included; the probes make
// binder calls and must stay behind the mime gate.
assertFalse(
shouldForceFfmpegDtsDecode(
MimeTypes.VIDEO_H265,
{ throw AssertionError("directOutputBlocked consulted for a non-DTS mime") },
{ throw AssertionError("routeCanBitstreamDts consulted for a non-DTS mime") }
)
)
}
@Test
fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() {
// eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack
// AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate;
// naming them strands playback on a dead audio output (#1991).
assertEquals("ac3,dts", mpvSpdifCodecs { true })
}
@Test
fun spdifListDropsCodecsTheRouteCannotBitstream() {
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream,
// TrueHD and DTS do not (#1703).
// Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does not (#1703).
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
assertEquals("ac3,eac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
}
@Test
fun spdifListOmitsDtsHdOnDtsCoreOnlyRoutes() {
// media3's passthrough probe would answer yes for DTS-HD here by downgrading to the
// DTS core. mpv reads `dts,dts-hd` as `dts-hd` alone and force-passes DTS-HD MA, so
// accepting that downgrade would break DTS too.
val dtsCoreOnlyRoute = setOf(C.ENCODING_DTS)
assertEquals("dts", mpvSpdifCodecs { encoding -> encoding in dtsCoreOnlyRoute })
}
@Test
fun spdifListKeepsDtsHdWhenTheRouteAdvertisesIt() {
val dtsHdRoute = setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD)
assertEquals("dts,dts-hd", mpvSpdifCodecs { encoding -> encoding in dtsHdRoute })
assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
}
@Test
fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs { false })
}
@Test
fun iecRouteIsNeverOfferedBelowApi24() {
// ENCODING_IEC61937 does not exist there.
assertFalse(
iecRouteSupported(
sdkInt = 23,
canSizeBuffer = { true },
bitstreamSupported = { true },
directPlaybackSupported = { true },
hdmiRouteAdvertised = { true }
)
)
}
@Test
fun iecRouteRequiresASizableBufferOnEveryTier() {
for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) {
assertFalse(
"api $sdkInt",
iecRouteSupported(
sdkInt = sdkInt,
canSizeBuffer = { false },
bitstreamSupported = { true },
directPlaybackSupported = { true },
hdmiRouteAdvertised = { true }
)
)
}
}
@Test
fun routeOnApi24To28FollowsTheHdmiAdvertisement() {
// No runtime oracle exists below API 29; only an HDMI AudioDeviceInfo that explicitly
// advertises the IEC tuple can vouch for it. Shield Experience 8.x is API 28, and a flat
// `false` on this tier force-decoded TrueHD on routes that genuinely carry it (#1991).
for (supported in booleanArrayOf(true, false)) {
for (sdkInt in intArrayOf(25, 28)) {
assertEquals(
"api $sdkInt supported=$supported",
supported,
iecRouteSupported(
sdkInt = sdkInt,
canSizeBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
directPlaybackSupported = { throw AssertionError("isDirectPlaybackSupported does not exist below API 29") },
hdmiRouteAdvertised = { supported }
)
)
}
}
}
@Test
fun routeOnApi29To32FollowsTheDirectPlaybackProbe() {
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
for (supported in booleanArrayOf(true, false)) {
for (sdkInt in intArrayOf(29, 30, 32)) {
assertEquals(
"api $sdkInt supported=$supported",
supported,
iecRouteSupported(
sdkInt = sdkInt,
canSizeBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
directPlaybackSupported = { supported },
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
)
)
}
}
}
@Test
fun routeOnApi33UsesTheBitstreamProbe() {
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
// probe must not shadow it where the platform can answer precisely.
for (supported in booleanArrayOf(true, false)) {
assertEquals(
"supported=$supported",
supported,
iecRouteSupported(
sdkInt = 33,
canSizeBuffer = { true },
bitstreamSupported = { supported },
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") },
hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") }
)
)
}
}
}
@@ -8,12 +8,23 @@ import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.io.EOFException
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
class BufferedTransformingTrackOutputTest {
@Before
fun enableMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
}
@After
fun resetMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
}
@Test
fun activeTransformBuffersChunksAndEmitsOneNormalizedSample() {
@@ -0,0 +1,227 @@
package com.edde746.plezy.exoplayer
import com.edde746.plezy.exoplayer.BufferingStallPolicy.Verdict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BufferingStallPolicyTest {
private val position = 85_206L
private fun evaluate(
elapsedMs: Long = BufferingStallPolicy.STALL_TIMEOUT_MS,
baselinePositionMs: Long = position,
currentPositionMs: Long = position,
bufferedAheadMs: Long = 30_000L,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
) = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = baselinePositionMs,
currentPositionMs = currentPositionMs,
bufferedPositionMs = currentPositionMs + bufferedAheadMs,
playbackSpeed = playbackSpeed,
loadControlReady = loadControlReady,
loading = loading
)
@Test
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
// The #1790 shape on a high bitrate stream: the byte cap is full at a couple of seconds, the
// renderers never become ready so media3 never asks its load control, and nothing moves.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = true))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = false))
}
@Test
fun aLoaderStillFetchingWithATinyBufferIsAnOrdinaryRebuffer() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 500L, loading = true))
}
@Test
fun aStaleNotYetFromTheLoadControlCannotVetoAStoppedLoader() {
// media3 stops asking the moment a renderer goes unready — the failure this watchdog exists for
// — so a `false` recorded before that must not outrank a loader that has since stopped.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
}
@Test
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
// The real failure this watchdog exists for, on a high bitrate stream: media3 released playback
// off its byte target with only a couple of seconds buffered, and the player still will not move.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loadControlReady = true))
}
@Test
fun aLoadControlHoldingPlaybackBackIsNotIndictedWhileTheLoaderStillWants() {
// Plenty buffered by duration, so the duration signal alone would indict; media3 saying "not
// yet" while its loader keeps fetching is an ordinary rebuffer.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L, loadControlReady = false))
assertEquals(
"nothing says this player could start",
Verdict.STARVED,
evaluate(bufferedAheadMs = 1_000L, loading = true, loadControlReady = false)
)
}
@Test
fun progressOutranksTheLoadControlVerdict() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + 5_000, loadControlReady = true)
)
}
@Test
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
// Enough media to start at 1x, but the load control measures its bar in playout time, so at 2x
// this player is still legitimately rebuffering rather than stalled.
val justEnoughAtNormalSpeed = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1_000L
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed))
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed, playbackSpeed = 2f))
}
@Test
fun aSlowMotionPlayerNeedsLessMediaBeforeItIsIndicted() {
val shortOfTheBar = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = shortOfTheBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = shortOfTheBar, playbackSpeed = 0.5f))
}
@Test
fun anImpossibleSpeedIsTreatedAsNormal() {
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = 0f))
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
}
@Test
fun advancingPositionIsHealthy() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS)
)
}
@Test
fun advancingPositionOutranksAnExpiredTimeout() {
assertEquals(
Verdict.HEALTHY,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, currentPositionMs = position + 5_000)
)
}
@Test
fun clockJitterBelowTheEpsilonIsNotProgress() {
assertEquals(
Verdict.STALLED,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS - 1)
)
}
@Test
fun positionMovingBackwardsIsNotProgress() {
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
}
@Test
fun frozenPositionWaitsOutTheTimeout() {
assertEquals(Verdict.WAITING, evaluate(elapsedMs = BufferingStallPolicy.STALL_TIMEOUT_MS - 1))
}
/** The #1790 shape: data available, nothing playing, no error raised. */
@Test
fun bufferedButFrozenIsStalled() {
assertEquals(Verdict.STALLED, evaluate())
}
@Test
fun emptyBufferIsStarved() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 0))
}
@Test
fun starvationOutranksAnExpiredTimeout() {
assertEquals(
Verdict.STARVED,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, bufferedAheadMs = 0)
)
}
/**
* `DefaultLoadControl` deliberately holds playback in `STATE_BUFFERING` until it has
* [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS] buffered. A watchdog that indicts
* the player inside that window is indicting it for obeying its own load control.
*/
@Test
fun aBufferBelowTheLoadControlPlayStartThresholdIsStarved() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS.toLong()
)
)
}
@Test
fun theStallThresholdClearsTheLoadControlPlayStartThreshold() {
assertTrue(
"MIN_BUFFER_AHEAD_MS (${BufferingStallPolicy.MIN_BUFFER_AHEAD_MS}) must exceed the load " +
"control's post-rebuffer play-start threshold " +
"(${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS})",
BufferingStallPolicy.MIN_BUFFER_AHEAD_MS > LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
@Test
fun starvedIsStickyWhileTheBufferStaysThin() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1
)
)
}
@Test
fun starvationAndProgressBothRestartTheStallClock() {
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.STARVED))
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.HEALTHY))
}
@Test
fun waitingKeepsTheStallClockRunning() {
assertFalse(BufferingStallPolicy.resetsStallClock(Verdict.WAITING))
}
/**
* Regression: a long network stall used to accrue the whole timeout while starved, so the very
* first poll after the buffer refilled reported a stall that never happened and force-reloaded
* a perfectly healthy rebuffer. Starvation restarts the clock, so the refilled buffer gets the
* full timeout to start playing.
*/
@Test
fun aRecoveringBufferIsNotStalledByTimeSpentStarved() {
val starvedFor = 60_000L
val starved = evaluate(elapsedMs = starvedFor, bufferedAheadMs = 0)
assertEquals(Verdict.STARVED, starved)
assertTrue(BufferingStallPolicy.resetsStallClock(starved))
// The watchdog re-baselines on that verdict, so the next poll starts from zero elapsed.
assertEquals(
Verdict.WAITING,
evaluate(
elapsedMs = BufferingStallPolicy.CHECK_INTERVAL_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1
)
)
}
}
@@ -0,0 +1,270 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the DTS-HD -> IEC 61937 "DTS type IV" carrier against FFmpeg (#1988).
*
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
* FFmpeg's own input and output at the 768kHz HD rate that fills the 192kHz/7.1 carrier.
*
* FFmpeg cannot encode Master Audio, so the access units are synthesized — real `dca` core frames,
* each glued to a hand-built extension substream — by `dtshd_fixture_generator.py` beside the
* fixtures; the golden is then FFmpeg's own packing:
*
* ```
* ffmpeg -f dts -i dtshd_ma_access_units.bin -c:a copy -f spdif -dtshd_rate 768000 \
* dtshd_ma_iec61937_golden.bin
* ```
*
* One mid-stream unit is oversized on purpose, so the fixtures also pin FFmpeg's overflow answer:
* strip to the core substream and hold that for ~60s.
*/
class DtsHdIecPackerTest {
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("dtshd_ma_access_units.bin") }
private val golden by lazy { resource("dtshd_ma_iec61937_golden.bin") }
/** 512 samples at the fixture's 48kHz core rate on the 768kHz carrier model, in bytes. */
private val burstSize = 32768
/** The whole point: our carrier and FFmpeg's are the same bytes. */
@Test
fun packedCarrierMatchesFfmpegByteForByte() {
val packed = packAll(accessUnits)
assertEquals(
"burst count differs from FFmpeg",
golden.size / burstSize,
packed.size / burstSize
)
// Compare per burst so a failure names the burst rather than dumping 480KB.
for (index in 0 until packed.size / burstSize) {
val from = index * burstSize
val to = from + burstSize
assertArrayEquals(
"burst $index differs from FFmpeg",
golden.copyOfRange(from, to),
packed.copyOfRange(from, to)
)
}
}
/** Each burst is a full IEC 61937 frame: preamble, then payload, then zero padding. */
@Test
fun everyBurstCarriesTheDtsHdPreamble() {
val packed = packAll(accessUnits)
for (index in 0 until packed.size / burstSize) {
val at = index * burstSize
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
// Data type 0x11 with subtype 4: an 8192 IEC 60958 frame repetition period.
assertEquals("burst $index Pc (IEC61937_DTSHD)", 0x0411, readLittleEndianShort(packed, at + 4))
val payloadSize = 10 + 2 + burstAccessUnitSize(packed, at)
assertEquals(
"burst $index Pd must carry FFmpeg's (length & 0xf) == 0x8 alignment",
((payloadSize + 0x17) and 0x0F.inv()) - 8,
readLittleEndianShort(packed, at + 6)
)
}
}
/**
* A Master Audio peak the carrier cannot hold is stripped to the always-fitting core substream,
* and stays stripped for ~60s so the receiver does not flap between core and MA decoding. The
* fixture's fifth unit is oversized on purpose; everything after it is core-only.
*/
@Test
fun anOversizedUnitStripsToTheCoreAndHolds() {
val packer = DtsHdIecPacker()
val packed = packAll(accessUnits, packer)
val fullUnit = 3904
val coreOnly = 1884
for (index in 0 until packed.size / burstSize) {
val expected = if (index < 4) fullUnit else coreOnly
assertEquals("burst $index payload size", expected, burstAccessUnitSize(packed, index * burstSize))
}
assertTrue("the strip must be observable for logging", packer.strippedToCore)
assertFalse("stripping is a downgrade, not an unsupported stream", packer.unsupportedStream)
}
/** Core frames and their glued extension substreams split as one access unit each. */
@Test
fun accessUnitsSpanTheCoreAndItsExtensionSubstreams() {
val packer = DtsHdIecPacker()
var units = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
units++
offset += length
}
assertEquals("every byte of the fixture belongs to a unit", accessUnits.size, offset)
assertEquals(golden.size / burstSize, units)
}
/**
* Streams with core sometimes open with a stray HD frame that has no core (FFmpeg discards
* these). It must be consumed as a unit so the stream keeps moving, but never carried: there is
* no core to derive the burst period from.
*/
@Test
fun aStrayLeadingExtensionSubstreamIsConsumedButNotCarried() {
val packer = DtsHdIecPacker()
val firstUnit = packer.accessUnitLength(accessUnits, 0, accessUnits.size)
val exssSize = 2020
val stray = accessUnits.copyOfRange(firstUnit - exssSize, firstUnit)
val length = packer.accessUnitLength(stray, 0, stray.size)
assertEquals("the stray frame's own size field walks it", exssSize, length)
assertNull("a coreless frame cannot ride the carrier", packer.packAccessUnit(stray, 0, length))
assertFalse("a stray frame is dropped, not latched", packer.unsupportedStream)
}
/** The wide (bHeaderSizeType=1) extension-substream header carries 20-bit size fields. */
@Test
fun wideHeaderExtensionSubstreamSizesAreParsed() {
val packer = DtsHdIecPacker()
val core = accessUnits.copyOfRange(0, 1884)
val exssSize = 4100
val unit = core + wideHeaderExtensionSubstream(exssSize)
assertEquals(1884 + exssSize, packer.accessUnitLength(unit, 0, unit.size))
}
/**
* Little-endian and 14-bit core framings (S/PDIF and DTS-in-WAV captures) have no period mapping
* on this carrier; they must latch the stream unsupported so the sink hands it to the decoder,
* not silently consume it.
*/
@Test
fun nonBigEndianCoreFramingLatchesTheStreamUnsupported() {
for (sync in listOf(
byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()),
byteArrayOf(0x1F, 0xFF.toByte(), 0xE8.toByte(), 0x00),
byteArrayOf(0xFF.toByte(), 0x1F, 0x00, 0xE8.toByte())
)) {
val packer = DtsHdIecPacker()
val buffer = sync + ByteArray(64)
assertEquals(0, packer.accessUnitLength(buffer, 0, buffer.size))
assertTrue("sync ${sync.joinToString { "%02x".format(it) }}", packer.unsupportedStream)
}
}
/** A 44.1kHz-family core maps to no IEC 61937-11 repetition period at 192kHz; it must decode. */
@Test
fun aFortyFourFamilyCoreLatchesTheStreamUnsupported() {
val packer = DtsHdIecPacker()
val unit = accessUnits.copyOfRange(0, 1884)
// SFREQ sits in bits [5:2] of byte 8; index 8 is 44100Hz.
unit[8] = ((unit[8].toInt() and 0b11000011) or (8 shl 2)).toByte()
assertNull(packer.packAccessUnit(unit, 0, unit.size))
assertTrue(packer.unsupportedStream)
}
/**
* The flags gate every later call, so leaving them latched across a reset would make a packer
* that once saw a bad stream emit nothing (or log strips) for the rest of its life.
*/
@Test
fun resetClearsTheLatchesAndReproducesTheStream() {
val packer = DtsHdIecPacker()
val leSync = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
packer.accessUnitLength(leSync, 0, leSync.size)
assertTrue(packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the unsupported latch", packer.unsupportedStream)
assertFalse("reset must clear the strip latch", packer.strippedToCore)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
packAll(accessUnits, packer)
)
}
/** The packer must not allocate a burst per unit; buffers alternate and are reused. */
@Test
fun burstBuffersAreReusedRatherThanAllocated() {
val packer = DtsHdIecPacker()
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
var bursts = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected multiple bursts", bursts > 2)
assertEquals("buffers must alternate between exactly two", 2, seen.size)
}
private fun packAll(units: ByteArray, packer: DtsHdIecPacker = DtsHdIecPacker()): ByteArray {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (offset < units.size) {
val length = packer.accessUnitLength(units, offset, units.size)
if (length == 0) break
packer.packAccessUnit(units, offset, length)?.let { burst ->
val copy = ByteArray(burst.remaining())
burst.duplicate().get(copy)
out.write(copy)
}
offset += length
}
return out.toByteArray()
}
/** The 16-bit big-endian size field after the start code, read from the byte-swapped burst. */
private fun burstAccessUnitSize(packed: ByteArray, burstOffset: Int): Int {
// Payload bytes 10 and 11 land swapped within their 16-bit word: 10 -> +19, 11 -> +18.
val high = packed[burstOffset + 8 + 11].toInt() and 0xFF
val low = packed[burstOffset + 8 + 10].toInt() and 0xFF
return (high shl 8) or low
}
/** An extension substream whose header uses the wide 12/20-bit size fields. */
private fun wideHeaderExtensionSubstream(size: Int): ByteArray {
val frame = ByteArray(size)
frame[0] = 0x64
frame[1] = 0x58
frame[2] = 0x20
frame[3] = 0x25
// After UserDefinedBits(8): nExtSSIndex(2)=0, bHeaderSizeType(1)=1, then 12 header-size bits
// and 20 frame-size bits, all values stored minus one.
var bits = 0L
bits = (bits shl 2) or 0L
bits = (bits shl 1) or 1L
bits = (bits shl 12) or (32L - 1)
bits = (bits shl 20) or (size.toLong() - 1)
// 35 bits, MSB-aligned into bytes 5..9.
val aligned = bits shl (40 - 35)
for (i in 0 until 5) {
frame[5 + i] = ((aligned shr (32 - 8 * i)) and 0xFF).toByte()
}
return frame
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
}
@@ -10,8 +10,6 @@ class DvBitstreamSanitizerTest {
private val sanitizer = DvBitstreamSanitizer()
// --- HDR10+ SEI stripping (native DV codec path) ---
@Test
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02))
@@ -85,8 +83,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(original, remainingBytes(buffer))
}
// --- DV RPU/EL stripping (HEVC fallback path) ---
@Test
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
val vcl = annexBNal(1, byteArrayOf(0x01))
@@ -111,8 +107,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
}
// --- Buffer handling ---
@Test
fun respectsPositionAndRestoresIt() {
val prefix = byteArrayOf(0xAA.toByte(), 0xBB.toByte())
@@ -245,8 +239,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
}
// --- Helpers ---
/** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */
private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray {
val startCode = if (startCodeLen == 3) byteArrayOf(0, 0, 1) else byteArrayOf(0, 0, 0, 1)
@@ -24,8 +24,6 @@ class EndOfStreamPolicyTest {
frameStallMs = frameStallMs
)
// isFinishedFile
@Test
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
// The reported failure: the clock ran a minute past the last frame.
@@ -65,8 +63,6 @@ class EndOfStreamPolicyTest {
assertFalse(isFinishedFile(hasPlaybackOutput = false))
}
// fallbackStartPositionMs
@Test
fun fallbackResumesStrictlyInsideTheMedia() {
// The observed hand-off asked MPV to start 59_816ms past the end.
@@ -0,0 +1,61 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalSubtitleIdsTest {
@Test
fun unmergedTagResolves() {
assertTrue(ExternalSubtitleIds.isExternal("external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("external_0"))
assertEquals(7, ExternalSubtitleIds.indexOf("external_7"))
}
@Test
fun mediaSourceFactoryMergePrefixResolves() {
// DefaultMediaSourceFactory always merges side-loaded subtitles with the
// primary source, so MergingMediaPeriod prefixes the period index.
assertTrue(ExternalSubtitleIds.isExternal("1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("1:external_0"))
assertEquals(2, ExternalSubtitleIds.indexOf("3:external_2"))
}
@Test
fun containerSidecarOuterMergePrefixResolves() {
// The container-sidecar path wraps that merge in a second MergingMediaSource.
assertTrue(ExternalSubtitleIds.isExternal("0:1:external_0"))
assertEquals(0, ExternalSubtitleIds.indexOf("0:1:external_0"))
assertEquals(4, ExternalSubtitleIds.indexOf("0:2:external_4"))
}
@Test
fun embeddedAndUnknownIdsAreNotExternal() {
assertFalse(ExternalSubtitleIds.isExternal(null))
assertFalse(ExternalSubtitleIds.isExternal("1:2"))
assertFalse(ExternalSubtitleIds.isExternal("0:"))
// A container child whose own id merely ends in the tag's text.
assertFalse(ExternalSubtitleIds.isExternal("1:not_external_0"))
assertNull(ExternalSubtitleIds.indexOf("1:2"))
}
@Test
fun malformedTagIsExternalWithoutAnIndex() {
// Still a side-loaded track, but no usable URI lookup.
assertTrue(ExternalSubtitleIds.isExternal("1:external_x"))
assertNull(ExternalSubtitleIds.indexOf("1:external_x"))
}
@Test
fun writtenIdRoundTrips() {
for (index in 0..3) {
val id = ExternalSubtitleIds.idFor(index)
assertEquals(index, ExternalSubtitleIds.indexOf(id))
assertEquals(index, ExternalSubtitleIds.indexOf("1:$id"))
assertEquals(index, ExternalSubtitleIds.indexOf("0:1:$id"))
}
}
}
@@ -0,0 +1,662 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Timeline
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.MediaSource
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Routing and back-pressure contract for the IEC 61937 carrier: TrueHD/MAT (#1804) and DTS-HD
* (#1988).
*
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
* the wrong sink handle a carrier codec.
*/
@OptIn(UnstableApi::class)
class IecCarrierSinkTest {
private val accessUnits: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
.use { it.readBytes() }
private val golden: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin"))
.use { it.readBytes() }
private val dtsHdAccessUnits: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_access_units.bin"))
.use { it.readBytes() }
}
private val dtsHdGolden: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_iec61937_golden.bin"))
.use { it.readBytes() }
}
private fun trueHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
.setChannelCount(6)
.setSampleRate(sampleRate)
.build()
private fun dtsHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_HD)
.setChannelCount(6)
.setSampleRate(sampleRate)
.build()
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
private fun sink(
carrier: FakeSink = FakeSink(),
normal: FakeSink = FakeSink(),
routeAvailable: Boolean = true,
blocked: Boolean = false
) = IecCarrierSink(normal, carrier, { routeAvailable }, { blocked })
/**
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
* its raw ENCODING_DOLBY_TRUEHD path, which is the configuration that wedges on these devices.
*/
@Test
fun trueHdWithoutACarrierRouteIsReportedUnsupportedSoItDecodes() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal, routeAvailable = false)
assertFalse(carrierSink.supportsFormat(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
@Test
fun trueHdWithACarrierRouteIsSupportedDirectly() {
val carrierSink = sink()
assertTrue(carrierSink.supportsFormat(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
@Test
fun dtsHdWithACarrierRouteIsSupportedDirectly() {
val carrierSink = sink()
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
* The route that advertises the carrier and raw `ENCODING_DTS_HD` at once is the one whose raw
* path renders silence (#1988), so while the carrier route exists DTS-HD is the carrier or it is
* decoded — never the normal sink's raw path, even when policy declines the carrier.
*/
@Test
fun dtsHdOnACarrierRouteNeverFallsThroughToTheRawPath() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val blockedSink = sink(normal = normal, blocked = true)
assertFalse(blockedSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, blockedSink.getFormatSupport(dtsHdFormat()))
}
/**
* Routes without the IEC carrier never had it to lose, and some of them bitstream raw DTS-HD
* genuinely; the pre-carrier behavior is preserved there.
*/
@Test
fun dtsHdWithoutACarrierRouteFallsThroughToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal, routeAvailable = false)
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
@Test
fun dtsExpressIsLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal)
val express = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_EXPRESS)
.setSampleRate(48_000)
.build()
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(express))
assertTrue(carrierSink.supportsFormat(express))
}
/** Downmix and normalization already force decoding; the carrier must not override that. */
@Test
fun blockedDirectOutputDeclinesTheCarrier() {
val carrierSink = sink(blocked = true)
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/**
* 44.1kHz-family streams ride a 176.4kHz carrier this path does not build. It has to be decided
* from the format: the packer only learns the rate from the bitstream, long after selection, and
* selecting the carrier for it would produce silence.
*/
@Test
fun theFortyFourPointOneFamilyDeclinesTheCarrierFromTheFormatAlone() {
val carrierSink = sink()
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 44_100)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 176_400)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 44_100)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 88_200)))
}
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
@Test
fun aSpeedChangeDeclinesTheCarrier() {
val carrierSink = sink()
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
* The real UI path: speed changes while the carrier is already live. Querying support after
* setting speed passes trivially; nothing re-asks the sink unless capabilities are invalidated,
* so without this the carrier keeps running and is handed a speed it cannot apply.
*/
@Test
fun leavingUnitSpeedMidPlaybackInvalidatesCapabilities() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig())
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals("the renderer must be asked to re-evaluate", 1, listener.capabilityInvalidations)
assertEquals(
"the carrier delegate must never receive a speed it cannot apply",
1f,
carrier.lastPlaybackParameters.speed,
0f
)
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/** Returning to 1x has to re-offer the carrier, or Atmos is lost until the next open. */
@Test
fun returningToUnitSpeedInvalidatesCapabilitiesAgain() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig())
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(2, listener.capabilityInvalidations)
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
/** A speed change that stays at 1x is not a transition and must not disturb selection. */
@Test
fun aNoOpSpeedChangeDoesNotInvalidateCapabilities() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig())
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(0, listener.capabilityInvalidations)
}
/**
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
* When they disagree the packer emits nothing, so consuming the input would turn the stream into
* silence. The unit has to survive for whoever takes over.
*/
@Test
fun aRateFamilyMismatchLeavesTheCarrierInsteadOfGoingSilent() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig())
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
assertFalse("a mismatch must apply back pressure, not report success", accepted)
assertEquals("the offending access unit must stay in the buffer", 0, buffer.position())
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
assertEquals(
"TrueHD must now decode rather than ride the carrier",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* The DTS-HD equivalent: a bitstream whose framing the carrier cannot ride (an S/PDIF-style
* little-endian capture) must latch onto the decoder, and the offending unit must survive.
*/
@Test
fun aDtsHdFramingMismatchLeavesTheCarrierInsteadOfGoingSilent() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val littleEndianCapture = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
val buffer = ByteBuffer.wrap(littleEndianCapture)
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
assertFalse("a mismatch must apply back pressure, not report success", accepted)
assertEquals("the offending unit must stay in the buffer", 0, buffer.position())
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
assertEquals(
"DTS-HD must now decode rather than ride the carrier or the raw path",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(dtsHdFormat())
)
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* media3 resets every renderer disabled by a new selection before enabling the replacement, and
* both audio renderers share this sink. That reset lands mid-handover, so a latch cleared there
* would re-offer the carrier and loop straight back into the mismatch.
*/
@Test
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.flush()
carrierSink.reset()
assertEquals(
"the carrier must stay off across the handover",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/** The latch is scoped to the sink, so a released sink starts clean. */
@Test
fun releasingTheSinkClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.release()
assertEquals(
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/**
* Genuine 44.1kHz TrueHD access units, encoder-produced. Flipping the rate nibble in a 48kHz
* stream would invalidate the major sync checksum, which is not the case being modelled.
*/
private val fortyFourFamilyUnits by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_441_access_units.bin"))
.use { it.readBytes() }
}
private fun fortyFourFamilyAccessUnits(): ByteArray = fortyFourFamilyUnits
/**
* The mismatch is a property of one stream. The owner signals the real boundary, because the sink
* cannot see it: renderer resets and flushes happen constantly within a single item.
*/
@Test
fun aNewMediaItemClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
assertEquals(
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(trueHdFormat())
)
carrierSink.beginMediaItem()
assertEquals(
"a new item must be able to bitstream again",
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/**
* The boundary hook runs on the app thread while the mismatch is found on the playback thread, so
* a buffer from the outgoing stream can be handled after the new item began. That write must name
* the stream it came from rather than disabling the carrier for its successor.
*/
@Test
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(audioSinkConfig())
// The new item starts before the outgoing stream's last buffer is handled.
carrierSink.beginMediaItem()
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
assertEquals(
"the stale mismatch belongs to the previous item",
AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY,
carrierSink.getFormatSupport(trueHdFormat())
)
}
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
@Test
fun otherFormatsAreLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal)
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(ac3))
assertTrue(carrierSink.supportsFormat(ac3))
}
/**
* The regression this exists for: a delegate that refuses a burst part-way through a sample must
* not cost the access units behind it. media3 re-delivers the same buffer after a false return,
* so the carrier has to resume from where it stopped and come out byte-identical.
*/
@Test
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig())
// Refuse every third burst once, so rejections land inside samples rather than between them.
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink, accessUnits)
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
}
/** With no back pressure the same stream must produce exactly the same bytes. */
@Test
fun theCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig())
assertArrayEquals(golden, feedWholeStream(carrierSink, accessUnits))
}
/** A DTS-HD stream must select the DTS packer and come out matching FFmpeg's bytes. */
@Test
fun theDtsHdCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
assertArrayEquals(dtsHdGolden, feedWholeStream(carrierSink, dtsHdAccessUnits))
}
/** Back pressure must not cost DTS-HD units either; bursts and units are one-to-one there. */
@Test
fun aRefusedDtsHdBurstLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink, dtsHdAccessUnits)
assertArrayEquals(dtsHdGolden, produced)
assertTrue(carrier.refusals > 0)
}
/**
* Burst timestamps come from the carrier cadence, not the closing access unit. A DTS-HD burst is
* 10666.67us of carrier — not a whole number — so the cadence must be derived from cumulative
* carrier frames; per-burst rounding would drift ~62ms over a movie.
*/
@Test
fun dtsHdBurstTimestampsFollowTheExactCarrierCadence() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
feedWholeStream(carrierSink, dtsHdAccessUnits)
val framesPerBurst = 32768L / IecCarrier.BYTES_PER_FRAME
carrier.bufferTimesUs.forEachIndexed { index, timeUs ->
assertEquals(
"burst $index timestamp",
index * framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE,
timeUs
)
}
}
/** A refused burst must be re-offered before any further input is taken. */
@Test
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig())
carrier.refuseNext = true
val buffer = ByteBuffer.wrap(accessUnits)
// Drive until the first refusal is observed.
while (buffer.hasRemaining() && carrierSink.handleBuffer(buffer, 0L, 1)) Unit
assertTrue("expected a refusal to leave input unconsumed", buffer.hasRemaining())
assertTrue(carrierSink.hasPendingData())
}
@Test
fun persistentControlsReachBothDelegates() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
carrierSink.setVolume(0.5f)
carrierSink.setAudioSessionId(7)
assertEquals(0.5f, carrier.lastVolume, 0f)
assertEquals(0.5f, normal.lastVolume, 0f)
assertEquals(7, carrier.sessionId)
assertEquals(7, normal.sessionId)
}
/** Silence skipping deletes "silent" bytes, which would break the carrier's frame cadence. */
@Test
fun silenceSkippingNeverReachesTheCarrier() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
carrierSink.setSkipSilenceEnabled(true)
assertTrue(normal.skipSilence)
assertFalse("the carrier delegate must never skip silence", carrier.skipSilence)
}
@Test
fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val inputConfig = AudioSink.AudioSinkConfig.Builder(trueHdFormat())
.setPreferredBufferSizeOverride(4_096)
.setTimeline(Timeline.EMPTY)
.setMediaPeriodId(MediaSource.MediaPeriodId("period"))
.build()
carrierSink.configure(inputConfig)
val configured = checkNotNull(carrier.configuredConfig)
assertEquals(inputConfig.preferredBufferSizeOverride, configured.preferredBufferSizeOverride)
assertSame(inputConfig.timeline, configured.timeline)
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
/** DTS-HD rides the same PCM-shaped carrier tuple TrueHD does. */
@Test
fun theDtsHdCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val configured = checkNotNull(carrier.configuredConfig)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
@Test
fun nonCarrierAudioIsConfiguredOnTheNormalSink() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
val config = audioSinkConfig(ac3)
carrierSink.configure(config)
assertSame(config, normal.configuredConfig)
assertSame(ac3, normal.configuredConfig?.format)
assertEquals(null, carrier.configuredConfig)
}
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(units)
var guard = 0
// media3 retries handleBuffer until it returns true, even once the buffer is fully consumed;
// a refusal of the stream's final burst leaves it pending with nothing remaining to read.
while ((buffer.hasRemaining() || carrierSink.hasPendingData()) && guard++ < 10_000) {
carrierSink.handleBuffer(buffer, 0L, 1)
}
val carrier = carrierSinkDelegate(carrierSink)
return carrier.written.toByteArray()
}
private fun carrierSinkDelegate(sink: IecCarrierSink): FakeSink {
val field = IecCarrierSink::class.java.getDeclaredField("carrierSink")
field.isAccessible = true
return field.get(sink) as FakeSink
}
private class RecordingSinkListener : AudioSink.Listener {
var capabilityInvalidations = 0
override fun onPositionDiscontinuity() = Unit
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = Unit
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = Unit
override fun onAudioSinkError(audioSinkError: Exception) = Unit
override fun onAudioCapabilitiesChanged() {
capabilityInvalidations++
}
}
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
val bufferTimesUs = mutableListOf<Long>()
var configuredConfig: AudioSink.AudioSinkConfig? = null
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
var lastVolume: Float = -1f
var sessionId: Int = -1
var skipSilence: Boolean = false
var refuseNext: Boolean = false
var refuseEveryNth: Int = 0
var refusals: Int = 0
var lastPlaybackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var offered = 0
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
offered++
val refuse = refuseNext || (refuseEveryNth > 0 && offered % refuseEveryNth == 0)
if (refuse) {
refuseNext = false
refusals++
// Refuse once per burst: the retry must be accepted or the stream cannot progress.
offered++
return false
}
val copy = ByteArray(buffer.remaining())
buffer.duplicate().get(copy)
written.write(copy)
bufferTimesUs.add(presentationTimeUs)
buffer.position(buffer.limit())
return true
}
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
configuredConfig = audioSinkConfig
}
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
override fun getFormatSupport(format: Format) = formatSupport
override fun setVolume(volume: Float) {
lastVolume = volume
}
override fun setAudioSessionId(audioSessionId: Int) {
sessionId = audioSessionId
}
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
skipSilence = skipSilenceEnabled
}
override fun getSkipSilenceEnabled() = skipSilence
override fun setListener(listener: AudioSink.Listener) = Unit
override fun setPlayerId(playerId: PlayerId?) = Unit
override fun setClock(clock: Clock) = Unit
override fun getCurrentPositionUs(sourceEnded: Boolean) = 0L
override fun play() = Unit
override fun handleDiscontinuity() = Unit
override fun playToEndOfStream() = Unit
override fun isEnded() = false
override fun hasPendingData() = false
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
lastPlaybackParameters = playbackParameters
}
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun setAudioAttributes(audioAttributes: AudioAttributes) = Unit
override fun getAudioAttributes(): AudioAttributes? = null
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) = Unit
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) = Unit
override fun getAudioTrackBufferSizeUs() = 0L
override fun enableTunnelingV21() = Unit
override fun disableTunneling() = Unit
override fun pause() = Unit
override fun flush() = Unit
override fun reset() = Unit
}
}
@@ -0,0 +1,72 @@
package com.edde746.plezy.exoplayer
import androidx.media3.exoplayer.DefaultLoadControl
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* [LoadControlPolicyTest] checks that the policy's arithmetic satisfies the ordering we believe
* media3 requires. This file checks the belief, by handing every value the settings screen can
* produce to a real `DefaultLoadControl.Builder`.
*
* The distinction matters because media3 validates with Guava `Preconditions.checkArgument`, not
* `androidx.media3.common.util.Assertions` and not a JVM `assert`. It is an unconditional throw
* that survives R8 — nothing in `proguard-rules.pro` elides it — so a rejected pair is an
* `IllegalArgumentException` out of player construction on a user's device, not a degraded buffer.
* A media3 upgrade that tightens the ordering should fail here rather than there.
*/
@RunWith(RobolectricTestRunner::class)
class LoadControlPolicyBuildTest {
/** Both Auto memory tiers, their boundary, and the unknown-memory case. */
private val memoryTiers = intArrayOf(0, 512, 1024, 2048, 2049, 4096, 8192)
private fun build(tier: LoadControlPolicy.BufferTier, availableMB: Int): DefaultLoadControl {
val durations = LoadControlPolicy.bufferDurations(tier, availableMB)
return DefaultLoadControl.Builder()
.setTargetBufferBytes(LoadControlPolicy.MIN_TARGET_BYTES)
.setPrioritizeTimeOverSizeThresholds(false)
.setBufferDurationsMs(
durations.minBufferMs,
durations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
@Test
fun everyTierBuildsOnEveryMemoryTier() {
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in memoryTiers) {
// build() throws IllegalArgumentException on a rejected ordering; reaching the next
// iteration is the assertion.
build(tier, availableMB)
}
}
}
@Test
fun aTierNameThisVersionDoesNotKnowStillBuilds() {
// The tier crosses a MethodChannel as a string, so the enum is not the only possible input:
// a Dart build newer than this native build can send a name that is not in `entries`.
for (wire in arrayOf(null, "", "gigantic", "EXTRA_LARGE", "0")) {
build(LoadControlPolicy.BufferTier.fromWire(wire), availableMB = 1024)
}
}
@Test
fun theShippedAutoTiersAreUnchanged() {
// #1816 adds a setting; it must not quietly retune the default everyone already has.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2048)
)
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2049)
)
}
}
@@ -2,14 +2,13 @@ package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private const val MIB = 1024 * 1024
class LoadControlPolicyTest {
// autoTargetBufferBytes
@Test
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
assertEquals(
@@ -63,8 +62,6 @@ class LoadControlPolicyTest {
)
}
// readAheadSeconds
@Test
fun readAheadReportsSecondsAtAKnownBitrate() {
// 64MiB of the #1618 stream (103_341 kbps) is ~5.2s — under the 15s minBufferMs, so the
@@ -84,4 +81,108 @@ class LoadControlPolicyTest {
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, 0L))
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
}
/**
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
* a JVM assert, so a bad pair takes down `DefaultLoadControl.Builder.build()` in release too.
* Every path through the policy has to satisfy it, including inputs no UI can produce.
*/
private fun assertBuildable(durations: LoadControlPolicy.BufferDurations) {
assertTrue(
"bufferForPlaybackMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS}) must not exceed " +
"minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS <= durations.minBufferMs
)
assertTrue(
"bufferForPlaybackAfterRebufferMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS}) " +
"must not exceed minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS <= durations.minBufferMs
)
assertTrue(
"minBufferMs (${durations.minBufferMs}) must not exceed maxBufferMs (${durations.maxBufferMs})",
durations.minBufferMs <= durations.maxBufferMs
)
}
@Test
fun autoKeepsTheConservativeTierOnALowMemoryDevice() {
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 1024)
)
}
@Test
fun autoKeepsTheRoomierTierWhenMemoryAllows() {
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 4096)
)
}
@Test
fun unknownMemoryTakesTheConservativeTier() {
// availMem is reported as non-positive when unknown; guessing roomy there would be the
// wrong way to be wrong on the device that can least afford it.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 0)
)
}
@Test
fun anExplicitTierIgnoresTheMemoryTier() {
// The whole point of #1816: a low-memory box may still want a deep buffer, because the
// constraint it is working around is the network, not the heap. Auto is the only mode that
// gets to consult memory.
assertEquals(
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 8192),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 512)
)
}
@Test
fun theTiersMatchTheJellyfinClients() {
// Borrowed verbatim from jellyfin-androidtv BufferLength and jellyfin-android
// PlayerViewModel so the same words mean the same thing across Jellyfin clients.
// Changing these silently diverges Plezy from that shared vocabulary.
assertEquals(
LoadControlPolicy.BufferDurations(50_000, 120_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 4096)
)
assertEquals(
LoadControlPolicy.BufferDurations(80_000, 240_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.EXTRA_LARGE, availableMB = 4096)
)
}
@Test
fun everyTierProducesABuildablePairOnEveryMemoryTier() {
// A pair media3 rejects is a crash on play, not a bad buffer.
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in intArrayOf(0, 512, 1024, 2048, 4096, 8192)) {
assertBuildable(LoadControlPolicy.bufferDurations(tier, availableMB))
}
}
}
@Test
fun theWireNamesMatchTheDartNativeValues() {
assertEquals(LoadControlPolicy.BufferTier.LARGE, LoadControlPolicy.BufferTier.fromWire("large"))
assertEquals(
LoadControlPolicy.BufferTier.EXTRA_LARGE,
LoadControlPolicy.BufferTier.fromWire("extra_large")
)
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("auto"))
}
@Test
fun anUnrecognisedOrAbsentTierFallsBackToAuto() {
// A Dart/Kotlin skew must degrade to the shipped defaults, not to a deep buffer nobody
// asked for on a device that may not afford it.
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(null))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(""))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("EXTRA_LARGE"))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("extraLarge"))
}
}
@@ -0,0 +1,154 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MatroskaLateTracksTest {
private class CapturedTrack(val type: Int) : TrackOutput {
val timesUs = mutableListOf<Long>()
var format: Format? = null
val sampleCount: Int
get() = timesUs.size
override fun format(format: Format) {
this.format = format
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length)
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
data.skipBytes(length)
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
timesUs.add(timeUs)
}
}
private class CapturingExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, CapturedTrack>()
val seekMaps = mutableListOf<SeekMap>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
override fun endTracks() = Unit
override fun seekMap(seekMap: SeekMap) {
seekMaps.add(seekMap)
}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val bytesRead = minOf(length, data.size - position.toInt())
data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead)
position += bytesRead
return bytesRead
}
}
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) {
"fixture matroska_tracks_at_end.mkv missing from test resources"
}.use { it.readBytes() }
private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) {
var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length)
val seekPosition = PositionHolder()
repeat(100_000) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, length)
}
}
}
error("extractor did not reach end of input")
}
@Test
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong())
assertEquals(2, output.tracks.size)
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
output.tracks.values.forEach { track ->
assertNotNull(track.format)
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
}
}
/**
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for
* tracks-after-clusters files is before the Tracks element parsed — the map then permanently
* reports unseekable and resolves every seek to byte 0, snapping playback to the start
* (#1969; upstream androidx/media #3377).
* The production stack must repair it through the per-track cue lookups.
*/
@Test
fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() {
val assHandler = AssHandler()
val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
val reader = ByteArrayDataReader(data)
readToEnd(extractor, reader, data.size.toLong())
assertEquals(1, output.seekMaps.size)
val seekMap = output.seekMaps.single()
assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable)
val points = seekMap.getSeekPoints(500_000L)
// Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional
// estimate would return the requested time instead.
assertEquals(0L, points.first.timeUs)
val position = points.first.position
val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
assertTrue(
"seek position $position must point at a Cluster element",
data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId)
)
output.tracks.values.forEach { it.timesUs.clear() }
extractor.seek(position, points.first.timeUs)
reader.position = position
readToEnd(extractor, reader, data.size.toLong())
output.tracks.values.forEach { track ->
assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0)
assertEquals(points.first.timeUs, track.timesUs.first())
}
}
}
@@ -0,0 +1,125 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
/**
* The wrapper has to be a complete [LoadControl], not a partial one.
*
* media3's interface gives almost every member a default implementation that throws
* `IllegalStateException`, so a forwarder this class forgets does not fail to compile — it takes the
* player down at construction, or the first time ExoPlayer asks. Building a real player is the only
* assertion that covers all of them at once, including methods a media3 upgrade adds later.
*/
@RunWith(RobolectricTestRunner::class)
class ObservingLoadControlTest {
private fun loadControl() = DefaultLoadControl.Builder().setTargetBufferBytes(4 * 1024 * 1024).build()
@Test
fun aRealPlayerCanBeBuiltAndReleasedThroughTheWrapper() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val player = ExoPlayer.Builder(activity).setLoadControl(ObservingLoadControl(loadControl())).build()
try {
// Construction alone already routes through getBackBufferDurationUs and getAllocator.
assertEquals(ExoPlayer.STATE_IDLE, player.playbackState)
} finally {
player.release()
}
}
@Test
fun theStartPlaybackVerdictIsRecordedAndResettable() {
// A fake delegate, because a fabricated `Parameters` cannot satisfy DefaultLoadControl's own
// timeline lookups. What matters here is that the wrapper reports what the delegate said.
val delegate = FakeLoadControl()
val observing = ObservingLoadControl(delegate)
assertNull("nothing asked yet", observing.startPlaybackVerdict)
delegate.startPlayback = true
assertTrue(observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(true, observing.startPlaybackVerdict)
delegate.startPlayback = false
assertEquals(false, observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(false, observing.startPlaybackVerdict)
observing.reset()
assertNull("a fresh buffering window must not inherit the last verdict", observing.startPlaybackVerdict)
}
@Test
fun otherCallsReachTheDelegate() {
val delegate = loadControl()
val observing = ObservingLoadControl(delegate)
val playerId = androidx.media3.exoplayer.analytics.PlayerId.UNSET
observing.onPrepared(playerId)
try {
assertSame(delegate.getAllocator(playerId)::class.java, observing.getAllocator(playerId)::class.java)
assertEquals(delegate.getBackBufferDurationUs(playerId), observing.getBackBufferDurationUs(playerId))
assertEquals(delegate.retainBackBufferFromKeyframe(playerId), observing.retainBackBufferFromKeyframe(playerId))
} finally {
observing.onReleased(playerId)
}
}
}
/** Minimal complete [LoadControl], so the wrapper's recording can be exercised on its own. */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private class FakeLoadControl : LoadControl {
var startPlayback = false
val lastParameters: LoadControl.Parameters = LoadControl.Parameters(
androidx.media3.exoplayer.analytics.PlayerId.UNSET,
androidx.media3.common.Timeline.EMPTY,
LoadControl.EMPTY_MEDIA_PERIOD_ID,
/* playbackPositionUs= */
0L,
/* bufferedDurationUs= */
0L,
/* playbackSpeed= */
1f,
/* playWhenReady= */
true,
/* rebuffering= */
false,
/* targetLiveOffsetUs= */
androidx.media3.common.C.TIME_UNSET,
/* lastRebufferRealtimeMs= */
androidx.media3.common.C.TIME_UNSET
)
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean = startPlayback
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = true
override fun onPrepared(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: androidx.media3.exoplayer.source.TrackGroupArray,
trackSelections: Array<out androidx.media3.exoplayer.trackselection.ExoTrackSelection>
) = Unit
override fun onStopped(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onReleased(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun getAllocator(
playerId: androidx.media3.exoplayer.analytics.PlayerId
): androidx.media3.exoplayer.upstream.Allocator = androidx.media3.exoplayer.upstream.DefaultAllocator(true, 64 * 1024)
override fun getBackBufferDurationUs(playerId: androidx.media3.exoplayer.analytics.PlayerId): Long = 0L
override fun retainBackBufferFromKeyframe(playerId: androidx.media3.exoplayer.analytics.PlayerId): Boolean = false
}
@@ -0,0 +1,300 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.text.Cue
import androidx.media3.extractor.text.CuesWithTiming
import androidx.media3.extractor.text.SubtitleParser
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Regression coverage for #1953: display sets with several composition objects must
* produce one cue per object, and Normal-state display sets (palette-only fades,
* cached-object reuse) must keep the composition instead of blanking it.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PgsCompositionParserTest {
private companion object {
const val WHITE = 0xFFFFFFFF.toInt()
const val FADED_WHITE = 0x80FFFFFF.toInt()
const val YELLOW = 0xFFFFF000.toInt()
const val GREEN_BT709 = 0xFF00D800.toInt()
const val GREEN_BT601 = 0xFF00FF01.toInt()
val WHITE_ENTRY = intArrayOf(1, 235, 128, 128, 255)
val YELLOW_ENTRY = intArrayOf(2, 210, 146, 16, 255)
const val STATE_NORMAL = 0x00
const val STATE_EPOCH_START = 0x80
}
private class Buf {
private val out = ByteArrayOutputStream()
fun u8(value: Int) = apply { out.write(value) }
fun u16(value: Int) = apply {
out.write(value ushr 8)
out.write(value)
}
fun u24(value: Int) = apply {
out.write(value ushr 16)
out.write(value ushr 8)
out.write(value)
}
fun raw(bytes: ByteArray) = apply { out.write(bytes) }
fun build(): ByteArray = out.toByteArray()
}
private fun segment(type: Int, payload: ByteArray): ByteArray = Buf().u8(type).u16(payload.size).raw(payload).build()
private fun objectRef(id: Int, x: Int, y: Int, cropped: Boolean = false): ByteArray = Buf()
.u16(id)
.u8(0) // Window id.
.u8(if (cropped) 0x80 else 0)
.u16(x)
.u16(y)
.raw(if (cropped) ByteArray(8) else ByteArray(0)) // Crop rectangle.
.build()
private fun pcs(planeWidth: Int, planeHeight: Int, state: Int, vararg refs: ByteArray): ByteArray {
val payload = Buf()
.u16(planeWidth)
.u16(planeHeight)
.u8(0x10) // Frame rate.
.u16(0) // Composition number.
.u8(state)
.u8(0) // Palette update flag.
.u8(0) // Palette id.
.u8(refs.size)
refs.forEach { payload.raw(it) }
return segment(0x16, payload.build())
}
private fun pds(vararg entries: IntArray): ByteArray {
val payload = Buf().u8(0).u8(0) // Palette id, version.
entries.forEach { e -> payload.u8(e[0]).u8(e[1]).u8(e[2]).u8(e[3]).u8(e[4]) }
return segment(0x14, payload.build())
}
private fun ods(id: Int, width: Int, height: Int, rle: ByteArray): ByteArray = segment(
0x15,
Buf().u16(id).u8(0).u8(0xC0).u24(rle.size + 4).u16(width).u16(height).raw(rle).build()
)
/** RLE for a solid bitmap: one literal palette-index byte per pixel. */
private fun solidRle(width: Int, height: Int, colorIndex: Int): ByteArray = ByteArray(width * height) { colorIndex.toByte() }
private fun end(): ByteArray = segment(0x80, ByteArray(0))
private fun displaySet(vararg segments: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
segments.forEach { out.write(it) }
return out.toByteArray()
}
private fun parseCues(parser: PgsCompositionParser, sample: ByteArray): List<Cue> {
val outputs = mutableListOf<CuesWithTiming>()
parser.parse(sample, SubtitleParser.OutputOptions.allCues()) { outputs.add(it) }
assertEquals(1, outputs.size)
return outputs[0].cues
}
/** Epoch-start display set: white 4x2 object at (100, 900) and yellow 6x3 object at (200, 100). */
private fun twoObjectEpochStart(): ByteArray = displaySet(
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900), objectRef(2, 200, 100)),
pds(WHITE_ENTRY, YELLOW_ENTRY),
ods(1, 4, 2, solidRle(4, 2, 1)),
ods(2, 6, 3, solidRle(6, 3, 2)),
end()
)
@Test
fun twoCompositionObjectsProduceIndependentlyPositionedCues() {
val cues = parseCues(PgsCompositionParser(), twoObjectEpochStart())
assertEquals(2, cues.size)
val (dialogue, sign) = cues
assertEquals(100f / 1920f, dialogue.position, 1e-6f)
assertEquals(900f / 1080f, dialogue.line, 1e-6f)
assertEquals(4f / 1920f, dialogue.size, 1e-6f)
assertEquals(2f / 1080f, dialogue.bitmapHeight, 1e-6f)
assertEquals(WHITE, dialogue.bitmap!!.getPixel(0, 0))
assertEquals(200f / 1920f, sign.position, 1e-6f)
assertEquals(100f / 1080f, sign.line, 1e-6f)
assertEquals(6, sign.bitmap!!.width)
assertEquals(3, sign.bitmap!!.height)
assertEquals(YELLOW, sign.bitmap!!.getPixel(5, 2))
}
@Test
fun paletteOnlyFadeUpdateKeepsBothObjectsAndPreservesUnsentEntries() {
val parser = PgsCompositionParser()
parseCues(parser, twoObjectEpochStart())
// Normal-state fade step: same references, only the white entry's alpha changes.
val cues = parseCues(
parser,
displaySet(
pcs(1920, 1080, STATE_NORMAL, objectRef(1, 100, 900), objectRef(2, 200, 100)),
pds(intArrayOf(1, 235, 128, 128, 128)),
end()
)
)
assertEquals(2, cues.size)
assertEquals(FADED_WHITE, cues[0].bitmap!!.getPixel(0, 0))
// The yellow entry was not resent; an in-place palette update must preserve it.
assertEquals(YELLOW, cues[1].bitmap!!.getPixel(0, 0))
}
@Test
fun zeroObjectDisplaySetClearsTheComposition() {
val parser = PgsCompositionParser()
parseCues(parser, twoObjectEpochStart())
val cues = parseCues(parser, displaySet(pcs(1920, 1080, STATE_NORMAL), end()))
assertTrue(cues.isEmpty())
}
@Test
fun epochStartReleasesCachedObjects() {
val parser = PgsCompositionParser()
parseCues(parser, twoObjectEpochStart())
// New epoch referencing an object it never defines: nothing may leak across.
val cues = parseCues(
parser,
displaySet(pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900)), end())
)
assertTrue(cues.isEmpty())
}
@Test
fun resetDropsEpochState() {
val parser = PgsCompositionParser()
parseCues(parser, twoObjectEpochStart())
parser.reset()
val cues = parseCues(
parser,
displaySet(pcs(1920, 1080, STATE_NORMAL, objectRef(1, 100, 900)), end())
)
assertTrue(cues.isEmpty())
}
@Test
fun fragmentedObjectReassembles() {
val rle = solidRle(4, 2, 1)
val first = segment(
0x15,
Buf().u16(1).u8(0).u8(0x80).u24(rle.size + 4).u16(4).u16(2).raw(rle.copyOfRange(0, 3)).build()
)
val continuation = segment(
0x15,
Buf().u16(1).u8(0).u8(0x40).raw(rle.copyOfRange(3, rle.size)).build()
)
val cues = parseCues(
PgsCompositionParser(),
displaySet(pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0)), pds(WHITE_ENTRY), first, continuation, end())
)
assertEquals(1, cues.size)
assertEquals(WHITE, cues[0].bitmap!!.getPixel(3, 1))
}
@Test
fun croppedFirstReferenceDoesNotCorruptTheSecond() {
val cues = parseCues(
PgsCompositionParser(),
displaySet(
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 100, 900, cropped = true), objectRef(2, 200, 100)),
pds(WHITE_ENTRY, YELLOW_ENTRY),
ods(1, 4, 2, solidRle(4, 2, 1)),
ods(2, 6, 3, solidRle(6, 3, 2)),
end()
)
)
assertEquals(2, cues.size)
assertEquals(200f / 1920f, cues[1].position, 1e-6f)
assertEquals(100f / 1080f, cues[1].line, 1e-6f)
}
@Test
fun runLengthEncodedBitmapsDecode() {
// Short-form color run (16 white pixels), then a long-form run (100 pixels).
val shortRun = byteArrayOf(0x00, 0x90.toByte(), 0x01)
val longRun = byteArrayOf(0x00, 0xC0.toByte(), 0x64, 0x01)
val cues = parseCues(
PgsCompositionParser(),
displaySet(
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0), objectRef(2, 0, 200)),
pds(WHITE_ENTRY),
ods(1, 16, 1, shortRun),
ods(2, 100, 1, longRun),
end()
)
)
assertEquals(2, cues.size)
assertEquals(WHITE, cues[0].bitmap!!.getPixel(15, 0))
assertEquals(WHITE, cues[1].bitmap!!.getPixel(99, 0))
}
@Test
fun zlibCompressedSampleParses() {
val deflater = Deflater()
deflater.setInput(twoObjectEpochStart())
deflater.finish()
val compressed = ByteArray(4096)
val length = deflater.deflate(compressed)
deflater.end()
val cues = parseCues(PgsCompositionParser(), compressed.copyOf(length))
assertEquals(2, cues.size)
}
@Test
fun paletteMatrixFollowsPlaneHeight() {
val greenEntry = intArrayOf(1, 145, 34, 54, 255)
val hdCues = parseCues(
PgsCompositionParser(),
displaySet(
pcs(1920, 1080, STATE_EPOCH_START, objectRef(1, 0, 0)),
pds(greenEntry),
ods(1, 1, 1, solidRle(1, 1, 1)),
end()
)
)
val sdCues = parseCues(
PgsCompositionParser(),
displaySet(
pcs(720, 576, STATE_EPOCH_START, objectRef(1, 0, 0)),
pds(greenEntry),
ods(1, 1, 1, solidRle(1, 1, 1)),
end()
)
)
assertEquals(GREEN_BT709, hdCues[0].bitmap!!.getPixel(0, 0))
assertEquals(GREEN_BT601, sdCues[0].bitmap!!.getPixel(0, 0))
}
}
@@ -0,0 +1,294 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioOutput
import androidx.media3.exoplayer.audio.AudioOutputProvider
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `DefaultAudioSink` increments a static, process-wide pending-release counter on every flush and
* decrements it only when the output reports `onReleased`. A dropped report pins that counter above
* zero for the life of the process, which silently disables media3's own AudioTrack-init retry and
* turns any later init failure into a permanent buffering hang (#1790). These tests pin the
* wrapper's side of that contract: exactly one report per release, from every path.
*/
@OptIn(UnstableApi::class)
class RawPositionAudioOutputTest {
private val rawPositionUs = AtomicLong(Long.MIN_VALUE)
private fun config(
encoding: Int = AudioFormat.ENCODING_PCM_16BIT,
isOffload: Boolean = false,
isTunneling: Boolean = false
): AudioOutputProvider.OutputConfig = AudioOutputProvider.OutputConfig.Builder()
.setEncoding(encoding)
.setSampleRate(48_000)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setBufferSize(40_000)
.setIsOffload(isOffload)
.setIsTunneling(isTunneling)
.build()
private fun provider(delegate: FakeOutputProvider) = RawPositionOutputProvider(delegate, rawPositionUs, log = null, sdkInt = 34)
private fun acquire(
provider: RawPositionOutputProvider,
config: AudioOutputProvider.OutputConfig
): Pair<RawPositionAudioOutput, RecordingListener> {
val output = provider.getAudioOutput(config) as RawPositionAudioOutput
val listener = RecordingListener()
output.addListener(listener)
return output to listener
}
/**
* A parked track is never going to release, so its flush has to be answered at once. Deferring
* until the eviction would hold `DefaultAudioSink`'s process-wide pending-release count above
* zero for the whole live track after the first seek, and any nonzero value stops media3
* escalating *both* init and write failures — the #1790 hang, re-armed by an ordinary seek.
*/
@Test
fun aParkedOutputAnswersItsFlushImmediately() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("the flush must be answered while the track is parked", 1, listener.releasedCount)
assertFalse("a parked output must keep its AudioTrack", delegate.outputs.single().released)
}
@Test
fun realReleaseReportsOnlyOnceTheDelegateConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
val real = delegate.outputs.single()
assertTrue("a bitstream output must really release", real.released)
assertEquals("release must not be reported before the AudioTrack is gone", 0, listener.releasedCount)
real.confirmReleased()
assertEquals(1, listener.releasedCount)
}
@Test
fun releaseIsReportedOnceWhenTheDelegateNeverConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
// Player teardown: the sink flushes, then media3 posts the release completion to a playback
// looper that is already quitting, so the confirmation never arrives.
output.release()
provider.release()
assertEquals(1, listener.releasedCount)
}
@Test
fun aLateDelegateConfirmationDoesNotReportTwice() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
output.settleReleaseNow()
delegate.outputs.single().confirmReleased()
assertEquals(1, listener.releasedCount)
}
@Test
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, _) = acquire(provider, config())
first.release()
val (second, _) = acquire(provider, config())
assertSame(first, second)
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/**
* The sink charges the pending-release counter once per flush and builds a fresh listener per
* acquisition, so every reuse cycle has to settle its own flush. One unanswered cycle is enough
* to pin the counter above zero for the rest of the process.
*/
@Test
fun everyReuseCycleAnswersItsOwnFlush() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("cycle $it must report exactly one release", 1, listener.releasedCount)
}
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/** A stale listener per cycle on the real output is how the accounting drifted in the first place. */
@Test
fun reuseDoesNotAccumulateListenersOnTheRealOutput() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, _) = acquire(provider, config())
output.release()
}
assertEquals(1, delegate.outputs.single().listeners.size)
}
@Test
fun forwardedEventsReachTheCurrentListenerOnly() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, firstListener) = acquire(provider, config())
first.release()
val (_, secondListener) = acquire(provider, config())
delegate.outputs.single().emitUnderrun()
assertEquals(0, firstListener.underrunCount)
assertEquals(1, secondListener.underrunCount)
}
/**
* The replacement is deliberately built while the evicted track is still going away. Refusing
* until it confirms looks safer, but the refusal reaches media3 as an init failure with no
* pending release to excuse it, so its 200ms deadline starts immediately — and on the TVs this
* cache exists for a teardown can outlast that, turning an ordinary config change into a
* playback error. Upstream tolerates the same overlap.
*/
@Test
fun anEvictedParkedOutputIsReplacedWithoutWaitingForItsRelease() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, _) = acquire(provider, config())
output.release()
provider.getAudioOutput(config(isTunneling = true))
assertTrue("the parked output must have been released", delegate.outputs.first().released)
assertEquals("the replacement must not wait for the release", 2, delegate.outputs.size)
}
/**
* The evicted track's flush was already answered when it parked, so its late confirmation —
* however late — must not report a second release and drive the process-wide count negative.
*/
@Test
fun aSlowEvictionReleaseDoesNotReportASecondTime() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
val evicted = delegate.outputs.first()
provider.getAudioOutput(config(isTunneling = true))
assertEquals(1, listener.releasedCount)
evicted.confirmReleased()
evicted.confirmReleased()
assertEquals(1, listener.releasedCount)
}
private class RecordingListener : AudioOutput.Listener {
var releasedCount = 0
var underrunCount = 0
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onOffloadDataRequest() = Unit
override fun onOffloadPresentationEnded() = Unit
override fun onUnderrun() {
underrunCount++
}
override fun onReleased() {
releasedCount++
}
}
private class FakeOutputProvider : AudioOutputProvider {
val outputs = mutableListOf<FakeAudioOutput>()
override fun getFormatSupport(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getOutputConfig(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput = FakeAudioOutput().also { outputs.add(it) }
override fun addListener(listener: AudioOutputProvider.Listener) = Unit
override fun removeListener(listener: AudioOutputProvider.Listener) = Unit
override fun release() = Unit
}
private class FakeAudioOutput : AudioOutput {
val listeners = mutableListOf<AudioOutput.Listener>()
var released = false
var stopped = false
var flushed = false
fun confirmReleased() {
for (listener in listeners.toList()) listener.onReleased()
}
fun emitUnderrun() {
for (listener in listeners.toList()) listener.onUnderrun()
}
override fun play() = Unit
override fun pause() = Unit
override fun flush() {
flushed = true
}
override fun stop() {
stopped = true
}
override fun release() {
released = true
}
override fun write(buffer: ByteBuffer, encodedAccessUnitCount: Int, presentationTimeUs: Long) = true
override fun setVolume(volume: Float) = Unit
override fun isOffloadedPlayback() = false
override fun getAudioSessionId() = 1
override fun getSampleRate() = 48_000
override fun getBufferSizeInFrames() = 0L
override fun getPositionUs() = 0L
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun isStalled() = false
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParams: PlaybackParameters) = Unit
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = Unit
override fun setOffloadEndOfStream() = Unit
override fun attachAuxEffect(effectId: Int) = Unit
override fun setAuxEffectSendLevel(level: Float) = Unit
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = Unit
}
}
@@ -7,8 +7,6 @@ import org.junit.Test
class ResumeStallPolicyTest {
// checkWindowMs
@Test
fun windowFloorsAtDefaultForNormalFrameRates() {
// 24fps → 4 × 42ms = 168ms, floored to 1000ms
@@ -39,8 +37,6 @@ class ResumeStallPolicyTest {
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
}
// evaluate
@Test
fun advancingFramesAreHealthy() {
assertEquals(
@@ -9,16 +9,39 @@ class SubtitleViewLayoutTest {
@Test
fun letterboxUsesVisibleVideoRectForTextAndBitmapSubtitles() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT)
val bitmap = bitmapDimensions()
assertEquals(SubtitleViewDimensions(1920, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
}
@Test
fun croppedVideoUsesBitmapCompositionPlaneAspect() {
// #1945: 3840x1604 video with a 1920x1080 PGS plane in a 2400x1080 container.
val planeAspect = SubtitleViewLayout.bitmapPlaneAspect(
bitmapWidth = 480,
bitmapHeight = 100,
cueWidthFraction = 480f / 1920f,
cueHeightFraction = 100f / 1080f
)
assertEquals(
SubtitleViewDimensions(1781, 1002),
SubtitleViewLayout.bitmapDimensions(
containerWidth = 2400,
containerHeight = 1080,
videoWidth = 3840,
videoHeight = 1604,
pixelRatio = 1f,
planeAspect = planeAspect
)
)
}
@Test
fun coverKeepsTextOnScreenAndBitmapSubtitlesFullyVisible() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM)
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM)
val bitmap = bitmapDimensions()
assertEquals(SubtitleViewDimensions(2424, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
@@ -28,7 +51,7 @@ class SubtitleViewLayoutTest {
@Test
fun manualZoomKeepsBitmapSubtitlesFullyVisible() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, zoomScale = 1.5f)
val bitmap = bitmapDimensions()
assertEquals(SubtitleViewDimensions(2424, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
@@ -38,16 +61,56 @@ class SubtitleViewLayoutTest {
@Test
fun stretchDoesNotStretchBitmapSubtitlesToScreenAspect() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
val bitmap = bitmapDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL)
val bitmap = bitmapDimensions()
assertEquals(SubtitleViewDimensions(2424, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
assertAspectCloseTo16By9(bitmap!!)
}
@Test
fun anchorToScreenSizesTextToContainerAndKeepsBitmapOnVideoRect() {
val text = textDimensions(resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, anchorToScreen = true)
val bitmap = bitmapDimensions()
assertEquals(SubtitleViewDimensions(2424, 1080), text)
assertEquals(SubtitleViewDimensions(1920, 1080), bitmap)
assertAspectCloseTo16By9(bitmap!!)
}
@Test
fun anchorToScreenReachesBelowLetterboxedWideVideo() {
// 2.37:1 video on a 16:9 screen — the #1730 use case. Without the anchor
// the text view stops at the bottom letterbox bar; with it the view spans
// the physical screen height.
val unanchored = SubtitleViewLayout.textDimensions(
containerWidth = 1920,
containerHeight = 1080,
videoWidth = 2560,
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
zoomScale = 1f
)
val anchored = SubtitleViewLayout.textDimensions(
containerWidth = 1920,
containerHeight = 1080,
videoWidth = 2560,
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT,
zoomScale = 1f,
anchorToScreen = true
)
assertEquals(SubtitleViewDimensions(1920, 810), unanchored)
assertEquals(SubtitleViewDimensions(1920, 1080), anchored)
}
private fun textDimensions(
resizeMode: Int,
zoomScale: Float = 1f
zoomScale: Float = 1f,
anchorToScreen: Boolean = false
): SubtitleViewDimensions? = SubtitleViewLayout.textDimensions(
containerWidth = 2424,
containerHeight = 1080,
@@ -55,20 +118,17 @@ class SubtitleViewLayoutTest {
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = resizeMode,
zoomScale = zoomScale
zoomScale = zoomScale,
anchorToScreen = anchorToScreen
)
private fun bitmapDimensions(
resizeMode: Int,
zoomScale: Float = 1f
): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
private fun bitmapDimensions(): SubtitleViewDimensions? = SubtitleViewLayout.bitmapDimensions(
containerWidth = 2424,
containerHeight = 1080,
videoWidth = 1920,
videoHeight = 1080,
pixelRatio = 1f,
resizeMode = resizeMode,
zoomScale = zoomScale
planeAspect = null
)
private fun assertAspectCloseTo16By9(dimensions: SubtitleViewDimensions) {
@@ -0,0 +1,180 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the TrueHD -> MAT/IEC 61937 carrier against FFmpeg (#1804).
*
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
* FFmpeg's own input and output:
*
* ```
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f truehd truehd_access_units.bin
* ffmpeg -i surround_5_1_truehd.mka -c:a copy -f spdif truehd_iec61937_golden.bin
* ```
*/
class TrueHdMatPackerTest {
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("truehd_access_units.bin") }
private val golden by lazy { resource("truehd_iec61937_golden.bin") }
/** The whole point: our carrier and FFmpeg's are the same bytes. */
@Test
fun packedCarrierMatchesFfmpegByteForByte() {
val packed = packAll(accessUnits)
assertEquals(
"burst count differs from FFmpeg",
golden.size / TrueHdMatPacker.MAT_PKT_OFFSET,
packed.size / TrueHdMatPacker.MAT_PKT_OFFSET
)
// Compare per burst so a failure names the frame rather than dumping 240KB.
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
val from = index * TrueHdMatPacker.MAT_PKT_OFFSET
val to = from + TrueHdMatPacker.MAT_PKT_OFFSET
assertArrayEquals(
"burst $index differs from FFmpeg",
golden.copyOfRange(from, to),
packed.copyOfRange(from, to)
)
}
}
/** Each burst is a full IEC 61937 frame: preamble, then payload, then the inter-frame gap. */
@Test
fun everyBurstCarriesTheTrueHdPreamble() {
val packed = packAll(accessUnits)
assertTrue("expected at least one burst", packed.isNotEmpty())
for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) {
val at = index * TrueHdMatPacker.MAT_PKT_OFFSET
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
assertEquals("burst $index Pc (IEC61937_TRUEHD)", 0x16, readLittleEndianShort(packed, at + 4))
assertEquals("burst $index Pd", TrueHdMatPacker.MAT_FRAME_SIZE, readLittleEndianShort(packed, at + 6))
}
}
/** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */
@Test
fun aBurstIsTwentyMillisecondsOfCarrier() {
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / IecCarrier.BYTES_PER_FRAME
val durationUs = framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE
assertEquals(20_000L, durationUs)
}
/**
* Media3's Matroska path concatenates 16 syncframes into one sample, so the packer has to split
* them itself; feeding a whole sample as if it were one access unit reads a single timing for
* sixteen frames and desynchronises the carrier.
*/
@Test
fun aRechunkedSampleIsSplitIntoItsAccessUnits() {
var offset = 0
var units = 0
while (offset < accessUnits.size) {
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
units++
offset += length
}
assertTrue("fixture should contain many access units, found $units", units > 16)
assertEquals("access unit walk must consume the stream exactly", accessUnits.size, offset)
}
/** Reset must not leave a half-filled frame behind, or the next seek emits a spliced burst. */
@Test
fun resetDropsPartialCarrierState() {
val packer = TrueHdMatPacker()
feed(packer, accessUnits, stopAfterBursts = 1)
packer.reset()
val afterReset = packAll(accessUnits, packer)
assertArrayEquals(
"a reset packer must reproduce the stream from the start",
packAll(accessUnits),
afterReset
)
}
/** The packer must not allocate a burst per frame; buffers alternate and are reused. */
@Test
fun burstBuffersAreReusedRatherThanAllocated() {
val packer = TrueHdMatPacker()
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
var bursts = 0
var offset = 0
while (offset < accessUnits.size) {
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected several bursts, saw $bursts", bursts > 2)
assertTrue("at most two buffers may ever be handed out, saw ${seen.size}", seen.size <= 2)
}
private fun packAll(units: ByteArray, packer: TrueHdMatPacker = TrueHdMatPacker()): ByteArray {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (offset < units.size) {
val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size)
if (length == 0) break
packer.packAccessUnit(units, offset, length)?.let { burst ->
val copy = ByteArray(burst.remaining())
burst.duplicate().get(copy)
out.write(copy)
}
offset += length
}
return out.toByteArray()
}
private fun feed(packer: TrueHdMatPacker, units: ByteArray, stopAfterBursts: Int) {
var offset = 0
var bursts = 0
while (offset < units.size && bursts < stopAfterBursts) {
val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size)
if (length == 0) break
if (packer.packAccessUnit(units, offset, length) != null) bursts++
offset += length
}
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
/**
* The flag gates every later call, so leaving it latched across a reset would make a packer that
* once saw a 44.1kHz-family stream emit nothing for the rest of its life.
*/
@Test
fun resetClearsTheUnsupportedRateFamilyFlag() {
val packer = TrueHdMatPacker()
val units = resource("truehd_441_access_units.bin")
val length = TrueHdMatPacker.accessUnitLength(units, 0, units.size)
packer.packAccessUnit(units, 0, length)
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the flag", packer.unsupportedStream)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
packAll(accessUnits, TrueHdMatPacker())
)
}
}
@@ -9,13 +9,24 @@ import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ZlibInflatingTrackOutputTest {
@Before
fun enableMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
}
@After
fun resetMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
}
@Test
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
@@ -48,6 +48,19 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
// with no context to inspect the audio route the only safe answer is "" (#1703, #1991).
val result = RecordingResult()
MpvPlayerPlugin().onMethodCall(MethodCall("getAudioSpdifCodecs", null), result)
assertEquals("", result.successValue)
assertNull(result.errorCode)
assertEquals(1, result.completionCount)
}
@Test
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
@@ -0,0 +1,58 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceQuirksTest {
private fun unreliable(
manufacturer: String = "Amazon",
model: String = "AFTMM",
fps: Float = 23.976f
) = DeviceQuirks.hasUnreliableTunneledPlayback(manufacturer, model, fps)
@Test
fun fireTvStick4kJuddersOnFilmRateSoTunnelingIsWithdrawn() {
assertTrue(unreliable(fps = 23.976f))
assertTrue(unreliable(fps = 24f))
assertTrue(unreliable(fps = 25f))
assertTrue(unreliable(fps = 29.97f))
assertTrue(unreliable(fps = DeviceQuirks.MAX_UNRELIABLE_TUNNELED_FPS))
}
@Test
fun highFrameRateKeepsTunnelingOnTheSameDevice() {
// Tunneling exists for this workload and Amazon recommends it here, so the
// quirk must not reach 4K50/60 content.
assertFalse(unreliable(fps = 50f))
assertFalse(unreliable(fps = 59.94f))
assertFalse(unreliable(fps = 60f))
}
@Test
fun unknownFrameRateLeavesBehaviourUnchanged() {
assertFalse(unreliable(fps = -1f))
assertFalse(unreliable(fps = 0f))
}
@Test
fun otherAmazonHardwareIsUnaffected() {
// Only AFTMM was reported and A/B-tested; siblings keep stock behaviour.
assertFalse(unreliable(model = "AFTKA"))
assertFalse(unreliable(model = "AFTMM2"))
assertFalse(unreliable(model = "AFTSSS"))
assertFalse(unreliable(model = "KFMAWI"))
}
@Test
fun otherManufacturersAreUnaffected() {
assertFalse(unreliable(manufacturer = "NVIDIA", model = "SHIELD Android TV"))
assertFalse(unreliable(manufacturer = "Sony", model = "BRAVIA 4K GB"))
}
@Test
fun buildStringCasingDoesNotDefeatTheMatch() {
assertTrue(unreliable(manufacturer = "amazon", model = "aftmm"))
}
}
@@ -17,6 +17,13 @@ import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.testing.WorkManagerTestInitHelper
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -291,7 +298,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "active-owner",
"generation" to 1L,
"items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
@@ -308,7 +315,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "queued-owner",
"generation" to 2L,
"items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
@@ -720,6 +727,26 @@ class WatchNextProviderTest {
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
}
@Test
fun packageUpdateCancelsLegacyShelfRefreshWork() {
WorkManagerTestInitHelper.initializeTestWorkManager(context)
val workManager = WorkManager.getInstance(context)
workManager.enqueueUniquePeriodicWork(
SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<LegacyShelfRefreshStandIn>(6, TimeUnit.HOURS).build()
).result.get()
SystemShelfUpdateReceiver(Executor { command -> command.run() })
.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
val states = workManager
.getWorkInfosForUniqueWork(SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK)
.get()
.map { it.state }
assertEquals(listOf(WorkInfo.State.CANCELLED), states)
}
@Test
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
ScriptedHttpServer(
@@ -1281,7 +1308,7 @@ class WatchNextProviderTest {
private fun syncCall(ownerId: String, generation: Long) = MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to ownerId,
"generation" to generation,
"items" to emptyList<Map<String, Any?>>()
@@ -1605,3 +1632,15 @@ private class ManualExecutorService : AbstractExecutorService() {
tasks.removeFirst().run()
}
}
/**
* Stands in for the removed ShelfRefreshWorker so the legacy periodic job can
* be enqueued. Public because WorkManager's default factory instantiates
* workers reflectively and cannot access a package-private class.
*/
class LegacyShelfRefreshStandIn(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result = Result.success()
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Regenerates the DTS-HD MA packer fixtures (#1988).
FFmpeg has no DTS-HD MA encoder, so the access-unit fixture is synthesized: real
DTS core frames from FFmpeg's `dca` encoder, each followed by a hand-built
extension substream (EXSS) whose header fields are coherent enough for FFmpeg's
DCA parser to glue core+EXSS into one access unit. The IEC 61937 wrapping never
inspects EXSS payload bytes, only the sync word and size fields, so this pins
the same wire format genuine Master Audio does.
One mid-stream access unit is oversized so `ffmpeg -f spdif` overflows the
burst and strips to core-only for `dtshd_fallback_time` (60s, i.e. the rest of
the fixture) — the golden therefore also pins the strip-and-hold behavior.
Usage (from this directory):
ffmpeg -y -f lavfi -i "aevalsrc=0.3*sin(2*PI*440*t)|0.3*sin(2*PI*554*t)|0.3*sin(2*PI*659*t)|0.2*sin(2*PI*220*t)|0.25*sin(2*PI*330*t)|0.25*sin(2*PI*392*t):s=48000:d=0.15" \
-c:a dca -strict experimental -f dts /tmp/dts_core.dts
python3 dtshd_fixture_generator.py /tmp/dts_core.dts
ffmpeg -y -f dts -i dtshd_ma_access_units.bin -c:a copy \
-f spdif -dtshd_rate 768000 dtshd_ma_iec61937_golden.bin
"""
import struct
import sys
CORE_SYNC = 0x7FFE8001
EXSS_SYNC = 0x64582025
# Payload bytes past which a 32768-byte burst (512 samples at 48kHz on the
# 768kHz HBR carrier) overflows: 32768 - 8 preamble - 12 start code/length.
BURST_CAPACITY = 32768 - 8 - 12
NORMAL_EXSS_SIZE = 2020
# Large enough that core (1884) + EXSS exceeds the burst capacity.
OVERSIZED_EXSS_SIZE = BURST_CAPACITY
OVERSIZED_AU_INDEX = 4
def build_exss(size: int) -> bytes:
"""A minimal EXSS frame: sync, then the narrow (bHeaderSizeType=0) header.
Bit layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
bHeaderSizeType(1), nuBits4Header(8) = header bytes - 1,
nuBits4ExSSFsize(16) = frame bytes - 1. Everything else is padding the
parser and packer never read.
"""
header_size = 16
bits = 0
bits = (bits << 8) | 0 # UserDefinedBits
bits = (bits << 2) | 0 # nExtSSIndex
bits = (bits << 1) | 0 # bHeaderSizeType
bits = (bits << 8) | (header_size - 1)
bits = (bits << 16) | (size - 1)
packed = bits.to_bytes(5, "big") # 35 bits, left-aligned below
body = bytearray(size)
struct.pack_into(">I", body, 0, EXSS_SYNC)
shifted = int.from_bytes(packed, "big") << (40 - 35)
body[4:9] = shifted.to_bytes(5, "big")
return bytes(body)
def main() -> None:
core = open(sys.argv[1], "rb").read()
out = bytearray()
offset = 0
index = 0
while offset + 9 <= len(core):
sync = struct.unpack_from(">I", core, offset)[0]
assert sync == CORE_SYNC, hex(sync)
b24 = (core[offset + 5] << 16) | (core[offset + 6] << 8) | core[offset + 7]
fsize = ((b24 >> 4) & 0x3FFF) + 1
out += core[offset : offset + fsize]
exss_size = OVERSIZED_EXSS_SIZE if index == OVERSIZED_AU_INDEX else NORMAL_EXSS_SIZE
out += build_exss(exss_size)
offset += fsize
index += 1
open("dtshd_ma_access_units.bin", "wb").write(out)
print(f"{index} access units, {len(out)} bytes")
if __name__ == "__main__":
main()
-3
View File
@@ -11,7 +11,6 @@ default_platform(:android)
platform :android do
desc "Build and deploy to Google Play Store"
lane :release do
# Get version from pubspec.yaml
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
@@ -26,7 +25,6 @@ platform :android do
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
@@ -37,7 +35,6 @@ platform :android do
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
+2 -2
View File
@@ -54,8 +54,8 @@ kotlin {
dependencies {
implementation("androidx.annotation:annotation:1.10.0")
implementation("androidx.annotation:annotation-experimental:1.6.0")
implementation("androidx.media3:media3-exoplayer:1.10.1")
implementation("androidx.media3:media3-ui:1.10.1")
implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
testImplementation("junit:junit:4.13.2")
}
+46 -283
View File
@@ -18,6 +18,7 @@ static inline long long nowMs(void) {
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
#include "AssPack.h"
#include "ass/ass.h"
#define LOG_TAG "SubtitleRenderer"
@@ -243,35 +244,8 @@ Java_com_edde746_plezy_libass_AssRender_nativeAssRenderDeinit(JNIEnv* env, jclas
}
}
// Hard cap on atlas pages (see the packing comment below). 4 pages of a GL-max
// texture is far above the worst real frame measured (a 4K-rendered full-screen
// typeset letter needs 3); beyond it tiles are dropped and counted in truncated.
#define MAX_ATLAS_PAGES 4
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
// can exceed one atlas page only when the render frame is larger than a page
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
// one image across pages (a quad samples one texture), so tiling does, keeping
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
// multi-page pack below, not oversized single images.) Tiles are built in list
// order (= libass blend/painter order, preserved for emission); the single-page
// pack runs height-sorted via a separate key array so emission order is untouched.
typedef struct {
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
int ox, oy; // tile offset within the source bitmap
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
int page; // atlas page the tile is packed into; -1 if dropped for capacity
int sx, sy; // packed slot within the page; valid when page >= 0
} PackTile;
typedef struct {
int th; // tile height (the sort key)
int idx; // index into the build-order tiles[] array
} TileSortKey;
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
}
// Packing/composite policy lives in AssPack.c (pure C, desktop-testable); this
// file owns the JNI boundary, buffer plumbing and logging.
static int imageListHasOutput(ASS_Image* image) {
for (ASS_Image* img = image; img != NULL; img = img->next) {
@@ -293,11 +267,12 @@ static int truncationLogCounter = 0;
// [5]=hasOutput [6]=pageCount
// [7 .. 7+MAX-1] = pageHeights[pageCount]
// [7+MAX .. 7+2*MAX-1] = pageQuadCounts[pageCount]
#define ASS_HEADER_INTS (7 + 2 * MAX_ATLAS_PAGES)
// [7+2*MAX] = mode (ASS_PACK_MODE_ATLAS | ASS_PACK_MODE_COMPOSITE)
#define ASS_HEADER_INTS (7 + 2 * ASS_PACK_MAX_PAGES + 1)
static jint writeAtlasHeader(
JNIEnv* env, jintArray headerBuf, int atlasWidth, int quadCount, int changed, int truncated, int requiredPages,
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads) {
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads, int mode) {
int hdr[ASS_HEADER_INTS];
memset(hdr, 0, sizeof(hdr));
hdr[0] = atlasWidth;
@@ -307,36 +282,32 @@ static jint writeAtlasHeader(
hdr[4] = requiredPages;
hdr[5] = hasOutput;
hdr[6] = pageCount;
for (int i = 0; i < pageCount && i < MAX_ATLAS_PAGES; i++) {
for (int i = 0; i < pageCount && i < ASS_PACK_MAX_PAGES; i++) {
hdr[7 + i] = pageHeights ? pageHeights[i] : 0;
hdr[7 + MAX_ATLAS_PAGES + i] = pageQuads ? pageQuads[i] : 0;
hdr[7 + ASS_PACK_MAX_PAGES + i] = pageQuads ? pageQuads[i] : 0;
}
hdr[7 + 2 * ASS_PACK_MAX_PAGES] = mode;
(*env)->SetIntArrayRegion(env, headerBuf, 0, ASS_HEADER_INTS, hdr);
return 1;
}
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
//
// - atlasBuf holds one or more vertically-stacked ALPHA_8 *pages*, each atlasMaxW ×
// atlasMaxH (row stride atlasMaxW); page p starts at byte offset p*atlasMaxW*atlasMaxH.
// The buffer's capacity bounds how many pages this render may fill; AssAtlasFrame
// reports pageHeights (rows worth uploading per page) and requiredPages.
// - In the common ATLAS mode, atlasBuf holds one or more vertically-stacked ALPHA_8
// *pages*, each atlasMaxW × atlasMaxH (row stride atlasMaxW); page p starts at byte
// offset p*atlasMaxW*atlasMaxH. The buffer's capacity bounds how many pages this
// render may fill; AssAtlasFrame reports pageHeights (rows worth uploading per page)
// and requiredPages. UVs are page-local, normalized against atlasMaxW × atlasMaxH.
// - In COMPOSITE mode (frames whose tiles can never fit ASS_PACK_MAX_PAGES pages or
// the vertex budget) atlasBuf instead starts with one premultiplied RGBA rect of
// atlasWidth × pageHeights[0] pixels, drawn as the single emitted quad (UVs 0..1).
// - vertexBuf holds a per-quad vertex stream (6 vertices × (2 pos + 2 uv + 4 color)
// floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in
// AssSubtitleAtlasPipeline.kt. UVs are page-local, normalized against atlasMaxW ×
// atlasMaxH (the per-page texture dims).
// - The common case packs everything into a single height-sorted page (minimizes
// packed height, byte-identical to the prior single-page packer). When that
// overflows (a 4K full-screen sign can exceed one GL-max texture), the packer
// spills into additional pages in list order: page assignment is monotonic in
// libass's painter order, so each page's quads are one contiguous run in the
// vertex stream and drawing the pages in turn reproduces the blend order.
// - Vertices are always emitted in original list order (= libass's painter order).
// AssSubtitleAtlasPipeline.kt. Vertices are emitted in libass's painter order.
//
// Never fails on content size: when the frame needs more pages than the buffer holds
// (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; any
// genuinely undrawable tiles (past MAX_ATLAS_PAGES / the vertex budget) are dropped
// and counted in truncated.
// Never drops content for size: when the frame needs more capacity than the buffer
// holds (requiredPages > pageHeights.size) the caller grows the buffer and re-renders;
// frames too dense for the paged atlas flatten into the RGBA composite (see AssPack.c).
//
// Returns 0 for missing buffers/handles (the caller maps that to a null frame); 1 when
// the header was written. On changed == 0 the header carries (atlasWidth=0, quadCount=0,
@@ -359,7 +330,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time,
tAss - t0, changed, hasOutput);
}
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL);
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
}
if (image == NULL) {
@@ -368,7 +339,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
tAss - t0, changed);
}
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
}
uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf);
@@ -382,253 +353,45 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
(long long)atlasCap);
return 0;
}
// 48 floats per quad × 4 bytes = 192 bytes/quad
const int maxQuads = (int)(vertexCap / 192);
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
int providedPages = (int)(atlasCap / (jlong)pageBytes);
if (providedPages < 1) return 0; // one page is guaranteed above; keep the page math safe
if (providedPages > MAX_ATLAS_PAGES) providedPages = MAX_ATLAS_PAGES;
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
// sorted by height for the single-page pack so it produces tight rows.
int total = 0;
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w > 0 && img->h > 0) {
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
total += cols * rows;
}
}
if (total == 0) {
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
}
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
if (!tiles || !keys) {
free(tiles);
free(keys);
AssPackResult pack;
if (!ass_pack_frame(image, atlasPixels, (size_t)atlasCap, atlasMaxW, atlasMaxH, vertices, (size_t)vertexCap, &pack)) {
return 0;
}
int n = 0;
long long srcPixels = 0;
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w <= 0 || img->h <= 0) continue;
srcPixels += (long long)img->w * img->h;
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
int th = img->h - oy;
if (th > atlasMaxH) th = atlasMaxH;
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
int tw = img->w - ox;
if (tw > atlasMaxW) tw = atlasMaxW;
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
keys[n] = (TileSortKey){.th = th, .idx = n};
n++;
}
}
}
int pageHeights[MAX_ATLAS_PAGES] = {0};
int pageQuads[MAX_ATLAS_PAGES] = {0};
int pageCount = 1;
int requiredPages = 1;
int truncated = 0;
// Pass 1a: height-sorted single page — the common case, minimal packed height
// (byte-identical to the prior single-page packer when the frame fits one page).
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[keys[i].idx];
if (accepted >= maxQuads) break;
int cx = cursorX, cy = cursorY, rh = rowH;
if (cx + t->tw > atlasMaxW) {
cy += rh;
cx = 0;
rh = 0;
}
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
t->page = 0;
t->sx = cx;
t->sy = cy;
cursorX = cx + t->tw;
cursorY = cy;
rowH = (t->th > rh) ? t->th : rh;
if (cy + t->th > packedH) packedH = cy + t->th;
accepted++;
}
if (accepted == n) {
pageHeights[0] = packedH;
pageQuads[0] = accepted;
} else {
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
// page whenever a tile won't fit the current one. List order keeps the page
// index monotonic in painter order, so each page's quads stay one contiguous run.
for (int i = 0; i < n; i++) {
tiles[i].page = -1;
tiles[i].sx = -1;
tiles[i].sy = -1;
}
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[i];
if (cx + t->tw > atlasMaxW) {
cy += rh;
cx = 0;
rh = 0;
}
if (cy + t->th > atlasMaxH) {
page++;
cx = 0;
cy = 0;
rh = 0;
}
if (page + 1 > requiredPages) requiredPages = page + 1;
if (page < providedPages && placed < maxQuads) {
t->page = page;
t->sx = cx;
t->sy = cy;
if (cy + t->th > pageHeights[page]) pageHeights[page] = cy + t->th;
pageQuads[page]++;
placed++;
}
cx += t->tw;
rh = (t->th > rh) ? t->th : rh;
}
pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
accepted = placed;
truncated = n - placed;
}
// Warn only for genuinely-unrecoverable loss. A frame that needs more pages than
// the buffer currently holds, yet fits within MAX_ATLAS_PAGES and the vertex
// budget, is recoverable: the caller grows the buffer and re-renders, so the
// first (discarded) render's truncated > 0 is a false alarm, not data loss. Tiles
// are only truly lost past the page cap or the vertex budget.
const int recoverableGrow = requiredPages <= MAX_ATLAS_PAGES && n <= maxQuads;
if (truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
// Warn only for genuinely-unrecoverable loss. A frame that needs more capacity than
// the buffer currently holds is recoverable: the caller grows the buffer and
// re-renders, so the first (discarded) render's truncated > 0 is a false alarm, not
// data loss. With the composite fallback, unrecoverable truncation should be
// unreachable for real content.
const int maxQuads = (int)(vertexCap / 192);
const int recoverableGrow =
pack.requiredPages <= ASS_PACK_MAX_PAGES && (pack.mode == ASS_PACK_MODE_COMPOSITE || pack.totalTiles <= maxQuads);
if (pack.truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
__android_log_print(
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %d)",
truncated, n, atlasMaxW, atlasMaxH, requiredPages, providedPages);
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %lld)",
pack.truncated, pack.totalTiles, atlasMaxW, atlasMaxH, pack.requiredPages,
(long long)(atlasCap / ((jlong)atlasMaxW * atlasMaxH)));
}
if (accepted == 0) {
free(tiles);
free(keys);
return writeAtlasHeader(env, headerBuf, 0, 0, changed, truncated, requiredPages, 1, 1, NULL, NULL);
}
// Clear only the packed rows of each written page.
for (int p = 0; p < pageCount; p++) {
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * pageHeights[p]);
}
// Emit tiles in list order (= libass's painter/blend order), copying each placed
// tile into its page slot and emitting its quad. Monotonic page assignment makes
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
int qi = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[i];
if (t->page < 0) continue;
ASS_Image* img = t->img;
const int px = t->sx;
const int py = t->sy;
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
for (int y = 0; y < t->th; y++) {
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
memcpy(dst, src, (size_t)t->tw);
}
const float x0 = (float)(img->dst_x + t->ox);
const float y0 = (float)(img->dst_y + t->oy);
const float x1 = x0 + (float)t->tw;
const float y1 = y0 + (float)t->th;
const float u0 = (float)px / (float)atlasMaxW;
const float v0 = (float)py / (float)atlasMaxH;
const float u1 = (float)(px + t->tw) / (float)atlasMaxW;
const float v1 = (float)(py + t->th) / (float)atlasMaxH;
const unsigned int c = img->color;
const float r = (float)((c >> 24) & 0xFFu) / 255.0f;
const float g = (float)((c >> 16) & 0xFFu) / 255.0f;
const float b = (float)((c >> 8) & 0xFFu) / 255.0f;
const float a = (float)(0xFFu - (c & 0xFFu)) / 255.0f;
float* vx = vertices + (size_t)qi * 48;
// 8 floats per vertex: x, y, u, v, r, g, b, a.
// Triangle 1: (x0,y0) (x1,y0) (x0,y1)
vx[0] = x0;
vx[1] = y0;
vx[2] = u0;
vx[3] = v0;
vx[4] = r;
vx[5] = g;
vx[6] = b;
vx[7] = a;
vx[8] = x1;
vx[9] = y0;
vx[10] = u1;
vx[11] = v0;
vx[12] = r;
vx[13] = g;
vx[14] = b;
vx[15] = a;
vx[16] = x0;
vx[17] = y1;
vx[18] = u0;
vx[19] = v1;
vx[20] = r;
vx[21] = g;
vx[22] = b;
vx[23] = a;
// Triangle 2: (x1,y0) (x1,y1) (x0,y1)
vx[24] = x1;
vx[25] = y0;
vx[26] = u1;
vx[27] = v0;
vx[28] = r;
vx[29] = g;
vx[30] = b;
vx[31] = a;
vx[32] = x1;
vx[33] = y1;
vx[34] = u1;
vx[35] = v1;
vx[36] = r;
vx[37] = g;
vx[38] = b;
vx[39] = a;
vx[40] = x0;
vx[41] = y1;
vx[42] = u0;
vx[43] = v1;
vx[44] = r;
vx[45] = g;
vx[46] = b;
vx[47] = a;
qi++;
}
free(tiles);
free(keys);
// Slow-render breakdown: separates libass's own cost (rasterize/blur/shape)
// from this function's packing + memcpy, so device logs attribute the time.
// from this function's packing/compositing, so device logs attribute the time.
const long long tEnd = nowMs();
if (tEnd - t0 > 40) {
__android_log_print(
ANDROID_LOG_WARN, LOG_TAG,
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk "
"atlas=%dx%d pages=%d quads=%d",
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, atlasMaxH, pageCount, qi);
"atlas=%dx%d pages=%d quads=%d mode=%d",
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, pack.totalTiles, pack.srcPixels / 1000, atlasMaxW,
atlasMaxH, pack.pageCount, pack.quadCount, pack.mode);
}
// atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width);
// pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
// ATLAS: atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width)
// and pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
// COMPOSITE: atlasWidth × pageHeights[0] are the RGBA rect dims for the one quad.
return writeAtlasHeader(
env, headerBuf, atlasMaxW, qi, changed, truncated, requiredPages, 1, pageCount, pageHeights, pageQuads);
env, headerBuf, pack.atlasWidth, pack.quadCount, changed, pack.truncated, pack.requiredPages,
pack.totalTiles > 0 ? 1 : 0, pack.pageCount, pack.pageHeights, pack.pageQuads, pack.mode);
}
// --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) ---
+300
View File
@@ -0,0 +1,300 @@
#include "AssPack.h"
#include <limits.h>
#include <stdlib.h>
#include <string.h>
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
// can exceed one atlas page only when the render frame is larger than a page
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
// one image across pages (a quad samples one texture), so tiling does, keeping
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
// multi-page pack below, not oversized single images.) Tiles are built in list
// order (= libass blend/painter order, preserved for emission); the single-page
// pack runs height-sorted via a separate key array so emission order is untouched.
typedef struct {
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
int ox, oy; // tile offset within the source bitmap
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
int page; // atlas page the tile is packed into; -1 if dropped for capacity
int sx, sy; // packed slot within the page; valid when page >= 0
} PackTile;
typedef struct {
int th; // tile height (the sort key)
int idx; // index into the build-order tiles[] array
} TileSortKey;
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
}
// 8 floats per vertex (x, y, u, v, r, g, b, a) x 6 vertices; layout must match
// BYTES_PER_QUAD/VERTEX in AssSubtitleAtlasPipeline.kt.
static void emitQuad(
float* vx, float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1, float r, float g,
float b, float a) {
const float pos[6][2] = {{x0, y0}, {x1, y0}, {x0, y1}, {x1, y0}, {x1, y1}, {x0, y1}};
const float uv[6][2] = {{u0, v0}, {u1, v0}, {u0, v1}, {u1, v0}, {u1, v1}, {u0, v1}};
for (int i = 0; i < 6; i++) {
*vx++ = pos[i][0];
*vx++ = pos[i][1];
*vx++ = uv[i][0];
*vx++ = uv[i][1];
*vx++ = r;
*vx++ = g;
*vx++ = b;
*vx++ = a;
}
}
// Flattens the whole image list into one premultiplied RGBA rect (the union
// bounding box) at the start of `atlasPixels`, emitted as a single quad. The
// blend is libass painter-order src-over, the same math the GL path applies to
// alpha-atlas quads, so output is visually identical — memory just becomes
// O(union area <= frame area) instead of O(sum of image areas). Used for
// frames whose summed image area cannot fit ASS_PACK_MAX_PAGES alpha pages
// (#1868: ~150 overlapping paint-strokes put ~5 pages of tiles at 1080p and
// ~19 at 4K behind a 4-page cap, silently dropping the painter-order tail —
// the sign's text).
static void compositeFrame(
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, size_t pageBytes, float* vertices, size_t vertexCap,
AssPackResult* out) {
int ux0 = INT_MAX, uy0 = INT_MAX, ux1 = INT_MIN, uy1 = INT_MIN;
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w <= 0 || img->h <= 0) continue;
if (img->dst_x < ux0) ux0 = img->dst_x;
if (img->dst_y < uy0) uy0 = img->dst_y;
if (img->dst_x + img->w > ux1) ux1 = img->dst_x + img->w;
if (img->dst_y + img->h > uy1) uy1 = img->dst_y + img->h;
}
const int uw = ux1 - ux0;
const int uh = uy1 - uy0;
const size_t rgbaBytes = (size_t)uw * uh * 4;
out->mode = ASS_PACK_MODE_COMPOSITE;
out->requiredPages = (int)((rgbaBytes + pageBytes - 1) / pageBytes);
out->pageCount = 1;
if (rgbaBytes > atlasCap || vertexCap < 192) {
// Buffers too small for the flattened rect: report the needed capacity and
// write nothing — the caller grows and re-renders (same contract as the
// multi-page atlas grow). truncated flags the frame as not presentable.
out->truncated = out->totalTiles;
return;
}
memset(atlasPixels, 0, rgbaBytes);
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w <= 0 || img->h <= 0) continue;
const unsigned int c = img->color;
const unsigned cr = (c >> 24) & 0xFFu;
const unsigned cg = (c >> 16) & 0xFFu;
const unsigned cb = (c >> 8) & 0xFFu;
const unsigned ca = 0xFFu - (c & 0xFFu);
if (ca == 0) continue;
for (int y = 0; y < img->h; y++) {
const uint8_t* src = img->bitmap + (size_t)y * img->stride;
uint8_t* dst = atlasPixels + (((size_t)(img->dst_y - uy0 + y) * uw) + (size_t)(img->dst_x - ux0)) * 4;
for (int x = 0; x < img->w; x++, dst += 4) {
const unsigned a = (src[x] * ca + 127u) / 255u;
if (a == 0) continue;
const unsigned inv = 255u - a;
dst[0] = (uint8_t)((cr * a + dst[0] * inv + 127u) / 255u);
dst[1] = (uint8_t)((cg * a + dst[1] * inv + 127u) / 255u);
dst[2] = (uint8_t)((cb * a + dst[2] * inv + 127u) / 255u);
dst[3] = (uint8_t)((255u * a + dst[3] * inv + 127u) / 255u);
}
}
}
out->atlasWidth = uw;
out->pageHeights[0] = uh;
out->pageQuads[0] = 1;
out->quadCount = 1;
out->truncated = 0;
emitQuad(
vertices, (float)ux0, (float)uy0, (float)(ux0 + uw), (float)(uy0 + uh), 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
1.0f);
}
int ass_pack_frame(
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
size_t vertexCap, AssPackResult* out) {
memset(out, 0, sizeof(*out));
out->mode = ASS_PACK_MODE_ATLAS;
out->requiredPages = 1;
out->pageCount = 1;
// 48 floats per quad x 4 bytes = 192 bytes/quad
const int maxQuads = (int)(vertexCap / 192);
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
int providedPages = (int)(atlasCap / pageBytes);
if (providedPages > ASS_PACK_MAX_PAGES) providedPages = ASS_PACK_MAX_PAGES;
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
// sorted by height for the single-page pack so it produces tight rows.
int total = 0;
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w > 0 && img->h > 0) {
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
total += cols * rows;
}
}
out->totalTiles = total;
if (total == 0) return 1;
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
if (!tiles || !keys) {
free(tiles);
free(keys);
return 0;
}
int n = 0;
for (ASS_Image* img = image; img != NULL; img = img->next) {
if (img->w <= 0 || img->h <= 0) continue;
out->srcPixels += (long long)img->w * img->h;
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
int th = img->h - oy;
if (th > atlasMaxH) th = atlasMaxH;
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
int tw = img->w - ox;
if (tw > atlasMaxW) tw = atlasMaxW;
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
keys[n] = (TileSortKey){.th = th, .idx = n};
n++;
}
}
}
int truncated = 0;
// Pass 1a: height-sorted single page — the common case, minimal packed height
// (byte-identical to the prior single-page packer when the frame fits one page).
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[keys[i].idx];
if (accepted >= maxQuads) break;
int cx = cursorX, cy = cursorY, rh = rowH;
if (cx + t->tw > atlasMaxW) {
cy += rh;
cx = 0;
rh = 0;
}
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
t->page = 0;
t->sx = cx;
t->sy = cy;
cursorX = cx + t->tw;
cursorY = cy;
rowH = (t->th > rh) ? t->th : rh;
if (cy + t->th > packedH) packedH = cy + t->th;
accepted++;
}
if (accepted == n) {
out->pageHeights[0] = packedH;
out->pageQuads[0] = accepted;
} else {
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
// page whenever a tile won't fit the current one. List order keeps the page
// index monotonic in painter order, so each page's quads stay one contiguous run.
for (int i = 0; i < n; i++) {
tiles[i].page = -1;
tiles[i].sx = -1;
tiles[i].sy = -1;
}
int requiredPages = 1;
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[i];
if (cx + t->tw > atlasMaxW) {
cy += rh;
cx = 0;
rh = 0;
}
if (cy + t->th > atlasMaxH) {
page++;
cx = 0;
cy = 0;
rh = 0;
}
if (page + 1 > requiredPages) requiredPages = page + 1;
if (page < providedPages && placed < maxQuads) {
t->page = page;
t->sx = cx;
t->sy = cy;
if (cy + t->th > out->pageHeights[page]) out->pageHeights[page] = cy + t->th;
out->pageQuads[page]++;
placed++;
}
cx += t->tw;
rh = (t->th > rh) ? t->th : rh;
}
if (requiredPages > ASS_PACK_MAX_PAGES || n > maxQuads) {
// The frame can never fit the paged alpha atlas: its tiles exceed the page
// cap or the vertex budget outright. Flatten instead of dropping the
// painter-order tail (#1868).
free(tiles);
free(keys);
memset(out->pageHeights, 0, sizeof(out->pageHeights));
memset(out->pageQuads, 0, sizeof(out->pageQuads));
compositeFrame(image, atlasPixels, atlasCap, pageBytes, vertices, vertexCap, out);
return 1;
}
out->requiredPages = requiredPages;
out->pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
accepted = placed;
truncated = n - placed;
}
out->truncated = truncated;
if (accepted == 0) {
free(tiles);
free(keys);
memset(out->pageHeights, 0, sizeof(out->pageHeights));
memset(out->pageQuads, 0, sizeof(out->pageQuads));
return 1;
}
// Clear only the packed rows of each written page.
for (int p = 0; p < out->pageCount; p++) {
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * out->pageHeights[p]);
}
// Emit tiles in list order (= libass's painter/blend order), copying each placed
// tile into its page slot and emitting its quad. Monotonic page assignment makes
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
int qi = 0;
for (int i = 0; i < n; i++) {
PackTile* t = &tiles[i];
if (t->page < 0) continue;
ASS_Image* img = t->img;
const int px = t->sx;
const int py = t->sy;
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
for (int y = 0; y < t->th; y++) {
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
memcpy(dst, src, (size_t)t->tw);
}
const unsigned int c = img->color;
emitQuad(
vertices + (size_t)qi * 48, (float)(img->dst_x + t->ox), (float)(img->dst_y + t->oy),
(float)(img->dst_x + t->ox + t->tw), (float)(img->dst_y + t->oy + t->th), (float)px / (float)atlasMaxW,
(float)py / (float)atlasMaxH, (float)(px + t->tw) / (float)atlasMaxW, (float)(py + t->th) / (float)atlasMaxH,
(float)((c >> 24) & 0xFFu) / 255.0f, (float)((c >> 16) & 0xFFu) / 255.0f, (float)((c >> 8) & 0xFFu) / 255.0f,
(float)(0xFFu - (c & 0xFFu)) / 255.0f);
qi++;
}
free(tiles);
free(keys);
out->quadCount = qi;
out->atlasWidth = atlasMaxW;
return 1;
}
+57
View File
@@ -0,0 +1,57 @@
// Pure-C frame packer behind the JNI atlas render entry point (AssKt.c).
// Kept free of JNI/Android includes so desktop test harnesses can compile the
// exact shipped packing/composite logic against a host libass build.
#ifndef PLEZY_ASS_PACK_H
#define PLEZY_ASS_PACK_H
#include <stddef.h>
#include <stdint.h>
#include "ass/ass.h"
// Hard cap on atlas pages. 4 pages of a GL-max texture covers the worst real
// frame measured for ordinary typesetting (a 4K-rendered full-screen letter
// needs 3); denser frames (#1868: ~1500 overlapping paint-stroke images whose
// summed area is ~19 pages at 4K) fall back to the RGBA composite below instead
// of dropping tiles. Must match AssAtlasPipelineConfig.MAX_ATLAS_PAGES.
#define ASS_PACK_MAX_PAGES 4
// Result modes. ATLAS: one or more ALPHA_8 pages plus per-quad vertices, the
// common fast path. COMPOSITE: the frame's images were flattened CPU-side into
// a single premultiplied RGBA rect (the union bounding box) drawn as one quad —
// memory is bounded by frame area instead of the sum of per-image areas.
#define ASS_PACK_MODE_ATLAS 0
#define ASS_PACK_MODE_COMPOSITE 1
typedef struct {
int mode; // ASS_PACK_MODE_*
// ATLAS: row stride of every page. COMPOSITE: width of the RGBA rect.
int atlasWidth;
int quadCount;
// Tiles dropped for capacity (frame incomplete). Composite output never
// drops content; it reports truncated == totalTiles only in the
// nothing-written grow request state (quadCount == 0).
int truncated;
// Pages of caller buffer capacity this frame needs to render completely.
// When it exceeds the provided capacity the caller grows and re-renders.
int requiredPages;
int pageCount;
int totalTiles; // tiles the frame splits into (caller-side logging)
long long srcPixels; // summed source image area (caller-side logging)
// ATLAS: packed rows per page / quads per page (contiguous vertex runs).
// COMPOSITE: pageHeights[0] = RGBA rect height, pageQuads[0] = 1.
int pageHeights[ASS_PACK_MAX_PAGES];
int pageQuads[ASS_PACK_MAX_PAGES];
} AssPackResult;
// Packs libass's image list for `atlasPixels`/`vertices` (layout documented at
// the JNI entry point in AssKt.c). Never drops content for size: frames that
// cannot fit ASS_PACK_MAX_PAGES alpha pages (or the vertex budget) flatten into
// an RGBA composite; when the provided buffers are too small for either
// representation, requiredPages tells the caller how much to grow before
// re-rendering, and nothing is written. Returns 0 only on allocation failure.
int ass_pack_frame(
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
size_t vertexCap, AssPackResult* out);
#endif // PLEZY_ASS_PACK_H
+1 -1
View File
@@ -44,7 +44,7 @@ set_target_properties(ass PROPERTIES
IMPORTED_LOCATION "${LIBASS_ARCHIVE}")
target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include")
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c SurfaceTxProbe.c)
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c AssPack.c SurfaceTxProbe.c)
add_dependencies(${CMAKE_PROJECT_NAME} libass_prebuilt_${LIBASS_ABI_TARGET})
# HarfBuzz brings C++; link the shared STL that the app already packages.
# (SurfaceTxProbe resolves its libandroid/libsync entry points via dlsym, so no extra link.)
@@ -4,33 +4,44 @@ package com.edde746.plezy.libass
* Result of a packed-atlas render. The atlas pixel data is stored in the direct ByteBuffer
* that was passed into [AssRender.renderFrameAtlas]; the vertex stream is in the other.
*
* The atlas may span more than one *page* — a heavy full-screen sign can produce more
* sub-pixels than a single GL-max texture holds. Pages are vertically stacked in the
* atlas ByteBuffer (page `p` at byte offset `p * atlasWidth * atlasMaxHeight`), each its
* own texture, and are drawn in turn. Quads are emitted in libass painter order and page
* assignment is monotonic in that order, so each page's quads form one contiguous run in
* the vertex stream ([pageQuadCounts]); the runner uploads page `p`, then draws its run.
* In the common [MODE_ATLAS] the atlas may span more than one ALPHA_8 *page* — a heavy
* full-screen sign can produce more sub-pixels than a single GL-max texture holds. Pages
* are vertically stacked in the atlas ByteBuffer (page `p` at byte offset
* `p * atlasWidth * atlasMaxHeight`), each its own texture, and are drawn in turn. Quads
* are emitted in libass painter order and page assignment is monotonic in that order, so
* each page's quads form one contiguous run in the vertex stream ([pageQuadCounts]); the
* runner uploads page `p`, then draws its run.
*
* In [MODE_COMPOSITE] the frame was too dense for the paged atlas (its summed image area
* exceeds every page: overlapping paint-stroke signs, #1868) and the native side flattened
* it into one premultiplied RGBA rect of [atlasWidth] × `pageHeights[0]` pixels at the
* start of the atlas ByteBuffer, drawn as the single emitted quad with UVs 0..1.
*
* Built in Kotlin by [AssRender.renderFrameAtlas] from the int[] header the native
* renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so
* the minifier may obfuscate it freely without breaking the native boundary.
*
* @param atlasWidth atlas row stride in pixels (= the allocated width; same for every
* page; 0 when [changed] == 0)
* page; 0 when [changed] == 0) — or the RGBA rect width in
* [MODE_COMPOSITE]
* @param pageHeights packed height (rows worth uploading) of each page; `size` = page count
* @param pageQuadCounts quads on each page, contiguous in the vertex stream in this order;
* `size` = page count, `sum` = [quadCount]
* @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices
* @param changed libass change flag (0 = no change, 1 = positions, 2 = content)
* @param truncated images dropped because the frame needed more than [requiredPages]
* pages of capacity or exceeded the vertex budget; the frame is
* incomplete but never stale (should be unreachable for real content)
* @param requiredPages pages this frame needs to render completely. When it exceeds
* [pageHeights].size the caller must grow the atlas buffer and
* re-render; the rendered pages are still valid in the meantime.
* @param truncated images dropped because the frame needed more capacity than the
* buffer holds; the frame is incomplete but never stale. Recoverable
* by growing to [requiredPages]; with the composite fallback,
* unrecoverable truncation should be unreachable for real content
* @param requiredPages pages of buffer capacity this frame needs to render completely.
* When it exceeds [pageHeights].size the caller must grow the atlas
* buffer and re-render; the rendered pages are still valid in the
* meantime.
* @param hasOutput true when libass reported at least one visible image for this
* timestamp, even when [changed] is 0 and the buffers were not
* rewritten. false means this timestamp should be blank.
* @param mode [MODE_ATLAS] or [MODE_COMPOSITE]; must match the ASS_PACK_MODE_*
* constants in AssPack.h
*/
class AssAtlasFrame(
val atlasWidth: Int,
@@ -40,8 +51,17 @@ class AssAtlasFrame(
val changed: Int,
val truncated: Int,
val requiredPages: Int,
val hasOutput: Boolean
val hasOutput: Boolean,
val mode: Int = MODE_ATLAS
) {
companion object {
/** One or more ALPHA_8 atlas pages, per-quad colors in the vertex stream. */
const val MODE_ATLAS = 0
/** One premultiplied RGBA rect at the start of the atlas buffer, one quad. */
const val MODE_COMPOSITE = 1
}
/** Number of atlas pages this frame occupies. */
val pageCount: Int get() = pageHeights.size
@@ -8,9 +8,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
companion object {
/** Must match MAX_ATLAS_PAGES + the header layout in AssKt.c (`writeAtlasHeader`). */
/** Must match ASS_PACK_MAX_PAGES + the header layout in AssPack.h/AssKt.c (`writeAtlasHeader`). */
private const val MAX_ATLAS_PAGES = 4
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES + 1
@JvmStatic
external fun nativeAssRenderInit(ass: Long): Long
@@ -89,6 +89,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
lock.withLock { this.track = track }
}
/** False after [setTrack] with null (subtitles disabled) — renders would return null. */
val hasTrack: Boolean get() = lock.withLock { track != null }
fun setFontScale(scale: Float) {
generation.incrementAndGet()
withNative { nativeAssRenderSetFontScale(it, scale) }
@@ -175,7 +178,8 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
changed = header[2],
truncated = header[3],
requiredPages = header[4],
hasOutput = header[5] != 0
hasOutput = header[5] != 0,
mode = header[7 + 2 * MAX_ATLAS_PAGES]
)
}
}
@@ -58,9 +58,11 @@ internal object AssAtlasPipelineConfig {
* Hard cap on vertically-stacked atlas pages per slot. A frame whose packed
* sub-pixels exceed one [ATLAS_PIXEL_BUDGET] texture (a 4K-rendered full-screen
* typeset sign) spills into extra pages so nothing is dropped (#1436); the atlas
* buffer grows on demand toward this cap. 4 covers the worst frame measured (a 4K
* letter needs 3); past it tiles are dropped and counted in `truncated`. Must match
* MAX_ATLAS_PAGES in AssKt.c.
* buffer grows on demand toward this cap. 4 covers the worst frame measured for
* ordinary typesetting (a 4K letter needs 3); frames too dense even for that
* (overlapping paint-stroke signs, #1868) flatten into a single RGBA composite
* ([AssAtlasFrame.MODE_COMPOSITE]) instead of dropping tiles. Must match
* ASS_PACK_MAX_PAGES in AssPack.h.
*/
internal const val MAX_ATLAS_PAGES = 4
@@ -311,8 +313,9 @@ internal class AssAtlasPipeline(
/**
* Re-renders the last requested position — for renderer state changes (margins,
* use-margins) that must become visible while playback is paused. Safe during
* playback: the next video frame's [requestRender] supersedes it (latest-wins).
* use-margins, a disabled track) that must become visible while playback is
* paused. Safe during playback: the next video frame's [requestRender]
* supersedes it (latest-wins).
*/
fun invalidate() {
libassThread.invalidate()
@@ -580,6 +583,12 @@ private class AtlasLibassThread(
@Volatile private var lastRequestedPtsUs = UNSET
private var contentSeqCounter = 0L
/** True once any payload reached the GL thread — the overlay may be showing content. */
private var hasEverPosted = false
/** State generation a trackless blank clear was posted for; MIN_VALUE = none pending. */
private var tracklessClearedGeneration = Long.MIN_VALUE
// Thread-confined; created on first render so non-ASS playback never allocates.
private var engine: SpecRenderEngine? = null
private var engineStateGeneration = Long.MIN_VALUE
@@ -718,8 +727,9 @@ private class AtlasLibassThread(
var frame = render.renderFrameAtlas(timeMs, payload.atlasBuf, slots.atlasW, slots.atlasH, payload.vertexBuf)
?: return null
// A frame overflows one atlas page only on dense full-screen typesetting. When it
// does, grow this slot's buffer to the pages it needs (capped) and render once more
// — libass's caches make the re-render cheap, and the slot keeps the larger buffer
// does — multi-page atlas or an RGBA composite rect needing more than one page —
// grow this slot's buffer to the pages it needs (capped) and render once more:
// libass's caches make the re-render cheap, and the slot keeps the larger buffer
// so the same density never re-grows. The truncated first result is never handed off.
if (frame.requiredPages > payload.pageCapacity && payload.pageCapacity < AssAtlasPipelineConfig.MAX_ATLAS_PAGES) {
payload.growAtlas(
@@ -757,7 +767,22 @@ private class AtlasLibassThread(
val waitMs = (tDrain - request.enqueueNs) / 1_000_000
// Before any ASS render exists (SRT/VTT or no subs) do nothing — this also
// keeps the slot buffers unallocated for non-ASS playback.
if (assHandler.render == null) return
val render = assHandler.render ?: return
if (!render.hasTrack) {
// Subtitles were disabled mid-cue: every render now returns null, so no
// payload would ever reach the GL thread again and the last swapped cue
// would sit on the overlay until playback ends (#1884). Swap one explicit
// blank frame instead. Keyed on the state generation ([AssRender.setTrack]
// bumped it) so a clear the GL thread drops as stale is retried by the
// next request; reset below once a track is live again.
val tracklessGeneration = stateGeneration()
if (hasEverPosted && tracklessClearedGeneration != tracklessGeneration) {
tracklessClearedGeneration = tracklessGeneration
postBlankClear(request, tracklessGeneration)
}
return
}
tracklessClearedGeneration = Long.MIN_VALUE
val slots = acquireSlots()
val generation = stateGeneration()
val engine = ensureEngine(slots, generation)
@@ -787,6 +812,7 @@ private class AtlasLibassThread(
payload.requestSeq = request.sequence
payload.stateGeneration = generation
onFrameReady(payload)
hasEverPosted = true
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(
TAG,
@@ -794,7 +820,8 @@ private class AtlasLibassThread(
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
"specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " +
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated}"
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated} " +
"mode=${payload.frame.mode}"
)
}
}
@@ -824,6 +851,29 @@ private class AtlasLibassThread(
maybePrefetch(engine, slots, pts, pinned)
}
/**
* Hands the GL thread a zero-quad payload — [AtlasRenderer.onDrawFrame] clears
* and draws nothing — so a cue still on screen when its track is disabled goes
* away immediately instead of outliving the selection (#1884).
*/
private fun postBlankClear(request: PendingFrame, generation: Long) {
val slots = acquireSlots()
// Never rewrite the slot the GL thread may still be reading (slotCount >= 2).
val payload = slots.payloads.first { it.slotIndex != glTakenSlot() }
payload.frame = AssAtlasFrame(0, 0, 0, 0, 0)
payload.contentSeq = ++contentSeqCounter
payload.sourcePresentationTimeUs = request.sourcePtsUs
payload.presentationTimeUs = request.ptsUs
payload.releaseTimeNs = request.releaseNs
payload.phaseLeadUs = request.phaseLeadUs
payload.requestSeq = request.sequence
payload.stateGeneration = generation
onFrameReady(payload)
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(TAG, "trackless-clear req=${request.sequence} pts=${request.ptsUs / 1000}ms gen=$generation")
}
}
/** Start time of the last event boundary we cache-warmed (libass/track ms). */
private var lastPrefetchedStartMs = Long.MIN_VALUE
@@ -1403,15 +1453,19 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
}
""".trimIndent()
// u_Rgba switches the sampling mode: 0 = ALPHA_8 atlas mask tinted by the per-vertex
// color (the common path), 1 = premultiplied RGBA composite sampled directly
// (AssAtlasFrame.MODE_COMPOSITE). Both end premultiplied, matching the blend state.
private val fragmentShaderCode = """
precision mediump float;
varying vec2 v_TexCoord;
varying vec4 v_Color;
uniform sampler2D u_Texture;
uniform float u_Rgba;
void main() {
float mask = texture2D(u_Texture, v_TexCoord).a;
float alpha = v_Color.a * mask;
gl_FragColor = vec4(v_Color.rgb * alpha, alpha);
vec4 texel = texture2D(u_Texture, v_TexCoord);
float alpha = v_Color.a * texel.a;
gl_FragColor = mix(vec4(v_Color.rgb * alpha, alpha), texel, u_Rgba);
}
""".trimIndent()
@@ -1423,11 +1477,18 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
private var allocatedPages = 0
private var vertexBufferId = 0
// Texture for MODE_COMPOSITE frames (premultiplied RGBA rect); allocated lazily on
// the first composite frame and re-specced when the rect outgrows it.
private var rgbaTexId = 0
private var rgbaAllocW = 0
private var rgbaAllocH = 0
private var aPosition = 0
private var aTexCoord = 0
private var aColor = 0
private var uTexture = 0
private var uSurfaceSize = 0
private var uRgba = 0
private var atlasAllocatedW = 0
private var atlasAllocatedH = 0
@@ -1477,6 +1538,7 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color")
uTexture = glProgram.getUniformLocation("u_Texture")
uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize")
uRgba = glProgram.getUniformLocation("u_Rgba")
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glUniform1i(uTexture, 0)
@@ -1527,11 +1589,22 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, stride, 0)
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8)
GLES20.glVertexAttribPointer(aColor, 4, GLES20.GL_FLOAT, false, stride, 16)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
if (frame.mode == AssAtlasFrame.MODE_COMPOSITE) {
// The whole frame is one premultiplied RGBA rect (atlasWidth × pageHeights[0])
// at the start of the atlas buffer, drawn as the single emitted quad.
GLES20.glUniform1f(uRgba, 1f)
bindCompositeTexture()
if (!reuseUploads) uploadComposite(payload.atlasBuf, frame.atlasWidth, frame.pageHeights[0])
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, quadCount * 6)
return
}
GLES20.glUniform1f(uRgba, 0f)
// Each atlas page is its own texture; its quads are one contiguous run in the
// stream (page assignment is monotonic in painter order). Upload + draw each in
// turn, which reproduces the libass blend order across pages.
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
var quadOffset = 0
for (p in 0 until frame.pageCount) {
val pageQuads = frame.pageQuadCounts[p]
@@ -1545,6 +1618,48 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
}
}
/** Generates (once) and binds the RGBA composite texture. */
private fun bindCompositeTexture() {
if (rgbaTexId == 0) {
val tex = IntArray(1)
GLES20.glGenTextures(1, tex, 0)
rgbaTexId = tex[0]
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
rgbaAllocW = 0
rgbaAllocH = 0
} else {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
}
}
/** Uploads the composite RGBA rect from the start of the stacked atlas buffer into
* the bound composite texture. GLES2 has no UNPACK_ROW_LENGTH, so a sub-image
* update is only stride-correct at the allocated width; otherwise re-spec. */
private fun uploadComposite(atlasBuf: ByteBuffer, width: Int, height: Int) {
if (width <= 0 || height <= 0) return
atlasBuf.clear()
atlasBuf.limit(width * height * 4)
atlasBuf.position(0)
if (width == rgbaAllocW && height <= rgbaAllocH) {
GLES20.glTexSubImage2D(
GLES20.GL_TEXTURE_2D, 0, 0, 0, width, height,
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
)
} else {
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
width, height, 0,
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
)
rgbaAllocW = width
rgbaAllocH = height
}
}
/** Uploads page [page]'s packed rows from the stacked atlas buffer into the
* currently-bound page texture. */
private fun uploadPage(atlasBuf: ByteBuffer, page: Int, atlasW: Int, pageH: Int) {
@@ -1578,6 +1693,13 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
atlasTexIds.fill(0)
allocatedPages = 0
}
if (rgbaTexId != 0) {
val tex = intArrayOf(rgbaTexId)
GLES20.glDeleteTextures(1, tex, 0)
rgbaTexId = 0
rgbaAllocW = 0
rgbaAllocH = 0
}
if (vertexBufferId != 0) {
val buf = intArrayOf(vertexBufferId)
GLES20.glDeleteBuffers(1, buf, 0)
@@ -45,7 +45,8 @@ class AssSubtitleSurfaceView(
pipeline?.requestRender(presentationTimeUs, releaseTimeNs)
}
/** Re-renders the last position, e.g. after margin changes while paused. */
/** Re-renders the last position — margin changes while paused, or clearing a
* still-visible cue right after subtitles were disabled (#1884). */
fun invalidateSubtitles() {
pipeline?.invalidate()
}
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"/>
</svg>

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M1.928.029A2.47 2.47 0 0 0 .093 1.673c-.085.248-.09.629-.09 10.33s.005 10.08.09 10.33a2.51 2.51 0 0 0 1.512 1.558l.276.108h20.237l.277-.108a2.51 2.51 0 0 0 1.512-1.559c.085-.25.09-.63.09-10.33s-.005-10.08-.09-10.33A2.51 2.51 0 0 0 22.395.115l-.277-.109L12.117 0C6.615-.004 2.032.011 1.929.029m7.48 8.067 2.123 2.004v1.54c0 .897-.02 1.536-.043 1.527s-.92-.845-1.995-1.86c-1.071-1.01-1.962-1.84-1.977-1.84s-.024 1.91-.024 4.248v4.25H4.911V6.085h1.188l1.183.006zm9.729 3.93v5.94h-2.63l-.01-4.25-.013-4.25-1.907 1.795a367 367 0 0 1-1.98 1.864c-.076.056-.08-.047-.08-1.489v-1.555l2.127-1.995 2.122-1.995 1.187-.005h1.184z"/></svg>

After

Width:  |  Height:  |  Size: 715 B

-1
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3396">
<rdf:RDF>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3886">
<rdf:RDF>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Some files were not shown because too many files have changed in this diff Show More