Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 61c7453a7a chore(deps): bump actions/setup-go from 6.5.0 to 7.0.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.5.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/924ae3a1cded613372ab5595356fb5720e22ba16...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 12:15:41 +00:00
edde746 395798f28e fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto
On Auto, Dart derives a buffer size for mpv's demuxer from the device heap and
sets it as `demuxer-max-bytes`. The Android player forwarded that same number to
`DefaultLoadControl.setTargetBufferBytes`, so ExoPlayer's sample allocator was
sized by a tier table written for a different consumer: 64MB on any device whose
large heap is 512MB or less, which every Shield is.

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

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

Confirmed against the hardware in the 2.9.1 passthrough report. That reporter's
own log is a natural A/B: three runs at 64MB fail with `0 frames rendered after
8002ms`, spanning both DV conversion modes and both tunneling states, while the
single run after he manually selected 128MB logs `Position advancing` and
renders. Reproduced on the same Shield model with codec and bitrate held fixed
and only the cap varied — 6s of audio demand stalls at 64MiB and plays at
128MiB, 4 of 4 predictions, with read-ahead measured off an injected
DefaultAllocator at 65 664 and 131 776 KiB. That device reports
`dalvik.vm.heapsize` 512m, so the heap term binds first at every free-memory
level in his log and Auto now derives exactly the 128MB he had to pick by hand;
the shipped path logs `Buffer: 128MB limit (auto, heap=512MB, available=568MB)`
where it previously logged 64MB.
2026-08-01 06:59:21 +02:00
edde746 3f49bcabf8 fix(prefs): replace the desktop preference store atomically
Upstream shared_preferences_windows and _linux write the whole preference
document with a bare `writeAsStringSync`. That opens with the default
`FileMode.write`, which truncates the live file before writing it, so every
single preference write has a window in which the only copy on disk is empty
or half-written. A crash, power loss, forced reboot or antivirus interception
inside that window leaves a document that fails to parse on every subsequent
launch — and the store holds the credential-vault key, so the loss is not
recoverable by rewriting it. This is the corruption class behind #1732; the
recovery path already landed is a band-aid over it.

Vendor both packages under packages/ — the convention saf_util and
wakelock_plus already follow — and stage, flush, then rename over the target.
The flush has to precede the rename or it could publish contents that were
never committed, the same corruption by another route. Staging uses one fixed
sibling name rather than a stamped one, because the file is a plaintext copy
of the vault key, tracker refresh tokens and Seerr cookies; it is created in
the target's own directory so rename stays on one volume and the mode matches
what the canonical file would have had, and a stale one is swept once the
canonical document has been read cleanly. Both deltas are marked in-source and
in provenance.json with the refresh contract.

Atomicity is proven, not asserted. A hard link to the store observes the old
document after a write, which only holds when the directory entry was replaced
— truncate-in-place would have rewritten the shared inode, and that test does
fail against unpatched upstream. Upstream's own suites still pass unchanged in
both packages and now run in CI, so the patch keeps the contract it inherited.
Windows `MoveFileExW` replacement semantics cannot be proven on a POSIX runner
or a memory file system, so they get their own test on the existing
windows-latest job, including replacement while a reader holds the file open —
antivirus and Search Indexer both do.
2026-08-01 06:59:20 +02:00
edde746 9ecf8db90f fix(startup): stop offering Retry after a repair that needs a restart
A seed-and-restart repair writes the salvaged credentials straight to disk and
leaves this process's store closed, because the plugin still holds the bad
document in memory. repairCorruptStore says so plainly — "nothing may write a
preference before that restart … the caller keeps the app on the failure
screen precisely so nothing does" — but the caller did not. Clearing the
repairing flag re-enabled Retry, and pressing it reopened onto the stale map,
whose first write would flush it back over the seed and orphan every
ciphertext token in the database.

The repair hook returned a bare bool, which cannot express the difference
between "retry now" and "never retry in this process", so replace it with
StartupRepairResult. The restart case latches terminal state on the bootstrap,
withdraws Retry and Repair rather than grey them out — a disabled control
still invites another press — and says what to do instead, which nothing did:
repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a
Quit button through the existing AppExitService seam; Copy and Upload stay
live everywhere, because a stuck user still needs the diagnostic out.

close #1732
2026-08-01 06:59:20 +02:00
edde746 3ae7aa554b fix(prefs): recover a preference store whose bytes are not valid UTF-8
`File.readAsString` reports a UTF-8 decode failure as a FileSystemException,
not a FormatException, so three guards written for that case never ran. The
preflight's `on FormatException` branch was unreachable and its
`on FileSystemException` sibling waved the document through; the plugin then
threw the same FileSystemException, which failed the FormatException/TypeError
test that decides repairability; and quarantine's lossy-decode fallback sat
dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray
0x80 — therefore reached the user as a failure screen with no Repair button
and no way forward at all.

Read bytes and decode explicitly instead, at both sites. Classification moves
into describeStoreDamage, so a failure that surfaces after the preflight
passed is judged by re-reading the file rather than by the error's type: a
denied or locked store is indistinguishable from a decode failure by type or
message, and offering a destructive repair for a permissions problem would
reset every setting and risk the vault key over something a chmod fixes.
isCorruptStoreError went with it, having no remaining callers.

A repair that quarantines the store and then cannot reopen it no longer
strands the process either. The repaired future was built straight from the
cache loader, bypassing the self-healing reset sharedCache installs, so a
failed reopen parked a rejected future in _cacheFuture and every later attempt
replayed that stale error — with the damaged file already moved aside, so a
restart would have booted cleanly.

CorruptPreferenceStoreException now carries reopenSafe and a derived,
content-free shape: byte length, whether it decoded, whether every byte is
zero. #1732 arrived as "FormatException at offset 0" and nothing else, which
cannot separate an all-zero file from a non-JSON first character from bytes
that are not UTF-8; these can, and never quote the document.

Cover the loop against the real desktop backend rather than a fake.
shared_preferences_linux is pure Dart, byte-identical to the Windows
implementation, and exposes fs/pathProvider, so pointing it at a temp
directory exercises the genuine read, parse, cache and write path on any host
— the join between preflight, classification and reopen where every one of
these defects lived, and which had no coverage at all.
2026-08-01 06:59:20 +02:00
edde746 3509f4b989 docs: vendor the Microsoft Store badge as a PNG
GitHub sized the hotlinked SVG from its 161x44 intrinsic box rather than the
img height attribute, so the badge rendered short beside the other three.

rsvg-convert at exactly 4x intrinsic keeps the aspect ratio bit-identical and
the rounded corners transparent, matching the neighbouring badge assets. All
four now render 60px tall, and the README no longer hotlinks any image.
2026-07-31 22:52:07 +02:00
edde746 8a46df2850 fix(player): keep live TV on its retry ladder when a stream 404s
The 404 branch added in 16668be5 ran ahead of the live-TV fallback chain, so a
transient live 404 — an HLS segment rolled off the playlist, or a transcode
session restarting under us — showed "file unavailable" and killed a stream
the bounded ladder would have recovered. Only on-demand playback can read a
404 as terminal, where it really does mean the file is unreadable. 500 stays
terminal for both, since a limit rejection is not something a retry clears.

The dispatch lived in a private extension on the screen state, where no test
could reach the decision. Extract it as resolvePlaybackFailureAction next to
runLiveStreamRetry, which already sets that precedent, and cover both the live
and on-demand paths plus the ladder's rungs.
2026-07-31 21:45:33 +02:00
edde746 56ad48824b fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.

The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.

Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
2026-07-31 21:45:33 +02:00
edde746 4fe4f7b1d7 fix(mpv): stop loading the ytdl hook for media-server streams
Every URL the player opens is a media-server stream or a local file, so mpv's
bundled ytdl_hook has nothing to resolve. It still ran an on_load hook per
open and, whenever an open failed, spawned yt-dlp with the full stream URL in
its argv — access token included, readable through /proc on Linux. It also
added ~700ms to every failed open and buried the real "[stream] Failed to
open" line under three ytdl_hook errors.

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

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

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

On Android a 404 previously failed the "Response code: 500" string test and
fell through to the ExoPlayer→MPV fallback, showing "switching to compatible
player" before failing again on the same request. Read the real status off
HttpDataSource.InvalidResponseCodeException instead and skip the fallback:
an HTTP status is not a codec problem.
2026-07-31 21:45:33 +02:00
edde746 6f9edd3e93 fix(startup): make the deferred crash report survive its races
The persist-then-flush model had four ways to lose or corrupt the record
it exists to protect.

A no-op hub — which is what a failed or timed-out crash-reporting init
leaves behind, because that phase is best effort — accepts an event and
returns an empty id without throwing. "Did not throw" was treated as
delivery, so the record was marked reported and suppressed forever.
Delivery now requires a non-empty Sentry id, and init completion is
tracked explicitly rather than assumed.

Opting out, and building without a DSN, are deliberate suppression
rather than delivery failure: both mark the record resolved so it is not
rediscovered every launch. Everything else stays pending, and
consumption no longer deletes an unreported record — deleting it ended
the only retry there was, which made "the next launch tries again"
false.

The write path is now a queue. Record writes were launched unawaited
from the failure path, so a fast retry could flush before the file
existed, consume before a late write landed, or run two writers against
one file and let the older one finish last. markReported joins the same
queue and compares record identity before rewriting, because reading and
writing outside it let a concurrent record land in between and be
overwritten by the record it had just superseded. Records carry an id so
that comparison is meaningful.

Consumption also waits on a registered flush, so the success path cannot
delete the file mid-send.

Also routes the tvOS recovery marker through the tolerant read.
reconcile() runs inside AppDatabase.open, a fatal gate step, so a
wrong-typed marker vetoed the launch outright on a first-class TV
target. Both new guards have regression tests verified to fail without
the fix.
2026-07-31 21:45:33 +02:00
edde746 9555937873 fix(startup): defer crash reports until the reporter exists
Reporting the failure inline was wrong for the phase that matters most.
The gate opens preferences before SentryFlutter.init, so a corrupt or
unreadable store — the likeliest cause of #1732 — was captured by a
NoOpHub and silently discarded, which is exactly the telemetry gap the
previous commit claimed to close. Initialising the reporter earlier is
not an option either: `_beforeSend` reads the crash-reporting opt-out
from settings, so events raised before settings load would bypass a
user's choice.

Every failure is now persisted first and flushed once the reporter is up
with settings loaded, which in practice is the user's own retry seconds
later in the same process. Records carry a `reported` flag so a send
happens exactly once, and a failed send leaves the flag clear so the
next launch tries again. The flush reads without consuming, so the
record still reaches Settings > Logs.

Also routes the tvOS recovery marker through the tolerant read:
`reconcile()` runs inside `AppDatabase.open`, a fatal gate step, so a
wrong-typed marker vetoed startup outright on a first-class TV target
despite the new default-instead-of-veto behaviour. An unreadable marker
tells us nothing, which is the same position as an absent one.
2026-07-31 21:45:33 +02:00
edde746 66549e3a67 fix(prefs): route every credential read through the tolerant path
The wrong-type recovery only covered reads that went through a
BaseSharedPreferencesService instance. The three stores that hold
credentials read the shared cache directly, so a mistyped value there
still threw a raw TypeError or, for Seerr, was swallowed by a catch-all
and reported as "no session" — the registry documented protection it did
not actually provide.

readPreferenceTolerantly now takes the cache, so CredentialVault,
TrackerAccountStore and SeerrSessionStore get the same classification as
the settings layer. CredentialVault's post-write re-read moves outside
its catch: a wrong-typed value written by another isolate was swallowed
there, and the process then returned a key that never durably landed,
making every ciphertext written under it unreadable on the next launch.

Those stores are consulted long after startup, where a throw is an
unhandled provider error rather than a repair prompt, so SettingsService
initialization now walks the cached key set once and reads every
sensitive key. That puts the failure inside a fatal gate step while the
store is still open and a surgical single-key repair is possible.

The remaining direct reads in settings and storage are routed too; the
only ones left are the library-density dual-type migration, which probes
both types deliberately, and an untyped switch that is type-safe by
construction.
2026-07-31 21:45:32 +02:00
edde746 7f0cad339c fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
2026-07-31 21:45:32 +02:00
edde746 7c515bf8fa feat(website): serve Windows from the Microsoft Store and tag store campaigns
The Windows button downloaded plezy-windows-installer.exe from the latest
release. It now opens the Store listing, which brings Store-managed updates.
The endpoint redirects to ms-windows-store://, so the button only resolves on
Windows; macOS and Linux keep their direct release downloads.

Store links carry campaign parameters (ct=Landing, utm_campaign=landing,
cid=landing) so landing-page traffic separates from the README's in each
store's own reporting. Play reports utm_source and utm_campaign, so no
utm_medium is sent.

Structured data keeps untagged canonical URLs: schema.org offers are consumed
by search engines, and a rich-result click is not landing-page traffic.
2026-07-31 21:21:06 +02:00
edde746 f30e2c621a docs: refresh readme features and download channels
The features section last changed substantively in 2c54baca3 (2026-05-17),
before the music and Explore subsystems shipped, so two whole feature areas
were missing and several availability notes had drifted.

Adds Music and Explore & Requests sections, and corrects claims that no
longer hold: the locale count (14 -> 21), the EPG guide is not Plex-only,
downloads include music and are unavailable on tvOS, Picture-in-Picture
excludes the TV platforms, and shaders and ambient lighting need the mpv
backend. Footnotes move from numeric to named so adding one no longer
renumbers the rest.

Windows now points at the Microsoft Store listing instead of the direct
installer and portable archives. The App Store and Play badges carry
campaign tokens so README traffic is attributable in each store's own
reporting.

The prerequisite Flutter version matches the pinned toolchain (3.44.0), and
the Maestro end-to-end suite gets the pointer it never had.
2026-07-31 21:18:53 +02:00
github-actions[bot] dc5a92a2e0 chore: bump version to 2.11.1 2026-07-30 23:09:53 +00:00
edde746 d55b875855 fix(tvos): raise the system keyboard on arrival, not on every focus
Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (71735354), which made three focus behaviours user-visible.

Submitting re-attached the input connection. EditableText schedules a
restart when a submit action fires with a non-null onSubmitted, and that
microtask runs before the setState flipping readOnly, so the field
re-showed a keyboard the form had just dismissed. The native path now
withholds onSubmitted from EditableText and invokes it from the host,
independently of onEditingComplete as _finalizeEditing does.

Auto-open fired on every focus entry, so D-pad traversal of a multi-field
form raised and dismissed the modal system keyboard on each step.
TvTextInputAutoOpenBehavior gains onFirstFocus, and the new `automatic`
default resolves to it on Apple TV: arriving at a field opens it once,
returning to it does not. Android TV keeps its docked-IME auto-open, and
explicit modes stay literal on both. The autofocused Jellyfin and Seerr
URL fields keep an explicit exception so entering the screen still does
not bury the form (#1217).

EditableText.connectionClosed unfocuses the field outright, so a UIKit
keyboard dismissal left nothing focused at all. The host takes focus back,
keyed on identity with the field's own enclosing scope so a dialog or
route claiming focus meanwhile is left alone.

close #1728
2026-07-31 01:05:10 +02:00
edde746 944a8d89f5 feat(windows): package for the Microsoft Store as an MSIX bundle
The Store's unpackaged EXE path would require Authenticode-signing the
installer and every PE file inside it. MSIX submissions are re-signed by the
Store instead, so this route needs no code-signing certificate. build-msix.ps1
mirrors build-installer.ps1 and consumes the same per-architecture build
artifacts, leaving the installer, portable archives and WinSparkle appcast
untouched.

One template generates the manifest for both architectures, carrying the
identity reserved in Partner Center. check_windows_msix.py recomputes the
package family name from the publisher DN, so a mistyped identity fails CI
rather than a submission, and it parses the script rather than running it
because root CI is Linux. Qualified logo assets are indexed into
resources.pri; without the altform-unplated variants the shell draws the
taskbar icon on an accent-coloured plate.

PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay
tile, which the read-only package directory and Store commerce policy
respectively rule out. Gating at runtime keeps one Windows build feeding both
the installer and the Store package.
2026-07-30 20:03:04 +02:00
edde746 834895486b style: apply dart format to six drifted sources
Formatting was clean through 53288116 and then drifted across three commits on
2026-07-30: 1bf7aac7 left one source unformatted, f13f5af6 a second, and
daab4f1e four more. CI's Verify formatting job checks the whole tree, so it has
had six files to report ever since. No pre-commit hook is installed in this
checkout, so the aggregate check never ran locally to catch them.

Formatted with the dart_style revision Dart 3.12.0 bundles, which is what the
pinned Flutter 3.44.0 CI toolchain runs, rather than with a newer local SDK; the
two disagree about some argument-list splits. The current stable formatter
accepts this result as well, so both report the tree clean.
2026-07-30 14:57:26 +02:00
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00
edde746 5a25c1f9cc feat(simkl): report playback progress while it happens
Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.

The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.

Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.

Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.

close #1719
2026-07-30 11:55:13 +02:00
github-actions[bot] 97cb98526e chore: update cask to 2.11.0 2026-07-30 01:44:55 +00:00
github-actions[bot] c90bc1629a chore: bump version to 2.11.0 2026-07-30 01:19:12 +00:00
edde746 88ffe0806d test(automotive): assert the picture-in-picture vetoes on every host
41ffaa7f2 gated picture-in-picture on FEATURE_AUTOMOTIVE and added a
settings case for it, but the assertion that case leads with — a stored
auto-PiP true surviving a read — needs supportsPictureInPicture() to be
true, and that gate ends in Platform.isAndroid || isIOS || isMacOS. The
term is false and unmockable on the Linux and Windows runners, so the
case passed on a macOS host and could never pass in CI: sanity checks
have been red for six commits on this one failure out of 4723. f13f5af6e
recorded it as a pre-existing Windows-host failure, but it entered in
this window and is red on Linux too.

Extract the gate's decision into a pure pictureInPictureAllowed that
takes the host's own capability as a parameter, the way
driver_distraction.dart already splits automotivePlaybackAllowed from its
ambient wrapper. The boolean algebra is unchanged, so the three callers
keep their behaviour; what changes is that the automotive and TV vetoes
become observable where every Platform branch is false, instead of being
vacuous on the host that gates the release.

The settings case keeps the pref-level contract on both host classes: a
stored true survives where the host supports PiP, and the gate pins it
off where it does not.

Verified with the host term forced false to emulate a Linux runner: both
files stay green, as does the full suite on macOS.
2026-07-30 03:16:49 +02:00
edde746 0a6865fa18 fix(macos): unbound the AVFoundation AO's PCM lookahead
MPVKit 1.0.15 bounded how far ahead ao_avfoundation enqueues PCM on macOS —
about 450ms of queue against the renderer's own ~1.7s — and disarms the feed
between refills, re-arming from a half-bound timer. 2.10 is the first release
to carry it: the AO pin went 1.0.12 to 1.0.16 over that release. #1711 reports
macOS audio skipping roughly every half second on 2.10 that 2.9.1 does not
have, and that bound is the only change to this path in the window, so restore
the renderer-owned depth 2.9.1 shipped. The option documents 0 as exactly that.

The AO itself stays. allowedAudioSpatializationFormats is a property of
AVSampleBufferAudioRenderer, and the compressed E-AC3 JOC sink lives there
too, while ao_coreaudio drives the HAL device and exposes no spatialization
control at all — CoreAudio is the fallback, not an alternative.

The cost is the latency the bound was added to remove: mpv multiplies --volume
into the samples as it hands them over, so a volume change stays inaudible
until the renderer queue drains. That is 2.9.1's behaviour, and the fix for it
belongs to the AO's gain domain rather than to how far ahead it may buffer.

Verified against the pinned MPVKit 1.0.16 libmpv on macOS: both option writes
are accepted, playback lands on ao_avfoundation and advances at 0.997x real
time. Runner's native suite passes.
2026-07-30 02:54:36 +02:00
edde746 27b994422c feat(player): optionally follow the server's per-episode track selections
With the new playback setting enabled, episode advance carries no audio or
subtitle preference at all, so both resolve from the streams selected on
the server for each individual episode. This serves setups that curate
selections server-side (e.g. Plex Auto Languages) and is independent of
"Remember track selections", which keeps gating only the write-back of
manual changes.

close #1717
2026-07-30 02:52:18 +02:00
edde746 daab4f1e24 fix(player): preserve the forced-subtitle class across episode boundaries
Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.

Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.

Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.

Ref #1716
2026-07-30 02:46:10 +02:00
edde746 f13f5af6e2 fix(images): scale artwork budgets to the physical display
Every artwork budget in the image pipeline was tuned for 1080p surfaces:
the transcode request clamp (1920x1080), the per-type decode caps
(poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV
image-cache bytes. Those numbers are exact on phones and on the many TV
boxes that composite the app at 1080p, but a TV compositing at 4K
renders every capped image below its slot and GPU-upscales the result:
hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x,
shelf posters by ~1.13x - the softness reported against the official
Plex client in #1697, and the class #860's min-2x-DPR fix could not
reach.

DevicePerformance now latches a display budget factor - the display's
shortest physical axis over 1080, capped at 2x - whenever the image
cache budget is applied (startup, post-mount, effects-setting changes).
The transcode clamp, the full-tier decode caps and the TV cache bytes
all scale by it, so a 4K surface fetches and decodes 4K backdrops and
proportionally larger cards. The reduced tier stays pinned to 1.0, and
sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art
(~33MB per decode) cannot starve mid-RAM boxes; latching once per
session keeps transcode URLs - and with them the disk cache keys -
stable across rotation and rebuilds.

Whether a given TV composites at 1080p or 4K decides whether any of
this can help, and logs never recorded it: the startup banner and the
log-upload header now carry a display line (physical, logical, DPR,
latched budget) so uploaded logs answer that question directly.

The two pre-existing Windows-host test failures (automotive auto-PiP
gate, backdrop temp-dir teardown lock) reproduce unchanged on the base
commit.
2026-07-30 01:40:24 +02:00
edde746 1bf7aac75b fix(explore): match Plex Discover titles to the library again
Two defects sank Explore's Plex integration. Discover started rejecting
X-Plex-Container-Size=500 with a 400, so the watchlist membership
snapshot never loaded: hearts stayed unknown and toggles dead. The
snapshot now pages at 100, and getWatchlist refetches a rejected page in
chunks of the row fetch's field-proven 25, so the next cap drift degrades
gracefully instead of failing and callers' offset math survives either
way.

Worse, every Plex catalog item reached the library matcher carrying only
its Discover rating key: listings were fetched without includeGuids, so
the lookup rested entirely on exact plex:// guid equality between two
metadata universes (Discover duplicate entries break it, notoriously for
anime), and the title fallback can never confirm a candidate without
external ids to intersect - "Not in your library" for owned titles the
MAL provider matched fine. Discover listings now request Guids, the
detail screen re-runs the matcher when enrichment gains id forms
(generation-guarded so the slower bare lookup cannot overwrite the
richer verdict), the matcher keys its memo by id fingerprint so the poor
form's cached negative cannot answer for the rich one, and the Plex
client stops burning title requests that external-id verification is
guaranteed to reject.

Discover requests are now logged like every other API surface; this bug
shipped blind because they were not.

close #1715
2026-07-30 01:33:46 +02:00
edde746 53288116fe fix(explore): size catalog detail relations, ratings and facts to their content
Four sections of the catalog detail screen spent more room than their data
justified.

Franchise relations drew one hub shelf per label. Real payloads make that
absurd: MAL returns twelve relations for Attack on Titan across six labels,
and "Side story" and "Sequel" each hold exactly one title, so each spent a
header, a scroll row and one card. Flatten the labelled groups into one
"Related titles" section of compact rows — poster thumb, label, title and year
— that flow into columns on wide viewports. D-pad moves through the grid by
index and still hands off to the cast strip above and the recommendations
shelf below, which keeps its shelf because taste-based recommendations are
meant to be browsed.

Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster
variants of the title you are already looking at, and it cost a page-height of
scroll; the `pictures` field comes back out of the detail request with it.

Draw attributed scores behind their own brand mark where the source has one,
the way the media detail screen already does: Rotten Tomatoes fresh/rotten and
upright/spilled, IMDb and TMDB, each on the scale that source publishes.
Sources with no mark (critic, audience, tracker scores) keep their written
label. Plex's own badge state is derived from the 60% tomatometer threshold it
encodes in `image.rating.ripe`.

Flow the definition rows — original title, studios, country, budget, box
office, crew — into two or three columns once the window is wide enough.
A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of
nothing per fact.

Verified against live MAL and Plex Discover payloads on macOS: the Attack on
Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282
to 1,154.
2026-07-30 01:01:38 +02:00
edde746 644c782edd fix(player): tick the frame-clock keep-alive only on Linux
The 1x1 keep-alive repaint loop was extended to Windows in a87aa296 to
paper over the legacy compositing path's resize desync (#227); the DComp
rework replaced that presentation path entirely. On the DComp engine the
100ms repaints become DirectComposition commits during playback, and
once fullscreen focus engages VRR (FreeSync/G-Sync) every commit forces
a scanout off the video's cadence - the micro-stutter of #1707.

The widget now owns the platform decision behind a test seam, and a new
quiescence test pins the hidden-chrome player UI to zero scheduled
frames so no future ticker can silently reintroduce the defect.
2026-07-29 19:04:57 +02:00
edde746 27acbaf435 feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
2026-07-29 06:47:54 +02:00
edde746 0ef71e6489 feat(file-info): detail every version, file and stream the server reports
The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.

Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.

File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.

Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
2026-07-29 05:19:27 +02:00
edde746 69ceea9466 fix(android): grant shelf artwork to every installed launcher
The Watch Next poster art moved to a local content:// URI in 2.10.0, gated
twice on the package returned by resolveActivity(MAIN+HOME,
MATCH_DEFAULT_ONLY): once as the grantUriPermission target, once as a
caller-identity check inside SystemShelfArtworkProvider.openFile. Any
launcher that is not the resolved default HOME activity was denied on every
image and drew its broken-image placeholder instead. Fire OS pins its own
launcher and silently reverts a third-party default, so Projectivy could
never satisfy either gate; a device with several launchers and no chosen
default resolves to the resolver activity and granted nobody at all.

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

close #1706
2026-07-29 05:12:28 +02:00
edde746 f02924b9a5 fix(player): require a fresh double tap for every mobile skip-zone seek
The skip badge doubled as an armed state: while it was up, any single tap
in the same-direction zone seeked again. The badge is also raised by
keyboard, D-pad, media-transport and live seeks, so one remote press armed
one-tap seeking on the touch surface with no double tap at all. It stayed
armed for 1200 ms plus the fade and renewed on every tap, leaving the side
zones - 35% of the width each, over 70% of the height - unable to raise
the chrome.

Pair taps off the pending single-tap timer rather than differencing
DateTime.now(). The window is then one deadline that a clock adjustment
cannot stretch, suppressing touch taps disarms a half-finished pair, and
_lastSkipTapTime belongs to the desktop double-click paths alone.

Consecutive completed skips still accumulate into one running badge total.
2026-07-29 05:05:40 +02:00
edde746 f3795d49eb feat(player): answer transport keys with transient indicators, not the chrome
Pressing pause or seeking while the player's on-screen controls were hidden raised
the entire OSD, covering the subtitles the viewer was rewinding to read. Transport
keys now answer with a transient indicator and leave the chrome down; Select,
D-pad Center and a centre tap remain the deliberate way to bring the controls
back.

Play/pause confirms with an icon-only translucent disc at the centre of the frame,
72px around a 44px glyph, which grows and fades in, holds half a second at rest,
then runs the same motion in reverse. Seeking shows the amount plus a single
chevron on the same line at the edge it travels toward, with no backdrop at all:
anything large enough to read as a surface is large enough to cover picture and
subtitles, so legibility comes from shadows instead. Only the chevron moves, and
it eases outward across most of its cycle and returns briefly, holding a visible
opacity floor rather than blinking out. Type is scaled per platform, since a
television is read from across the room. The existing text pill stays for genuine
notices - rate changes, chapter titles, zoom, errors - because an earlier centred
pill overlapped ASS \an8 subtitle placement, which is the readability complaint
this feedback exists to answer.

Every relative seek entry point now shares one coalescing primitive. The keyboard
shortcuts fell through to KeyboardShortcutsService and previously reported
nothing, and both they and the remote's chapter fallback rebased each press off
player.state.position, so a burst against a slow backend pinned every request near
one step while the indicator climbed to a total that was never committed. A
released key commits its pending target immediately and resets the acceleration
tier, including on live TV where seeks bypass the accumulator. A chapter seek with
nowhere to go, past the last chapter or already at the start, no longer announces a
jump it does not perform.

Rewind-on-resume follows the resolved intent rather than the current state, so a
directed pause on an already-paused video neither resumes nor rewinds. Indicators
carry their own liveRegion semantics nodes: their labels previously merged into the
full-screen "show playback controls" target, corrupting its accessible name, and
they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals.

close #1676
2026-07-29 04:24:04 +02:00
edde746 0eee9f688d fix(explore): match sequel catalog entries to their parent library show
MAL/AniList season entries never matched the library show they belong to.
Both backends use the catalog title as a server-side filter before verifying
external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2"
cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured
against a 267-show Plex library, 3 of 113 mapped sequel entries matched.

The reverse lookup now takes two ordered title candidates instead of one: the
entry's own title and its season-stripped form, with typographic punctuation
normalised because both servers miss on a curly apostrophe. That matches 77 of
113. Widening it further to romaji/native/synonym variants reached only 81, so
the cap stays at two rather than spending up to five more requests per lookup
that finds nothing.

A sequel's year is its own season's, not the parent show's, so the +/-1 year
window is dropped for one - it would exclude the very show being looked for.
That also keeps a miss at the two requests the single-title lookup already
spent. A Plex Discover item additionally skips the title search entirely by
filtering on the plex:// guid its own rating key already is, which costs no
extra request and needs no cloud lookup.

A season 2+ entry only matches when the server really has that season, which
costs one children fetch on a match. Only a season both TVDB and TMDB agree on
is gated: which provider a library numbers its seasons by is a server setting
no dataset supplies and none of it is inferable from the ids an item exposes,
so a disagreeing reference is left ungated rather than gated on a guess.

The match cache is keyed per source and per entry rather than by canonical id,
which every season of a series shares: all five Mushoku Tensei entries collapse
to imdb:tt13293588, so one season-gated result would have poisoned the rest.

Entries whose Fribb row carries no provider id at all remain unmatched. That is
an upstream mapping gap, not something to guess around with extra lookups.

close #1704
2026-07-29 01:35:35 +02:00
edde746 1165998dae fix(windows): elevate the installer when the install directory is read-only
PrivilegesRequired=lowest makes Inno Setup "always run in non
administrative install mode" — the launching token is irrelevant. So a
copy that ended up in C:\Program Files, which the destination page still
lets an elevated wizard run pick, is registered under HKCU while living
somewhere an ordinary process cannot write. UsePreviousAppDir then aims
every later run straight back at that directory.

WinSparkle launches the downloaded installer with plain ShellExecuteEx
and no verb, so nothing along the in-app update path ever asks for
elevation: the silent installer starts, cannot replace a single file, and
the only way out was to quit Plezy, fetch the installer by hand and pick
"Run as administrator". Inno's own PrivilegesRequiredOverridesAllowed
plus UsePreviousPrivileges does not help here, because it reads the
recorded install mode — which is exactly the non-administrative one that
cannot write.

Decide on write access instead. InitializeSetup probes the registered
install directory and, when it is not writable, relaunches setup through
ShellExec 'runas' pinned to that directory with /ALLUSERS, so the update
lands in place instead of forking a second per-user copy. The relaunch
carries a guard parameter and drops any conflicting mode override, and a
refused UAC prompt now explains itself and points at the releases page
rather than failing mutely. A machine-wide install that takes over a
per-user directory also clears the stale uninstall entry and Start Menu
group that would otherwise list Plezy twice in Apps & Features.

Fresh installs are unchanged: still per-user, still no prompt. Only
commandline is added to PrivilegesRequiredOverridesAllowed, since
allowing dialog would make a silent install with no previous copy stop
for the install-mode question — which is how winget installs.

The script carried two near-identical copies of the whole .iss, one per
architecture shape, so both would have needed this code. Collapse them
into one template parameterised by architecture, add -EmitScriptOnly to
generate the .iss without 7-Zip or Inno Setup, and guard the contract
with check_windows_installer.py so the elevation path, the single-source
AppId and the winget marker cannot rot.

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

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

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

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

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

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

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

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

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

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

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

close #1703
2026-07-28 19:44:04 +02:00
edde746 8d0fe73ced fix(jellyfin): bound the series last-played pass with one shared deadline
The scoped lookups run in sequential batches, and MediaServerHttpClient
applies a per-call timeout to connect and receive separately. A silent
endpoint therefore cost up to two request timeouts per batch, and six
batches of that outlast the single request the scoped form replaced —
the enrichment could hold Continue Watching longer than the query it was
introduced to fix.

Give the pass one deadline instead of a per-batch check. It aborts the
in-flight batch and is also raced client-side, because aborting only asks
the transport to stop and not every client honours abortTrigger. Whatever
phase a lookup is stuck in — silent connect, delayed headers, stalled
body — the pass now ends at the deadline with whatever dates it has.
2026-07-28 18:05:10 +02:00
edde746 126f5e3aa6 fix(jellyfin): auto-select direct-played embedded subtitles
Plezy's device profile declares every subtitle format with
`Method: External`, so Jellyfin answers PlaybackInfo with
`DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded
in a direct-played container. Direct play never fetches those URLs, but
the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed
rows only match a native track loaded from the same URL. No embedded
track could satisfy that, so `selectSubtitleTrack` reported "still
pending" forever: playback started with subtitles off and logged the
five- and thirty-second waits, and the server's default subtitle had to
be picked by hand on every item.

Restrict sidecar identity to the rows an open actually fetched as
sidecars. A row that stays in the container loses `key` and
`usesExternalDelivery` and matches on metadata again; genuine
`IsExternal` files keep theirs, and remuxed or transcoded renditions
still resolve their sidecars by URL.

Also declare every subtitle format Embed-first so a direct-played
container reports embedded delivery in the first place, and make the
pending contract match its purpose on every backend. The
complete-catalog escape is no longer Plex-only, so a Jellyfin row the
native player has not produced keeps the pass pending instead of
committing an unrelated default and retiring the listener that was
waiting for the real track. A source id absent from the catalog no
longer defers a decision that can never change, and the thirty-second
deadline resolves from what has arrived instead of re-deriving the same
deferral and applying nothing.

close #1696
2026-07-28 15:22:23 +02:00
edde746 19542e57f4 fix(jellyfin): scope the continue watching last-played lookup per series
The Next Up shelf dated its rows from one server-wide
`/Items?SortBy=DatePlayed&Recursive=true` scan. Jellyfin 12.0-rc3 builds
that sort key by OR-ing an item's own progress with its alternate
versions' (`ItemId == e.Id || Item.PrimaryVersionId == e.Id`,
jellyfin/jellyfin#17044), which no index can serve, so the user's whole
UserData table is scanned per sorted row. Measured on identical
10,120-item libraries, that scan cost 25ms on 10.10.7 and 5.8-13.3s on
12.0-rc3 while pegging a core, so it blew the call's 10s budget and
starved every other client of the server for tens of seconds. Upstream
fixed the order mapper after rc3 in jellyfin/jellyfin#17422.

Ask each pending series for its own newest played episode instead:
`ParentId` bounds the sort input to that series, and the same 21 series
now resolve in 1.5s against the rc3 server with byte-identical dates.
The lookups run four at a time under a shared wall-clock budget and a
short per-request timeout, so a silent endpoint costs less than the one
default-budget request this replaced, and a `count: null` shelf can no
longer fan out one request per started series. Endpoint failover stays
off so a slow enrichment row cannot move the client off a working
endpoint.

close #1699
2026-07-28 11:39:58 +02:00
edde746 3b019c8fe2 fix(artwork): show square background art on portrait heroes
Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.

Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.

close #1700
2026-07-28 10:31:18 +02:00
edde746 9a0e96114f feat(explore): search the active catalog source from the Explore page
Explore only reached search through an app-bar icon that pushed a separate
screen. Touch and pointer builds now carry the field inline under the app
bar: results replace the shelves while the query is non-empty and the
shelves return when it clears. TV keeps pushing CatalogSearchScreen, since
a text field cannot share the spotlight scaffold with the bottom-pinned
browse rail and the on-screen keyboard.

Pull-to-refresh and the toolbar refresh action re-run the live query
instead of reloading hidden rows, and switching catalog source re-runs the
query against the new source rather than leaving the previous source's
results under its name.
2026-07-28 06:14:03 +02:00
edde746 82d6c5d555 fix(explore): render Plex Discover home shelves
Plex Explore showed only the Watchlist row. `/hubs/sections/watchlist`
answers with placeholder hubs — every entry carries `placeholder: true`,
`size: 0` and no `Metadata` — so `fetchHubs` mapped each one to an empty
page and dropped all of them. That is true no matter what the profile has
watchlisted; the shelves never rendered.

Read `/hubs/sections/home` instead, the section Plex's own web client
renders on its Home > Trending tab, and hydrate each placeholder from its
own key (six at a time). `directory` shelves list browse categories and
`clip` shelves list trailers, neither of which becomes a catalog item, so
they are skipped before spending a request. A shelf that fails degrades to
the ones that succeeded; a pass where every shelf failed still throws.

Discover ignores container offsets on hub keys and truncates with `limit`
instead, so a hub is one page: View All takes the whole shelf in a single
request rather than replaying page one, and hub requests drop `Media` and
`Image` elements the catalog layer never reads.
2026-07-28 05:26:37 +02:00
edde746 53535e1678 fix(jellyfin): percent-encode the MediaBrowser auth header
Since real device names started reaching the header, an accented one
made login impossible: dart:io refuses header values above 0x7F, and
CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which
Kestrel rejects as a malformed request with 400 before Jellyfin routes
POST /Users/AuthenticateByName.

Encode every field the way the official Jellyfin SDK does; the server
already reverses it with WebUtility.UrlDecode, so the wire value stays
pure ASCII while the device list shows the real name. Quotes, commas
and `=` no longer need stripping either. sanitizeHeaderValue, which
still guards the Plex headers, now folds Latin letters to their base
form instead of emitting bytes no transport accepts.

close #1685
2026-07-28 05:07:00 +02:00
edde746 31b3689740 fix(settings): list external players only when they are installed
Detection now runs off the UI isolate and covers every platform where
the answer can be trusted.

Availability was a plain platform check, so Linux always listed VLC, mpv
and Celluloid, macOS always listed VLC and IINA, and Windows always
listed VLC and PotPlayer whether or not any of them existed. Each player
now has a detector that asks exactly the question its launcher asks:
`sh -c 'command -v'` for PATH launches so the kernel performs the
executable check, NSWorkspace/Launch Services for `open -a`, `where.exe`
plus the concrete install paths for Windows VLC, and the registered URL
handler for PotPlayer and the iOS players.

Detection is asynchronous and memoised behind KnownPlayers.probe rather
than a Process.runSync in a static initialiser, which forked three
shells on the UI isolate during ExternalPlayerScreen.build. It is
prewarmed from startup, fails open when a probe throws, and keeps the
selected player listed when a detector misses it so a false negative
cannot leave the list with nothing selected.

iOS and tvOS gained LSApplicationQueriesSchemes entries for vlc and
infuse. Without them canOpenURL returns false for both schemes, so
_launchUrlScheme was already refusing to hand off to either player.

Android keeps the platform check: package visibility needs native
declarations, and a wrong answer there hides a working player.
2026-07-28 04:38:33 +02:00
aldobarrandedde746 9cd486f5a2 Guard linux is available around actually installed linux commands. 2026-07-28 04:38:33 +02:00
edde746 1be982f43d fix(watch-together): re-host an abandoned room code instead of joining it
A room whose peers have all left is a code nobody is using, but the relay
kept it bound to the creator's reconnect capability and rejected every
other create with room_exists. The app compounded it: enterRoom only
promoted to host on room_not_found, so tapping a recent code landed the
user in the retained room as a guest of a host that was never coming
back, until the cleanup sweep finally dropped the room.

Create now replaces a room with no connected peers, and enterRoom hosts
the code when its probe join finds an empty room. An occupied room still
rejects create, including from its previous owner, and a host that is
merely disconnected still reclaims its peer ID through join with the
matching token.
2026-07-28 03:30:32 +02:00
edde746 314fec5383 fix(relay): mint five-character log ids again
A 25-character upload capability is unreadable over the phone or in a
support thread, which is the only way these ids are ever exchanged.
Lookups stay bounded by the per-source failed-lookup limiter and the
three-day expiry, and ids minted at the longer shape are retired on the
next startup because they no longer match the store's filename shape.
2026-07-28 03:30:32 +02:00
github-actions[bot] fd136f55f9 chore: update cask to 2.10.0 2026-07-27 23:11:05 +00:00
github-actions[bot] e362c0985f chore: bump version to 2.10.0 2026-07-27 22:18:10 +00:00
edde746 fa4d2a4991 test(android): scan every fixed endpoint source for certificate drift
The scan omitted six files the config itself cites, so jsdelivr.net,
api.github.com and image.tmdb.org were listed as system-only while no scanned
source referenced them; changing those hosts would have fallen through to the
base config and its user certificate authorities undetected.

Also assert the reverse direction, so a domain no scanned source produces
fails instead of silently losing its guard.
2026-07-27 19:02:17 +02:00
edde746 d5f7c5d7ac fix(player): skip the deferred track pass when its switch is superseded
Persisting the deferred choice suspends, so the source switch can be
superseded before the pass is armed. Return early when the continuation is
stale, and refuse to arm a disposed or inactive TrackManager at all.

The per-callback generation checks only stopped the work; the subscription
and the five-second timer were still allocated on a manager whose dispose had
already run, so nothing would ever cancel them.
2026-07-27 17:51:07 +02:00
edde746 841d336cb8 fix(downloads): rederive show rule download links before cleanup
Return initialized show and season rules from the backfill query so their
coverage is recomputed from download ancestry. Rule execution links only the
unwatched episodes it inspected, so the cached flag let a sibling list
cleanup delete episodes the show rule covers.
2026-07-27 17:44:52 +02:00
edde746 ea7dfe6e1e fix(website): rest the review strip at its first card and name the ratings
Add the scroll padding the inset scroller needs so mandatory snapping has a
valid position at scroll origin, and give the star groups an image role so
their label is exposed now that the icons are hidden.
2026-07-27 17:44:52 +02:00
edde746 0ed0ebf22a fix(tvos): derive the RunnerTests roster from the test directory
Read the sources from disk instead of a hand-maintained allow-list that the
script uses destructively, and add a guard that fails when the project and
directory disagree. FlutterNativeTextInputTests.mm was the second test the
list would have silently unwired.
2026-07-27 17:44:52 +02:00
edde746 7e00de3ae9 fix(tvos): answer the Atmos probe start call exactly once
Hold the pending result in a one-shot latch fired by every terminal path,
including cancellation and deallocation. Stopping a probe mid-download
released the only strong reference and left the method call unanswered.
2026-07-27 17:44:52 +02:00
edde746 ad3af474e9 fix(android): emit playback-restart after every seek
Separate the playback-restart signal from the one-shot decoder-hang latch. A
seek flushes the codec without re-initializing it, so the claimed latch
swallowed the post-seek first frame and Watch Together guests sat in
correcting for the full settle timeout.
2026-07-27 17:44:52 +02:00
edde746 088501513a fix(catalog): keep the watchlist action focusable while membership loads
Keep the action enabled and let a press retry the snapshot, as the media
detail action bar already does. A disabled sole action left the detail screen
with no initial D-pad focus on TV.
2026-07-27 17:44:52 +02:00
edde746 c64e6fc519 fix(artwork): stop memoizing posters that merely have no client yet
Distinguish an unresolvable URL from a failed load at the error-widget
boundary. A transiently null media client during a profile switch or
reconnect marked the primary poster dead in a process-global set, pinning the
item to fallback artwork for the rest of the session.
2026-07-27 17:44:52 +02:00
edde746 5f77da93d1 fix(android): keep fixed endpoints on system certificate authorities
Scope user-installed CA trust to the user-entered server hosts that need it
and pin the hard-coded first-party hosts to system anchors. The base config
applied user CAs to every host, including plex.tv token exchange and the
OAuth proxy.
2026-07-27 17:44:52 +02:00
edde746 6b87b6551e fix(livetv): keep the favorites filter narrow while favorites reload
Only the load that commits a favorites set writes the loaded flag, so a
refresh keeps the previous set authoritative. Clearing it up front widened
the guide to the full lineup for the whole round-trip and moved the D-pad
cursor when it collapsed back.
2026-07-27 17:44:52 +02:00
edde746 e3cdc2039e fix(player): exit the player when a Watch Together leave fails
Log and continue instead of letting the relay release abort the back handler,
matching the session screen and overlay. A guest pressing back with an
unreachable relay stayed in the player.
2026-07-27 17:44:52 +02:00
edde746 f695f7b192 fix(settings): export string-list preferences after a cold start
Match the tolerant list predicate the import path already uses. The platform
preference cache returns List<Object?> after a restart, so the exact
List<String> pattern silently dropped tracker library filter ids and a
restored profile resumed scrobbling libraries the user had excluded.
2026-07-27 17:44:52 +02:00
edde746 c45113fefe fix(downloads): report accurate status through repair and storage exhaustion
Supplementary repair runs over downloads whose video is already complete, so
it no longer emits a downloading transition, and artwork updates carry the
row's real status instead of asserting downloading. Previously a reconnect
left completed downloads stuck at "downloading 0%" until the next DB read.

Storage exhaustion fails every active row in one transaction, so
failActiveDownloadsForStorageFull now returns the affected keys and each one
gets a failed event; only the triggering key was announced before.

The post-recovery database open also closes its handle before rethrowing. A
failing storage-full write abandoned a drift background isolate and its
SQLite handles on every retry.
2026-07-27 17:44:52 +02:00
edde746 b202c62641 fix(jellyfin): keep unreachable endpoints when saving a connection
Persist every user-entered URL that is not positively known to belong to a
different server, matching reconcilePreviouslyStoredBaseUrls. Requiring a
successful identity probe deleted a stored LAN endpoint whenever the box was
asleep or the user saved from outside the network.
2026-07-27 17:44:52 +02:00
edde746 db593e1255 fix(player): keep an explicit transcode subtitle choice through the deferred pass
Persist the choice before arming the deferred selection pass. The screen
callback routes to onSubtitleTrackSelectedByUser, which invalidates the
pending selection, so arming first retired the very listener that applies
the choice once mpv discovers the sidecar.

The existing test stubbed the persist callback and so could not observe the
invalidation; it now routes through the manager like production does.
2026-07-27 17:44:51 +02:00
edde746 41c6389cd4 test(e2e): guard the sheet, track, locale, and TV settings behaviour
Four regression flows, each run on a Pixel 7 and, where relevant, a real
Android TV box.

Discriminating — the baseline fails, HEAD passes:

`07_sheet_back_dismiss` pins both halves of the hosted-sheet fix on touch: the
barrier removes the rows behind it from the semantics tree, and one Back closes
only the sheet while Settings stays the current route, including the nested
per-library options page. It fails on c48cbf70, the commit before 8e1904dd.
`03_tv_library_focus` gains the same occlusion assertion for the TV sort sheet
and fails there too.

`08_track_choice_survives_pending_pass` picks a non-default audio and subtitle
track, lets playback outlive the automatic pass's 5s attempt and 25s deadline,
and asserts the choice is still selected. On a56b9a3d the audio reverts to the
container's default. Both new flows onboard from a cleared install: the TV
regressions in the same group leave "Force TV mode" enabled, and a remembered
track selection would pre-select the rows under test.

Coverage without a comparable baseline:

`09_language_picker_locales` asserts the four new endonyms, switches to Turkish,
reads root navigation labels from the generated locale, and restores English.
The locales do not exist before 7677d159/100d7729, so there is nothing to fail
against — this is forward coverage, not a reproduction.

`10_tv_settings_navigation` runs on real Android TV hardware, which no existing
flow covers: the rail layout a device reports on its own, the TV dialog path for
Manage Libraries, the Apple-only Atmos gate staying closed on Android, and the
D-pad-only route to the number spinner's accessibility labels, which a single
tap would hide. It passes on either side of the range and is verified against
both an empty server and one with a resume position, since rail order shifts
with that. It does not assert the 15b54e2e row density: that change is invisible
to a semantics-tree driver, and a pixel `height` assertion would only hold for
one DPR. It registers under a new `android-tv-device` group no workflow
dispatches.

No TV playback flow is included. Entering content on the TV Recommended view has
no stable anchor: the accessibility `focused` flag sits on the hero backdrop
rather than the rail card, and a resume position anywhere in the library pushes
"Recently Added" below the fold and out of the semantics tree entirely. Search
is not a way around it either — inputText does not reach the TV search field.
Covering TV playback needs a testID on the rail card, not a cleverer selector.

Also repairs three assertions that could never fail:

`03_tv_library_focus` gated the sort sheet closing on `notVisible: "Sort by"`,
but the header renders "Sort By" and Maestro selectors are case-sensitive
regexes, so the wait returned immediately and the next D-pad press landed in the
sheet's close animation.

`open_codec_sample` matched a card's watch state as `watched|unwatched` only. A
codec sample keeps a resume position once any earlier flow has played it, so the
row announces "N percent watched" and the subflow stopped finding it on a
fixture container that outlives one suite.

`06_playback_recovery` tapped "Zulu Zone" out of the Recently Added rail, but
every seeded alphabet title shares one dateadded, so which of them the rail
returns is a tie-break. The flow only needs some playable movie.
2026-07-27 13:31:51 +02:00
edde746 bae6055123 fix(macos): drop the dangling MpvMetalLayerTests reference
The Xcode project wired macos/RunnerTests/MpvMetalLayerTests.swift into
the RunnerTests target, but the file was never committed alongside it,
so the macOS test target failed to build from a clean checkout:

  error: Build input file cannot be found: .../MpvMetalLayerTests.swift
         (in target 'RunnerTests' from project 'Runner')

Remove the reference. The test it belonged to is parked on
wip/macos-drawable-size-sync together with the change it covers.
2026-07-27 05:27:57 +02:00
edde746 3515eaf9b4 chore(mpv): bump MPVKit to 1.0.16
Brings in the AVFoundation audio output's bounded PCM lookahead, which
is compiled in on macOS only. AVSampleBufferAudioRenderer holds roughly
1.7s of audio there, and mpv multiplies --volume into the samples as it
hands them over, so a volume change stayed inaudible until that backlog
drained. The bound cuts the queue to about 450ms, measured; tvOS and
iOS preprocess to the source they had before the patch, so the deep
buffering their AirPlay path relies on is untouched.

Also carries the Dolby-conformant compressed EAC3 sink from 1.0.15.
2026-07-27 04:57:13 +02:00
edde746 214eeb8aec chore: bump version 2026-07-27 04:57:13 +02:00
edde746 41a2e996e1 perf(test): scale test concurrency and stop re-onboarding every Maestro flow
The Dart suite spent 77% of its cost compiling one isolate per test file
while `flutter test` used half the cores, and every Maestro flow replayed
a full Jellyfin onboarding before its first real assertion.

- Add scripts/run_tests.sh, which runs `flutter test` with -j set to the
  cores the process may actually use instead of the ncpu/2 default.
  Measured on 8 cores: 190s -> 136s; -j 12 regresses to 165s, so it scales
  to the core count rather than hard-coding one. CI and CONTRIBUTING use it.
  A cgroup v2 quota, a cgroup v1 quota, and the cpuset/affinity nproc
  reports can each be the binding limit independently, so the detector
  takes the smallest; trusting whichever it found first would oversubscribe
  4x on a container holding an 8-CPU quota while pinned to 2. Covered by
  scripts/test_run_tests.py, which the ci_guard_checks.sh glob picks up.
- Add .maestro/subflows/ensure_onboarded.yaml: cold-start the app and only
  onboard when no session is stored. Flows that just need a signed-in Home
  use it; 02_onboarding_home, 08_logout, 09_download_offline_playback and
  the profile regressions keep clearing state. 59s -> 16s per flow.
- Guard onboarding's two optional taps behind visibility checks. A missed
  `optional: true` tap still runs the full element search, costing 3.0s
  and 7.8s per onboarding to find nothing.
- Disable device animation scales in run_maestro.py, restored by the
  existing cleanup path. CI's emulator got this from the runner flag;
  physical devices never did.
- Shorten the watch_together setup-timeout replacement from 500ms to the
  10ms the same file already proves sufficient, and shorten the retry
  backoff at the one site that missed it: 8.04s -> 1.59s of execution.
- Make the LAN discovery waits deadline-based and resend the beacon while
  polling. Loopback UDP drops datagrams under load, which timed out a
  wait that could never be satisfied; this was the suite's one flaky test.
- Fix 08_logout, which searched for "Logout" and "Are you sure you want to
  logout?" after both strings became "Log out". The flow had been failing
  and aborting the suite before 09 ever ran.

flutter test 190s -> 131s. Maestro's Android suite 621s -> 385s across the
eight flows the baseline reached, and now runs all nine green.
2026-07-27 03:59:27 +02:00
edde746 8e1904ddee fix(sheets): let system back dismiss a hosted sheet on touch platforms
Back left the Manage Libraries sheet open on Android with no way to
dismiss it. The host answered the platform pop with
`BackKeyCoordinator.consumeIfHandled()`, which dedups the focused key
path against the platform pop. Only TV routes one Back through both;
touch platforms never deliver Back to the sheet's key handler, verified
on device — a physical Back produced only `popRoute` and no key event.
So there was nothing to dedup against, and the global one-shot marker,
once set by any other handler, silently swallowed the only signal that
closes the sheet.

Scopes the dedup to TV. The TV regression that guarded this never set
the TV override, so it asserted the swallow on every platform and hid
the defect; it now enables the override and a touch counterpart pins the
dismissal.

Also blocks semantics behind the barrier. The barrier takes every
pointer event but left the screen underneath in the semantics tree, so
assistive tech and UI automation still saw rows that could not be
activated — Maestro read an occluded settings row as visible and tapped
its stale coordinates into the sheet. Flutter's own ModalBarrier blocks
semantics for the same reason.

Verified by replaying the failing Maestro sequence
(.maestro/subflows/settings_deep_checks.yaml lines 42-60) on a device:
back dismisses the sheet, Services opens, and back returns to settings.
2026-07-27 01:11:23 +02:00
edde746 c48cbf7059 fix(settings): dismiss Manage Libraries without leaving Settings
On phone layouts main_screen pushes SettingsScreen as its own route, and
that route carried no OverlaySheetHost. showAdaptive could not find one
from the tile's context, so Manage Libraries fell back to
showModalBottomSheet. The sheet also owns a focused Back handler, so a
single Android Back arrived twice — once as a key event, once as
popRoute — and the two route-based paths raced, tearing down Settings
along with the sheet.

Installs one route-local host when no enclosing host exists, and opens
the sheet from a context below it. OverlaySheetHost then holds the route
while a sheet is open and deduplicates the key path, so one Back closes
only the sheet.
2026-07-26 23:05:53 +02:00
edde746 de4ed3cd2e fix(e2e): reach Jellyfin over adb reverse on the API 28 suite
The legacy playback group repeatedly failed to see any Jellyfin server
while the container reported healthy, because the API 28 image routes
the 10.0.2.2 host alias unreliably. Uses the runner's existing reverse
mapping, as the media suite already does on API 35, so the app connects
over 127.0.0.1 instead. No assertion is weakened.
2026-07-26 23:05:53 +02:00
edde746 468d680484 fix(player): keep an explicit track choice through the pending automatic pass
When a source advertises subtitles the native track list has not
produced yet, applyTrackSelectionWhenReady keeps an automatic selection
armed for up to thirty seconds. That late pass re-runs
TrackSelectionService against the stored preferences, so a track the
user picked in the meantime was silently reset. The Maestro codec suites
caught it: the English E-AC3 and Japanese DTS-HD flows select an audio
track, and fifteen seconds later the deadline puts the preferred
language back.

Adds explicit user-selection entry points that retire the pending
automatic selection first, and routes the sheet callbacks and the
remote's cycle shortcuts through them. Subtitles get the same treatment,
because the same pass re-selects them.

Bumping the generation is sufficient: TrackSelectionService re-checks it
in the statement immediately before each select call, and a mutation
already in flight was dispatched before the user's and so lands first.
2026-07-26 23:05:53 +02:00
edde746 78eedd21d3 style: apply dart format to eight drifted sources
`dart format --set-exit-if-changed` over lib and test rewrites these.
The analysis job never reached its formatting step, so the drift went
unnoticed. No behaviour changes.
2026-07-26 23:05:53 +02:00
edde746 e7f97cc090 refactor(music): move the playback stub beside the tests that use it
StubMusicPlaybackService is a base for test doubles with no production
caller, so `check-unused-code lib` flagged it and the analysis job
failed. Moves it to test/test_helpers/, where shared fakes belong.
2026-07-26 23:05:53 +02:00
edde746 01d7d523aa test(tv): follow the native text input contract on Apple TV
71735354 made TvTextInputPresentation.automatic use native platform
input for single-line Apple TV fields, and 829d3745 migrated the search
and Add Jellyfin suites to it. These five cases still expected the
Flutter on-screen keyboard widget and failed looking for a panel that no
longer exists.

Drives the native path instead: each case asserts input is live
(readOnly false), raises the keyboard, then keeps its original
regression intent — the first Back deactivates input and is consumed so
it cannot also pop, and only the second Back pops or cancels.
2026-07-26 23:05:53 +02:00
edde746 ef310459e5 fix(i18n): drop unused video-control strings and restore spinner lookups
`clean_translations.py --strict` reported seven unused keys.

Three are genuinely dead: TrackSelectionHelper.getEmptyMessage was
removed as unreachable in 4307c49c, and the sheets that need an empty
state carry their own strings. Removes them from every locale.

The four accessibility keys are false positives. tv_number_spinner
aliased the subtree as `final a11y = ...accessibility`, which the static
scanner cannot follow — its docstring says as much. Binds the documented
`final t = Translations.of(context)` instead and hoists the two labels,
so the semantics stay identical and the scanner sees the chains.
2026-07-26 23:05:53 +02:00
edde746 0c58b2dc55 fix(ci): follow the guard roster and Flutter pin to their current homes
Two workflow guards had drifted from the code they describe, so
`scripts/ci_guard_checks.sh` failed on a clean tree.

The Flutter release-tag pin moved out of build.yml into the shared
setup-flutter-git composite action, but the checker read that action
from a fixed repository path while its test mutated a workflow fixture.
The mutation could not reach the checker, so the rejection test asserted
against an unmodified run. The checker now resolves the action beside
the workflow it is given, and the test materialises a `.github` tree so
the pin is genuinely exercised.

The script-test roster likewise moved into ci_guard_checks.sh, which
discovers `scripts/test_*.py` by glob; the dispatch guard still expected
each one to be named explicitly in ci_checks.sh and ci.yml. It now reads
that glob and checks both aggregates delegate to the shared roster.
2026-07-26 23:05:17 +02:00
edde746 fcda012468 style(android): apply ktlint's wrapping to the player sources
scripts/format_native.sh --check reported eight class-signature,
function-signature and wrapping violations. Applies --fix; no behaviour
changes.
2026-07-26 23:05:17 +02:00
edde746 a893e1859c fix(macos): restore the CocoaPods version the runners regenerate
The lockfile was last written by CocoaPods 1.16.2 while the macOS runner
ships 1.17.0, so `flutter build macos --config-only` ran pod install,
rewrote the marker, and tripped the guard that asserts Flutter
configuration leaves the committed lockfile untouched. ios/Podfile.lock
already records 1.17.0, which is why only macOS failed.
2026-07-26 23:05:16 +02:00
edde746 35f7a12d7c fix(linux): keep the mpv node builder clear of X11's Bool macro
The Linux native reliability job stopped compiling mpv_player.cc: the
node-conversion builder exposed a leaf named `Bool`, and X11's Xlib.h —
reached through epoxy/egl.h -> EGL/eglplatform.h — defines `Bool` as a
macro for `int`, so the declaration was rewritten into nonsense.

Renames that leaf to `Boolean` across the shared walk and all three
builders. The name is the only thing that changes; no conversion
behaviour differs.
2026-07-26 23:05:16 +02:00
edde746andOmc725 100d7729df feat(i18n): add Azerbaijani, Kazakh, and Uzbek locales
Complete the contributed translations against the current English source and
register the locales in the language picker.

Fills the 51 keys the contributions predated, including the whole
downloads.backgroundWarning block that every locale must translate, and
restores the ${count} placeholder the sync-rule confirmation had dropped in all
three files.

Also corrects contributed strings: Azerbaijani "Imkan (Kopyalama)"
(possibility/copying) for resolution and several Turkish spellings, and Kazakh
Latin-script leaks ("Keyinirek", "Subtitr") plus Uzbek loanwords ("бош", "бир",
"муаммо") that do not read as Kazakh.

Consolidates #1689, #1690, and #1691.

Co-authored-by: Omc725 <98108290+Omc725@users.noreply.github.com>
2026-07-26 22:04:58 +02:00
edde746andOmc725 7677d1594c feat(i18n): add Turkish locale
Complete the contributed translation against the current English source and
register Turkish in the language picker.

Fills the 51 keys the contribution predated, including the whole
downloads.backgroundWarning block that every locale must translate, and
corrects a few contributed strings: "Sesi Kıs" (volume down) for mute,
"Disket" (floppy) for disc, "bitiş hızı" (finishing speed) for bitrate, and a
"Kısayol Ayaıla" typo.

Consolidates #1683 and #1688, which contributed byte-identical files.

Co-authored-by: Omc725 <98108290+Omc725@users.noreply.github.com>
2026-07-26 22:04:14 +02:00
edde746 15b54e2ec6 fix(settings): restore the shared compact row size
Settings rows carried their own platform-conditional typography and
density, so on desktop and TV they rendered a 16px title, 14px subtitle
and 80px row while every other row in the app — the Focusable*ListTile
defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12
in 61px.

Drop the overrides instead of re-tuning them: the tile defaults already
encode the app's row style, and the explicit title styles were redundant
under a dense ListTile (they also masked the disabled/selected title
color). settingsOptionTitleStyle now only serves group children that are
not ListTiles, and matches the dense title unconditionally.

SettingsGroup hands its children that same compact density, so the plain
ListTiles used as non-interactive info rows stop standing 11px taller
than their interactive siblings.
2026-07-26 21:04:53 +02:00
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00
edde746 9e8cfbc7dd fix(windows): keep video-child input on the Flutter view
Create the mpv host window with WS_DISABLED so Windows skips the video subtree
when it picks the window that owns a contact and hands the input to the parent
Flutter view instead. Touch over the video never reached Flutter before: mpv's
inner window owns the contact from its own thread, and neither relay worked
from there - Flutter resolves WM_POINTER with GetPointerInfo, which only
answers for a message the calling thread retrieved, and the system discards a
cross-thread pointer send outright. WS_EX_TRANSPARENT and an HTTRANSPARENT
WM_NCHITTEST reply are both same-thread-only, so disabling the subtree is the
one hit-test opt-out that applies across threads. The mouse relay stays for
input that still reaches mpv's window.

Repair the contract test that covers this. It drove its pointer assertions with
cross-thread sends that Windows drops, so every touch assertion had been dead
since it was added and the suite fails "primary touch must press once" on main.
Relaying those sends through the window's own thread runs all eight tests, and
injected mouse and touch presses over the disabled host now assert delivery to
the parent view; removing WS_DISABLED fails the suite.

close #1556
2026-07-26 19:04:36 +02:00
edde746 7e05686a7c fix(windows): restore x64 and arm64 builds 2026-07-26 15:32:02 +02:00
edde746 5ee3120a97 fix(media): distinguish leaf and aggregate watch state
close #1610
2026-07-26 14:58:38 +02:00
edde746 60cc983471 feat(downloads): remove playlist sync downloads together
close #1656
2026-07-26 14:58:38 +02:00
edde746 1fea9ef6e3 fix(search): recover omitted Plex media categories
close #1598
2026-07-26 14:58:38 +02:00
edde746 8ef977d890 fix(windows): route touch input over video 2026-07-26 14:58:37 +02:00
edde746 ecc55d9b36 fix(android): retry ExoPlayer after decoder loss
close #1540
2026-07-26 14:58:21 +02:00
edde746 f5661c766e perf(tv): tint inactive row artwork 2026-07-26 14:58:21 +02:00
edde746 829d3745a1 fix(tvos): restore native text input navigation 2026-07-26 07:08:01 +02:00
edde746 71735354b9 fix(tvos): unify remote and text input ownership 2026-07-26 07:08:01 +02:00
edde746 13179f08cd refactor: move the Plex switch-token parser in with the Plex models
user_switch_response.dart was left holding a single 13-line function after
UserSwitchResponse's decorative fields were dropped, so the filename no
longer described its contents and it sat at lib/models/ root while every
other Plex model lives in lib/models/plex/.

Renamed to lib/models/plex/plex_switch_response.dart, with the test moved
alongside the other plex_*_test.dart files. The parser stays public so the
#1488 drift characterization tests keep exercising it directly.
2026-07-26 06:09:50 +02:00
edde746 eb3ed45af1 refactor: share future coalescing, Plex client access, and event helpers
Deduplicates the hand-rolled coalescing/caching maps, the Plex client cast,
the missing-serverId event guard and the progress-failure backoff, and drops
the MusicPlaybackService availability gate, which could never fail in
production.
2026-07-26 06:09:50 +02:00
edde746 9429a76acc refactor: share search field, auth dialog, and list-download plumbing
The search screens, the out-of-band auth dialogs, the live TV guide and the
list-download paths each carried their own copy of the same shell. Extracts
SearchInputField and PendingAuthDialog and routes the duplicated download
and guide helpers through one implementation.
2026-07-26 06:09:49 +02:00
edde746 4eaf4423a1 refactor: share focus chrome and simplify the TV picker and browse paths
Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
2026-07-26 06:09:49 +02:00
edde746 c68ffe9ed0 refactor: share the toolbar scrim and dedupe playback and download paths
Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds
duplicated request/retry handling in the media server HTTP client, and
collapses the parallel playback-source, download-manager and live TV helper
paths into shared implementations.
2026-07-26 06:09:49 +02:00
edde746 83f4e2a263 refactor: fold single-use helpers into their call sites
Collapses indirection layers and one-caller abstractions across the video
player, shortcut dispatch, shader loading and context-menu code, including
the VideoPIPManager pass-through over PipService.
2026-07-26 06:09:49 +02:00
edde746 7416327d4b refactor: unify tracker slots, Seerr detail models, and queue launches
- Merge SeerrMovieDetails/SeerrTvDetails into one SeerrDetails model and
  route both detail endpoints through a single request helper.
- Replace the three parallel tracker session/store/rebind-generation
  triples in TrackersProvider with a _TrackerSlot record plus one _rebind
  path.
- Fold the three JellyfinSequentialLauncher entry points onto a shared
  _launchLocalQueue helper that owns loading, abort, shuffle and publish;
  each caller now supplies only its fetch.
2026-07-26 06:09:49 +02:00
edde746 316a69a1de refactor: share the paginated grid tab, cached remote store, and tile focus
- PaginatedCardGridTabState: the collections and playlists tabs were 95%
  identical; they now supply only pageSize/fetchPage/idOf instead of each
  duplicating the grid, memo, inflation budget and focus wiring.
- EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share
  one download/cache/isolate-parse/conditional-GET lifecycle.
- FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose
  instead of requiring every caller to forward three lifecycle hooks.

Also drops unused ServerCapabilities entries and dead code in
focusable_list_tile and music/track_row.
2026-07-26 06:09:48 +02:00
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00
edde746 61344f7862 test: extract shared fixtures and scaffolds
Collapse duplicated setup across the suite into six shared helpers under
test/test_helpers/ and rewrite the 28 suites that were open-coding it:

  http_fixtures.dart         jsonResponse() for http.Response JSON stubs
  library_tab_scaffold.dart  pumps library tabs under their required ancestors
  multi_server_fixtures.dart MultiServerProvider wiring for widget tests
  playback_report_fakes.dart PlaybackReportCall + fake report sinks
  profile_stack.dart         production-shaped profile dependency graph
  theme.dart                 testMonoTokens for fast-settling widget tests

Net -1245 lines with no change in coverage or assertions.
2026-07-26 06:09:47 +02:00
edde746 04d8070fd4 refactor: pin the look-alike code paths that must not be merged
Several pairs of near-identical code paths differ in one load-bearing
line. Each site now carries a comment naming the invariant that forces it
apart, backed by a characterization test so a future deduplication fails
loudly instead of silently changing behaviour.

Pinned: focusable wrapper vs. chip D-pad activation policy, profile
connection cleanup's raw-id vs. ServerId-typed server projections, live TV
tab loaders, video player display matching and playback service wiring,
track selection container ordering, tracker HTTP client status ladder, and
the MediaServerHttpClient shutdown/cancellation contract versus
ManagedHttpClient's closing guard.

New tests:
  test/focus/dpad_activation_policy_test.dart
  test/services/track_selection_container_ordinal_test.dart
  test/services/trackers/tracker_status_ladder_test.dart
  test/utils/media_server_http_client_shutdown_test.dart
2026-07-26 06:09:47 +02:00
edde746 4307c49cd2 refactor: remove unreachable code paths and unused members
Drops dead code across services, models, utils and widgets, including the
connection auth service, which had no implementer, and the Live TV DVR
provisioning models, which had no caller.

Tests that only covered deleted behaviour are removed or trimmed. No
behaviour change.
2026-07-26 06:09:47 +02:00
edde746 fcaa81bf3c fix(artwork): preserve clear-logo aspect ratios 2026-07-26 04:34:08 +02:00
edde746 ef183437d4 fix(jellyfin): keep login identity metadata valid 2026-07-26 04:24:56 +02:00
edde746 7c6eaac2d0 fix(android): render double-NUL ASS subtitles
close #1681
2026-07-26 04:24:56 +02:00
edde746 3b1e71b3fa feat(player): support playback speeds up to 8x
close #1545
2026-07-26 04:24:55 +02:00
edde746 2a25f21e27 fix(plex): stop Live TV caption burn-in
close #1590
2026-07-26 04:24:55 +02:00
edde746 e251273322 feat(downloads): warn about Android background restrictions 2026-07-26 04:24:55 +02:00
edde746 1b3c74550f fix(tvos): make remote input lifecycle engine-owned 2026-07-26 03:41:19 +02:00
edde746andEvan J c9543d4af0 feat(media): show directors in detail info rows
Co-authored-by: Evan J <42357644+ejach@users.noreply.github.com>
2026-07-25 22:40:39 +02:00
edde746 12b826faff fix(media): parse responses before caching 2026-07-25 17:38:09 +02:00
edde746 516bd69c19 fix(server): bypass debounce for terminal mutations 2026-07-25 17:38:09 +02:00
edde746 005a56db03 fix(tvos): preserve MPV contract test wiring 2026-07-25 17:38:09 +02:00
edde746 1ae58f676b fix(windows): recover failed display handoffs 2026-07-25 17:38:09 +02:00
edde746 6710c87892 fix(android): accept denied audio-focus resumes 2026-07-25 17:38:09 +02:00
edde746 96fd47a160 fix(libraries): filter content-type-less Jellyfin roots 2026-07-25 17:38:09 +02:00
edde746 c1cfb9609e perf(livetv): virtualize guide rendering 2026-07-25 16:47:10 +02:00
edde746 cd6716df47 perf(tv): trim media card focus semantics 2026-07-25 16:47:04 +02:00
edde746 5c221468d6 perf(android): use Skia on 32-bit Android TVs 2026-07-25 16:45:59 +02:00
edde746 77da82b648 fix(ci): make cross-platform checks deterministic 2026-07-25 16:17:25 +02:00
edde746 39ddfd2bd9 fix(website): restore contracts and focus indicators 2026-07-25 16:17:05 +02:00
edde746 0717009ade fix(server): make room persistence transactional 2026-07-25 16:16:48 +02:00
edde746 f8f366a513 fix(native): harden player teardown and shelf recovery 2026-07-25 16:16:24 +02:00
edde746 4af77f4696 fix(app): restore playback and state lifecycle contracts 2026-07-25 16:16:04 +02:00
edde746 73f4a3e055 fix(tv): focus late Continue Watching on startup
close #1602
2026-07-25 10:54:43 +02:00
edde746 0959cd3040 fix(libraries): exclude episodes from mixed roots
close #1675
2026-07-25 09:13:00 +02:00
edde746 9a6f48a4cb fix(android): use Skia on 32-bit TCL TVs 2026-07-25 08:33:06 +02:00
edde746 fb27621c75 perf(tv): cut semantics work during card navigation 2026-07-25 08:13:09 +02:00
edde746 2b3853a882 fix(player): preserve transcoded subtitles at high speed
close #1622
2026-07-25 04:21:37 +02:00
edde746 0643787fbe fix(android): prevent ghost playback after autoplay failures
close #1673
2026-07-25 04:12:54 +02:00
edde746 b7d5922b0a fix(ci): repair Windows native checks 2026-07-25 00:22:23 +02:00
edde746 c7b9eec087 fix(player): preserve subtitles across episode changes
close #1635
2026-07-24 21:40:22 +02:00
edde746 db7bc17c49 chore(website): remove tests and update SvelteKit 2026-07-24 20:30:45 +02:00
edde746 d1cec00b51 fix(ci): repair native and Maestro checks 2026-07-24 20:30:45 +02:00
edde746 102ef46d00 feat(jellyfin): lazily page recently added hubs
close #1611
2026-07-24 20:29:21 +02:00
edde746 d6b24c3e07 fix(android): prevent Dolby Vision seek crashes 2026-07-24 19:10:38 +02:00
edde746 4cc9a35b2e fix(android): use OpenGLES on 32-bit TCL TVs 2026-07-24 12:22:58 +02:00
edde746 40833e65ec fix(ci): align toolchains and isolate platform tests 2026-07-24 10:06:50 +02:00
edde746 6c03094ef1 fix(settings): label image-only cache clearing accurately 2026-07-24 08:09:14 +02:00
edde746 269bb7a322 fix(playback): recover episode navigation without Plex queues 2026-07-24 08:08:10 +02:00
edde746 54273ab09c fix(downloads): recover from full storage
close #1655
2026-07-24 07:48:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ef0524485a build(deps): bump softprops/action-gh-release (#1665)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from c12583777ecdfd3be55c69cf75464299dc01057e to 3d0d9888cb7fd7b750713d6e236d1fcb99157228.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/c12583777ecdfd3be55c69cf75464299dc01057e...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3d0d9888cb7fd7b750713d6e236d1fcb99157228
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:40 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
071b632c93 build(deps): bump awalsh128/cache-apt-pkgs-action (#1664)
Bumps [awalsh128/cache-apt-pkgs-action](https://github.com/awalsh128/cache-apt-pkgs-action) from 2153a1bf62a0ad7830c24ccdf1d588bedc2834a7 to 553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9.
- [Release notes](https://github.com/awalsh128/cache-apt-pkgs-action/releases)
- [Commits](https://github.com/awalsh128/cache-apt-pkgs-action/compare/2153a1bf62a0ad7830c24ccdf1d588bedc2834a7...553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9)

---
updated-dependencies:
- dependency-name: awalsh128/cache-apt-pkgs-action
  dependency-version: 553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:38 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
62330413ee build(deps): bump actions/attest-build-provenance (#1663)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e to 0f67c3f4856b2e3261c31976d6725780e5e4c373.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e...0f67c3f4856b2e3261c31976d6725780e5e4c373)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 0f67c3f4856b2e3261c31976d6725780e5e4c373
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa66f9a15d build(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#1662)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:31 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7ed2049a01 build(deps): bump actions/setup-java from 5.5.0 to 5.6.0 (#1661)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/0f481fcb613427c0f801b606911222b5b6f3083a...03ad4de0992f5dab5e18fcb136590ce7c4a0ac95)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:29 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a961a5a997 build(deps): bump reactivecircus/android-emulator-runner (#1660)
Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 4c44018e59b437e86cdfc41da381398f93ed8808 to a421e43855164a8197daf9d8d40fe71c6996bb0d.
- [Release notes](https://github.com/reactivecircus/android-emulator-runner/releases)
- [Changelog](https://github.com/ReactiveCircus/android-emulator-runner/blob/main/CHANGELOG.md)
- [Commits](https://github.com/reactivecircus/android-emulator-runner/compare/4c44018e59b437e86cdfc41da381398f93ed8808...a421e43855164a8197daf9d8d40fe71c6996bb0d)

---
updated-dependencies:
- dependency-name: reactivecircus/android-emulator-runner
  dependency-version: a421e43855164a8197daf9d8d40fe71c6996bb0d
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:38:26 +02:00
edde746andjunyou1998 ad7ef112fc feat(i18n): add Hungarian and Traditional Chinese locales
Complete and revise every shipped locale against the current English source, preserve locale-specific plurals, and map script-specific Chinese locales through device, Intl, duration, and Plex boundaries.

Co-authored-by: emgeje <mgj@mgj.hu>

Co-authored-by: junyou1998 <junyou1998@gmail.com>
2026-07-24 07:30:43 +02:00
edde746 0c6dab01b3 build(deps): upgrade Flutter package dependencies 2026-07-24 03:56:40 +02:00
edde746 3c2a353d32 build(android): upgrade Gradle and Kotlin toolchain 2026-07-24 03:56:40 +02:00
edde746 54a002f9b7 fix(android): consolidate native media decoders 2026-07-24 03:56:40 +02:00
edde746 fb45ff44f3 fix(android): persist startup and runtime exit diagnostics 2026-07-24 03:56:40 +02:00
edde746 9f2e050797 fix(native): bound cross-platform lifecycle ownership 2026-07-24 03:56:40 +02:00
edde746 09656fa4d3 fix(supply-chain): verify CI and production inputs
Pin external actions, images, toolchains, native archives, and tvOS engine artifacts; enforce fail-closed CI checks and keep website privacy disclosures aligned with shipped behavior.
2026-07-24 03:56:40 +02:00
edde746 b41fb4fe75 fix(ui): harden settings focus and semantics 2026-07-24 03:46:50 +02:00
edde746 f8bfecf57d fix(media): serialize browsing and metadata mutations 2026-07-24 03:46:50 +02:00
edde746 43a8fe020d fix(relay): secure reconnect and room ownership 2026-07-24 03:46:50 +02:00
edde746 e0bf66eea8 fix(runtime): harden application service boundaries 2026-07-24 03:46:46 +02:00
edde746 658da37b48 build: make toolchain inputs reproducible 2026-07-24 03:40:06 +02:00
edde746 1d1c301f61 test: stabilize deterministic integration coverage 2026-07-24 03:40:06 +02:00
edde746 d98e85614a fix(settings): match services-page compact row style on desktop 2026-07-20 10:49:44 +02:00
edde746 5fb167898e fix(tv): play episodes from detail rails
close #1608
2026-07-20 10:28:31 +02:00
edde746 0283c3bfff fix(plex): skip malformed subtitle streams
close #1589
2026-07-20 10:28:31 +02:00
edde746 e32fcc2190 feat(explore): add AniList, Simkl, and Plex catalogs 2026-07-20 10:28:31 +02:00
f8cb550be7 feat(tv): add corner spotlight backdrop option (#1628)
New TV-only Appearance toggle that pins the spotlight artwork to the
top-right corner (68% width, 72% height) with the left and bottom edges
feathered into the scaffold background, so the logo, metadata, and
shelves sit on a calm surface instead of the image. Default is off —
the full-bleed spotlight stays exactly as it is.

The artwork keeps its full-screen request size: the corner box only
crops the layout, so transcode URLs and image caches are unchanged and
toggling the setting stays instant.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:06:25 +02:00
edde746 0707d4d9b1 fix(live-tv): stabilize HLS playback 2026-07-18 20:04:09 +02:00
edde746 46dfc7f992 feat(detail): show episode file sizes 2026-07-18 13:32:40 +02:00
edde746 a1b6a89714 fix(player): load subtitle sidecars with media
close #1583
2026-07-17 23:09:07 +02:00
edde746 70cd7e8c67 fix(music): keep now playing state current
close #1600
2026-07-17 16:30:10 +02:00
edde746 5b90c4bca0 fix(images): decode cast cards at the square grid-cell budget
close #1591
2026-07-17 09:04:01 +02:00
edde746 6df8aba097 fix(nav): preserve HTPC escape behavior
Use Flutter's supported desktop application-exit API so the second root Back press closes Plezy on Windows. Preserve fullscreen when video player navigation is enabled while keeping fullscreen-first Escape behavior for normal desktop use.

close #1582
2026-07-16 20:43:10 +02:00
edde746 a244d249bd fix(detail): stop cropping the hero backdrop on wide windows
close #1588
2026-07-16 16:00:30 +02:00
edde746 ac9a5914a2 fix(plex): handle malformed lyrics XML 2026-07-16 14:36:48 +02:00
edde746 b7cd4e5e9c fix(music): preserve Jellyfin collection posters 2026-07-16 14:36:48 +02:00
edde746 ff602f1456 fix(plex): load local lyrics
Closing #1585
2026-07-16 12:56:16 +02:00
edde746 517c3f57ce fix(music): keep collection and playlist artwork square 2026-07-16 12:37:02 +02:00
edde746 d253450e9d fix(plex): show all artist releases
close #1577
2026-07-15 08:49:06 +02:00
edde746 fa901be328 fix(e2e): run emulator suites through Python 2026-07-15 08:25:55 +02:00
edde746 74cae73844 fix(ci): restore Dart formatting gate 2026-07-15 08:25:42 +02:00
edde746 0bcd00bcb6 ci: harden privileged workflow trust boundaries 2026-07-15 07:57:42 +02:00
edde746 54a4b8e414 refactor(mpv): move common code to shared 2026-07-15 07:57:02 +02:00
edde746 87c0547090 ci: harden pull request workflows 2026-07-15 07:15:34 +02:00
edde746 9471e30585 ci(test): consolidate Maestro workflow setup 2026-07-15 06:58:22 +02:00
edde746 ea4bd21977 fix(tv): refresh retained spotlight metadata 2026-07-15 06:40:59 +02:00
edde746 3168f6327f fix(tv): make select activation one-shot 2026-07-15 06:40:47 +02:00
edde746 139cf83507 feat(test): add Maestro end-to-end coverage 2026-07-15 06:40:35 +02:00
edde746 ddb7520ce8 fix(jellyfin): promote redirected server URLs 2026-07-14 23:05:57 +02:00
edde746 abf1027b77 feat(plex): migrate video transcoding to HLS 2026-07-14 18:11:04 +02:00
edde746 9d812fa3cc fix(plex): match legacy agent GUIDs
closing #1566
2026-07-14 18:11:04 +02:00
edde746 8c88385977 feat(jellyfin): cycle media backdrops
close #1568
2026-07-14 07:24:11 +02:00
edde746 30c978719b fix(ci): update actions for Node 24 2026-07-14 07:23:16 +02:00
edde746 12eb9b71b6 fix(ci): disable crashing analyzer plugin 2026-07-14 07:23:16 +02:00
edde746 0cb19a4e30 feat(website): adopt expressive material design 2026-07-14 07:19:46 +02:00
edde746 991aee6c90 fix(ci): resolve analyzer and settings test failures 2026-07-14 01:21:38 +02:00
edde746 cbb0a01c5f fix(release): create untagged build drafts 2026-07-14 00:33:12 +02:00
edde746 d86e820a16 fix(android): trust user-installed CAs 2026-07-14 00:24:54 +02:00
edde746 1bdd76d95a fix(plex): wait for shared server resource tokens 2026-07-14 00:15:46 +02:00
edde746 0a3e9d9e89 build(apple): bump MPVKit to 1.0.13 2026-07-13 23:37:30 +02:00
edde746 a0013323f2 chore(ui): enforce icon consistency 2026-07-13 23:13:53 +02:00
edde746 d5f3e581df fix: eliminate cross-app consistency drift 2026-07-13 23:13:53 +02:00
edde746 ec249d2ed4 fix(jellyfin): repair music library browsing
Fixes #1557
2026-07-13 22:58:12 +02:00
edde746 d6ad6a9506 fix(settings): align mobile option typography 2026-07-13 22:14:27 +02:00
edde746 7400a10c59 fix(release): prevent published asset replacement 2026-07-13 21:48:44 +02:00
edde746 dfeffad044 fix(windows): forward touch input over video 2026-07-13 18:48:58 +02:00
github-actions[bot] b0c2827eb4 chore: update cask to 2.9.1 2026-07-13 15:44:24 +00:00
github-actions[bot] 5267aa35ee chore: bump version to 2.9.1 2026-07-13 15:12:14 +00:00
edde746 40792e2779 fix: restore focus and interaction behavior 2026-07-13 17:00:50 +02:00
edde746 822657972d fix(android): enforce minSdk compatibility 2026-07-13 17:00:50 +02:00
edde746 ae71b7bc64 fix(android): guard API 29 codec query
close #1553
2026-07-13 17:00:49 +02:00
edde746 e15bbed554 test(servers): cover exhaustion reconnection and offline retry loop
Adds a production-wired exhaustion trigger hook, documents the
no-throw invariant behind the fire-and-forget verification probe, and
covers the confirmed-offline reconnect and debounce-driven recovery.
2026-07-13 12:05:23 +02:00
edde746 65e63c206e fix(jellyfin): slim music hub row fields
close #1552

Latest Albums returns MusicAlbum folder dtos, where the browse count/user-data
fields each cost a recursive per-album COUNT query; request it with the slim
album fields + EnableUserData=false under a dedicated latestalbums identifier,
and drop the folder fields and Overview from the played-track rows.
2026-07-13 12:05:23 +02:00
edde746 f104b5ffae fix(jellyfin): prevent false offline loops 2026-07-13 11:30:17 +02:00
edde746 e4db04fa62 fix: align UI focus and sheet behavior 2026-07-13 11:28:32 +02:00
edde746 e6e7d8cdfd test: remove redundant coverage and shorten timers 2026-07-13 02:15:03 +02:00
github-actions[bot] c4e9fa1650 chore: update cask to 2.9.0 2026-07-13 00:01:50 +00:00
edde746 8210b23d31 fix(ci): set explicit release tag 2026-07-13 01:42:41 +02:00
github-actions[bot] bd83c10b9b chore: bump version to 2.9.0 2026-07-12 22:11:51 +00:00
edde746 e03edad824 fix: prevent async state and platform regressions 2026-07-13 00:03:52 +02:00
edde746 26d14f7cb0 fix(seerr): align request button focus 2026-07-12 22:19:42 +02:00
edde746 b9f828ae88 refactor(livetv): reuse standard hub cards 2026-07-12 22:11:30 +02:00
edde746 098a472670 fix(catalog): restore TV focus navigation 2026-07-12 21:42:27 +02:00
edde746 780a1ea180 fix(ci): restore analyzer and unit checks 2026-07-12 19:55:24 +02:00
edde746 917f6a2839 fix: guard lifecycle-bound state updates 2026-07-12 19:00:28 +02:00
edde746 2101f1d7f0 perf: isolate focus and media rebuilds 2026-07-12 19:00:11 +02:00
edde746 a561777456 perf: reduce state notification fan-out 2026-07-12 18:59:58 +02:00
edde746 b4575c9780 fix: preserve live TV state across refreshes 2026-07-12 18:59:42 +02:00
edde746 0ef1e773bb fix: scope navigation callbacks to active owners 2026-07-12 18:59:27 +02:00
edde746 cd88f73f05 fix: serialize async state transitions 2026-07-12 18:59:14 +02:00
edde746 d1c4ae83eb fix(review): resolve post-consolidation regressions 2026-07-12 17:31:16 +02:00
edde746 858952929b fix(deps): refresh and document native dependencies 2026-07-12 17:31:16 +02:00
edde746 b676ef6c56 refactor(android): harden and share player integration 2026-07-12 17:31:15 +02:00
edde746 a9493f4f0e fix(relay): harden lifecycle and protocol handling 2026-07-12 17:31:15 +02:00
edde746 fe8d405a56 fix(i18n): enforce complete localized coverage 2026-07-12 17:31:14 +02:00
edde746 754c54761a refactor(downloads): consolidate task and metadata flows 2026-07-12 17:31:13 +02:00
edde746 7ecadffdb1 refactor(features): consolidate shared feature primitives 2026-07-12 17:31:13 +02:00
edde746 6506f36843 refactor(app): share client and presentation scaffolds 2026-07-12 17:31:12 +02:00
edde746 97f7508067 refactor(core): consolidate shared app foundations 2026-07-12 17:31:12 +02:00
edde746 a481fc1000 refactor(dialogs): unify text input contract 2026-07-12 08:42:26 +02:00
edde746 b7078448ea refactor(mpv): unify property decoding 2026-07-12 08:42:26 +02:00
edde746 359a6bf02a refactor(live-tv): split optional DVR support 2026-07-12 08:42:25 +02:00
edde746 a5449a1430 refactor(ci): matrix architecture builds 2026-07-12 08:42:25 +02:00
edde746 6182c34448 perf(playlists): bound card inflation 2026-07-12 08:42:25 +02:00
edde746 699e73bcd5 fix(downloads): propagate hierarchical watch state 2026-07-12 08:42:25 +02:00
edde746 352aa04d3e refactor(settings): share preference binding lifecycle 2026-07-12 08:42:25 +02:00
edde746 c81a1f9a97 fix(plex): tolerate malformed activity rows 2026-07-12 08:42:25 +02:00
edde746 34be331024 refactor(media): centralize HDR classification 2026-07-12 08:42:25 +02:00
edde746 ceae98b75f refactor(paging): share continuation loading 2026-07-12 08:42:25 +02:00
edde746 5328865639 refactor(input): share gamepad key simulation 2026-07-12 08:42:24 +02:00
edde746 f78089edf8 refactor(input): share D-pad long press state 2026-07-12 08:42:24 +02:00
edde746 c0d8951677 fix(release): centralize pubspec version parsing 2026-07-12 08:42:24 +02:00
edde746 2e830d3f97 refactor(database): share watch action queries 2026-07-12 08:42:24 +02:00
edde746 649eaa4cce refactor(watch-together): share relay setup 2026-07-12 08:42:24 +02:00
edde746 80344a630f feat(playlists): add picker filtering and TV focus 2026-07-12 08:42:24 +02:00
edde746 2a128777f1 refactor(profiles): centralize Plex token policy 2026-07-12 08:42:24 +02:00
edde746 baf3c41a44 refactor(providers): share coalesced load scheduling 2026-07-12 08:42:24 +02:00
edde746 f7f4e3a992 fix(windows): support structured mpv observations 2026-07-12 08:42:24 +02:00
edde746 6890cd8690 refactor(settings): remove dead shortcut preferences 2026-07-12 08:42:23 +02:00
edde746 c84b8b4792 refactor(downloads): unify deletion and scoped cache handling 2026-07-12 08:42:23 +02:00
edde746 3d36ecf094 fix(mpv): harden native initialization and logging 2026-07-12 08:42:23 +02:00
edde746 eaa60cc2b5 fix(release): resolve package tags once 2026-07-12 08:42:23 +02:00
edde746 de5234b59f refactor(trackers): share username enrichment 2026-07-12 08:42:23 +02:00
edde746 69cf5fb8a1 fix(live-tv): contain retry failures 2026-07-12 08:42:23 +02:00
edde746 a0f153e5ab refactor(symbols): share upload flow 2026-07-12 08:42:23 +02:00
edde746 0135318ded fix(android): include mpv end-file diagnostics 2026-07-12 08:42:22 +02:00
edde746 f4ec6ccf20 fix(settings): guard Atmos diagnostics stop 2026-07-12 08:42:22 +02:00
edde746 5f8d263d63 fix(ci): fail closed on analyzer errors 2026-07-12 08:42:22 +02:00
edde746 242cc8616c feat(settings): expose music quality preset 2026-07-12 08:42:22 +02:00
edde746 2ebc1e250d fix(providers): stop loads after disposal 2026-07-12 08:42:22 +02:00
edde746 5876a602c3 fix(android): surface mpv command failures 2026-07-12 08:42:22 +02:00
edde746 79dabaf125 refactor(watch-together): share host peer IDs 2026-07-12 08:42:22 +02:00
edde746 65d3d8e471 refactor(jellyfin): share auth and time handling 2026-07-12 08:42:22 +02:00
edde746 aa230983d5 refactor(navigation): centralize sidebar focus 2026-07-12 08:42:22 +02:00
edde746 1b9ef3b3b6 fix(discover): avoid progress refresh fanout 2026-07-12 08:42:21 +02:00
edde746 7ed342b345 refactor(watch-together): centralize participant lookup 2026-07-12 08:42:21 +02:00
edde746 29c029f04a refactor(watch-together): share core primitives 2026-07-12 08:42:21 +02:00
edde746 777780926c refactor(paging): share unknown-total sentinel 2026-07-12 08:42:21 +02:00
edde746 f08fceaee0 refactor(json): share scalar adapters 2026-07-12 08:42:21 +02:00
edde746 0ddd0a6c25 refactor(mpv): remove unused seek events 2026-07-12 08:42:21 +02:00
edde746 8a8b4f3a61 refactor: remove unused convenience APIs 2026-07-12 08:42:20 +02:00
edde746 cdec9d1514 refactor(player): centralize chapter traversal 2026-07-12 08:42:20 +02:00
edde746 9da047de70 refactor(cache): centralize non-fatal writes 2026-07-12 08:42:20 +02:00
edde746 18835d92dd refactor(navigation): reuse library icon mapping 2026-07-12 08:42:20 +02:00
edde746 967c25b11d refactor(player): share queue item labels 2026-07-12 08:42:20 +02:00
edde746 877e8880cf refactor(profiles): centralize Plex Home cache codec 2026-07-12 08:42:20 +02:00
edde746 ac9c236b6b refactor(providers): harden server listener state 2026-07-12 08:42:20 +02:00
edde746 df7fa08e40 refactor(focus): centralize focus node ownership 2026-07-12 08:42:20 +02:00
edde746 c58013b818 refactor(downloads): centralize sidecar cleanup 2026-07-12 08:42:20 +02:00
edde746 b925705173 fix(downloads): discard stale profile refreshes 2026-07-12 08:42:19 +02:00
edde746 7a0e838017 fix(profiles): remint empty Plex resource tokens 2026-07-12 08:42:19 +02:00
edde746 bc55aea701 refactor: remove superseded code paths 2026-07-12 08:42:19 +02:00
edde746 dca51a752a refactor(downloads): centralize metadata merging 2026-07-12 08:42:19 +02:00
edde746 0db6eb6f2e fix(downloads): normalize Windows sidecar paths 2026-07-12 08:42:19 +02:00
edde746 d1a67828b6 fix(ci): detect silent tool failures 2026-07-12 08:42:19 +02:00
edde746 048fb77c8c fix(windows): move mpv teardown off platform thread 2026-07-12 08:42:19 +02:00
edde746 a63369c247 fix: close async lifecycle races 2026-07-12 08:42:19 +02:00
edde746 6d94880c2f fix(input): reset long press state safely 2026-07-12 08:42:19 +02:00
edde746 9c6fcbc841 fix(plex): tolerate scalar schema drift 2026-07-12 08:42:18 +02:00
edde746 a809cbbcef fix(plex): surface server failures 2026-07-12 08:42:18 +02:00
edde746 e4b0680158 fix(ci): enforce automation checks 2026-07-12 08:42:18 +02:00
edde746 64db84c3e2 feat(library): default clip libraries to folder grouping
close #966
2026-07-12 03:38:23 +02:00
edde746 4a81246986 build(libass): bump fork to 0.18.3 2026-07-12 00:44:08 +02:00
edde746 a23c7f215c fix(player): tighten staged back navigation 2026-07-11 23:11:25 +02:00
edde746andmoduvoice 5af3a926d7 i18n: complete locale translations
close #1537

Co-authored-by: moduvoice <moduvoicr77@gmail.com>
2026-07-11 22:53:33 +02:00
edde746 2679745941 fix: stop Jellyfin live TV sessions
close #1533
2026-07-11 22:00:05 +02:00
edde746 4443b76145 fix(player): respect visible chrome on back 2026-07-11 17:38:22 +02:00
edde746 3a982c2a7a fix(player): prevent duplicate back actions 2026-07-11 12:59:57 +02:00
edde746 e07172ade4 fix(tvos): enable native EAC3 JOC playback 2026-07-11 12:59:57 +02:00
Ryan ErnestandGitHub c86d40f8dd fix(player): preserve volume when toggling mute (#1532)
* fix(player): preserve volume when toggling mute

Muting now keeps the last volume level memorized. Unmuting restores that level, including amplified values allowed by the configured maximum.

* fix(player): preserve volume for companion remote mute

Route the companion remote through the shared mute transition so it no longer overwrites the persisted volume or restores to 100%. Also correct the Dartdoc formatting for the transition record fields.
2026-07-11 12:46:58 +02:00
edde746 d86e328169 fix(player): unify back navigation
close #1525
2026-07-11 03:23:40 +02:00
edde746 02a032d287 fix(android): reconnect Flutter texture after TV standby 2026-07-11 01:01:08 +02:00
edde746 03651d6c73 fix(exoplayer): unwrap AAC-LATM (LOAS) audio in MKV direct streams
close #1521
2026-07-10 23:23:44 +02:00
edde746 9b6c12d086 style: format playback_open 2026-07-10 21:26:25 +02:00
edde746 c5c7dd614f i18n: translate stream-interrupted snackbar to all locales 2026-07-10 20:27:52 +02:00
edde746 f87d6e5a52 fix(player): keep paused server sessions alive
Report the paused timeline every tick (~10s, matching official clients)
instead of every ~60s, and ping the Plex transcoder keepalive endpoint
alongside it while transcoding — PMS reaps idle transcode sessions that
timeline reports alone historically have not kept alive. Prevents the
transcode-variant of the #1520 stream death; the reporter's direct-play
case is covered by EOF classification and reconnect.
2026-07-10 19:12:36 +02:00
edde746 c885c0c6bc fix(player): intercept spurious mid-file EOF and recover in place
close #1520

Classify player EOF signals against the best-known duration: a mid-file
EOF means the stream died (transcode reaped or idle connection closed
during a long pause), not that the media ended — it must never mark the
item watched, prompt Play Next, or exit a movie. Recover with a bounded
in-place reload at the parked position; if the server is still refusing,
park on the old frame and rebuild the stream on user play/seek or when
the server-status monitor sees it come back online.
2026-07-10 19:11:18 +02:00
edde746 3bf72d7237 fix(player): auto-reconnect network streams on transient HTTP 503
ffmpeg's reconnect gave up on the first HTTP-error response, so a PMS
answering 503 (startup maintenance) after an idle-pause disconnect
surfaced as a clean mid-file EOF instead of riding through the outage.
2026-07-10 19:07:29 +02:00
edde746 d07d849fe4 refactor(player): return an outcome enum from _reloadMediaInPlace
The old bool meant "attempt was current and handled", so callers could
not distinguish a real pre-open failure (old stream still loaded) from
success — the distinction the #1520 recovery needs.
2026-07-10 19:06:51 +02:00
edde746 e6ffc54c56 style: format image_cache_service 2026-07-10 19:06:51 +02:00
edde746 b5d8ff5332 fix(metadata-edit): use 16:9 poster picker for episodes
close #1519
2026-07-10 12:45:35 +02:00
edde746 7509077d63 perf(tv): reduce animated semantics work 2026-07-10 12:07:19 +02:00
edde746 cbabdf2151 fix(images): prevent artwork limiter permit leaks 2026-07-10 12:07:19 +02:00
edde746 1acc83572e perf(tv): reduce navigation rebuilds and artwork fan-out 2026-07-10 12:07:19 +02:00
edde746andl3gitpanda b46107cb58 fix(tv): dismiss on-screen keyboard on companion-remote search submit
close #1514

A query submitted from the companion remote now behaves like a submit
instead of focus-to-type: TvKeyboardController closes an already-open
OSK and lands focus on the input without reopening it, then the shared
handleSearchSubmit path runs the search and focuses the first result.
Also guard DebouncedMediaSearch against selection-only controller
notifications re-arming the debounce into a duplicate fetch.

Co-authored-by: l3gitpanda <12003346+l3gitpanda@users.noreply.github.com>
2026-07-10 08:48:38 +02:00
edde746 1261852514 fix(ci): regenerate Trakt catalog model 2026-07-10 07:32:02 +02:00
edde746 04daf89a20 feat(explore): Explore tab with catalog detail, search, and Seerr requests 2026-07-10 07:10:15 +02:00
edde746 f94b8c6586 feat(settings): Services hub, Seerr sign-in, and all-locale strings 2026-07-10 07:09:29 +02:00
edde746 b4943f8c50 feat(seerr): Seerr API, account provider, and Explore catalog source 2026-07-10 07:08:42 +02:00
edde746 2aa084287d feat(explore): MyAnimeList catalog source 2026-07-10 07:07:54 +02:00
edde746 dcdbbc8483 feat(explore): match catalog items to library content via reverse external-id lookup 2026-07-10 07:07:07 +02:00
edde746 4e6ea5b4c5 feat(trakt): catalog API and shared catalog core (watchlist, discover, recommendations) 2026-07-10 07:06:21 +02:00
edde746 ede80d225f refactor: shared coalescer, profile-scoped prefs key, and form-error helper 2026-07-10 07:05:35 +02:00
edde746 397de2698d feat(livetv): flat M3E card redesign for guide, recordings, and details 2026-07-10 06:41:15 +02:00
edde746 632d2ec16b feat(livetv): let user pick and remember the recording target library
close #1513
2026-07-10 06:41:15 +02:00
edde746 2c13438d71 fix(tv): retain live TV session while backgrounded 2026-07-09 23:22:27 +02:00
edde746 fe5d6d8d96 fix(music): play audio playlists from context menus 2026-07-09 23:22:27 +02:00
edde746 e2ad28ca32 fix(downloads): retain offline media after logout 2026-07-09 23:22:27 +02:00
edde746 ed183a855e ci: track Android Gradle wrapper 2026-07-09 17:55:06 +02:00
edde746 5867809560 fix: resolve Jellyfin, logout, playback, and Android regressions 2026-07-09 17:14:45 +02:00
edde746 a4a2942546 fix(tv): release native AV pipeline when app stays backgrounded
A paused-but-alive player keeps its MediaCodec decoders and tunneled
passthrough AudioTrack while Plezy sits in the background, which can
degrade playback in every other app on shared-pipeline TV SoCs until
force-stop. After a 30s grace on Android TV the player is stopped
(retaining session state); returning reloads in place, or re-tunes the
channel for live TV.
2026-07-09 15:23:29 +02:00
edde746 71d644c4ae fix(macos): stop CPU spin when display sleeps with video paused
When the display/system sleeps with a video paused, the window server
stops completing CAMetalLayer presents and mpv's MoltenVK swapchain wedges,
pinning one core at 100% until the app exits. occlusionState does not
reliably change on display sleep, so observe NSWorkspace screensDidSleep/
Wake to hide the metal layer (and drop the playback activity) while dark,
restoring on wake with a forced redraw when paused to avoid a stale frame.

Pairs with MPVKit v1.0.10, which gates libmpv presentation on the layer's
hidden state (check_visible) so presents actually stop while hidden.
2026-07-09 11:56:13 +02:00
edde746 574b8d524f fix(ios): don't claim audio session at app launch
The non-mixing playback session was activated in didFinishLaunching,
so merely opening the app stopped other apps' audio. Keep the category
configuration; playback start activates the session.
2026-07-07 08:55:26 +02:00
edde746 94852e49cb fix(ios): stop reclaiming audio session while paused in PiP
close #1496

Bumps os_media_controls so the audio session is only activated on
playing-state pushes; paused/metadata pushes no longer re-interrupt
other apps' audio (which caused the 1-2 Hz play/pause loop).
2026-07-07 08:54:31 +02:00
edde746 4a3295b7fd fix(player): handle Plex multi-episode files in up-next and watch state
close #1500
2026-07-06 18:39:45 +02:00
edde746 ef75887504 style: apply analyzer lint fixes across lib
Initializing formals, unawaited() on fire-and-forget futures, unused
import removal, wildcard params, and mounted vs context.mounted.
2026-07-06 15:56:56 +02:00
edde746 49d4b433a7 fix(music): show now-playing rail item only on TV
Desktop already has the mini-player as the way back into the
now-playing screen; the rail item is redundant there.
2026-07-06 15:56:01 +02:00
edde746 5bb8b1b9f2 feat(music): add persistent volume control
Independent of the video player volume; persisted via SettingsService
and re-applied to every audio player instance.
2026-07-06 15:55:06 +02:00
edde746andClaude Fable 5 04c0714881 chore: bump os_media_controls for lock-screen artwork fixes
Pulls edde746/media_controls@71b96f8: iOS/macOS guard async artwork
downloads against track changes (slow art no longer lands on the next
track) and clear stale artwork when a track has none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:33:21 +02:00
edde746andClaude Fable 5 5f7925f350 fix(music): scope current-track highlight per server
TrackRow compared bare ids (server-scoped rating keys), so a track on
another server with the same id lit up as currently playing. Compare
globalKey (serverId:id) like the rest of the queue engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:32:26 +02:00
edde746andClaude Fable 5 226be37f85 fix(music): mark only the active sleep timer preset selected
All timed presets shared one 'timed' bool, so arming any of 15/30/60
minutes checkmarked all three. The service now retains the armed
duration (sleepTimerDuration) and the menu marks the matching preset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:31:29 +02:00
edde746andClaude Fable 5 32c4810c21 fix(music): resync gapless state when queue edits race the track boundary
A queue edit can un-arm the next entry in the same instant mpv rolls
into it; the resulting transition was dropped as unexpected, leaving the
UI and progress reporting on the finished track for the entire next
file. Remember the cleared arm (generation-gated) so the transition is
still adopted, and handle the armed track no longer being in the queue:
advance to the queue's real next, or park when nothing follows. Also
fixes the latent fallthrough that left the cursor on the finished track
when the armed track vanished from the queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:30:18 +02:00
edde746andClaude Fable 5 28bc4a5df8 feat(player): handle stop/skip/speed media-session commands
The platforms advertise stop, skip forward/backward, and playback-rate
commands by default, but both the music and video handlers silently
dropped them (Android Auto/Bluetooth stop and FF/rewind did nothing;
iOS/macOS showed a dead rate control). setControlsEnabled now manages
those controls: music handles Stop and in-track skips and stops
advertising a speed control; video handles Stop (exit, matching the
companion remote), skips via a shared relative-seek helper, and rate
changes through player.setRate. Skip commands stay off on iOS/macOS
where they would displace the next/previous lock-screen buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:28:24 +02:00
edde746andClaude Fable 5 73be8ab1c8 fix(music): convert SAF content:// downloads for gapless arming
setNext() appended raw content:// URIs that mpv cannot open, stalling
playback at the SD-card-download track boundary. Convert to fdclose://
like open(), track the armed fd, and reclaim it via a new closeContentFd
method when the entry is dropped unplayed (close only when provably
unconsumed — playlist-pos 0 before and after the remove; leak on doubt).
The playlist-pos pre-check also keeps the clear path from removing the
playing entry when mpv rolls into the armed track mid-clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:26:59 +02:00
edde746 4de38f4b17 fix(music): hide album track numbers in queue rows
Album-relative numbers read as a sorting bug in a mixed-album queue;
the leading slot stays so titles align with the equalizer row.
2026-07-06 12:17:03 +02:00
edde746 adf732a1a1 fix(ui): app-bar tab chip strips scroll instead of overflowing
Shared TabChipStrip replaces the plain Rows in libraries/downloads/live
TV app bars (and live TV's hand-rolled scroll wrapper); chips center
themselves on focus so d-pad reaches off-screen tabs.
2026-07-06 12:17:03 +02:00
edde746 214a7c13dc feat(music): show full play queue with history, YouTube Music style
Queue sheet/panel now renders the whole playback order — dimmed played
tracks above the current one — instead of only up-next. Opens scrolled
to the current track and follows advancement while parked on it.
2026-07-06 11:30:23 +02:00
edde746 fcdef343eb fix(music): sheet safe-area, swipe-to-dismiss, hide mini-player under sheets 2026-07-06 10:51:19 +02:00
edde746 a4d40486c1 fix(music): responsive artist header on desktop
Wide layouts put the circular portrait beside a left-aligned
name/genre/bio/actions block (mirroring the album header) instead of
a centered mobile column floating over the left-hugging albums grid.
2026-07-06 00:23:09 +02:00
edde746 338a53a036 fix(player): comma-safe http header delivery to mpv
mpv's http-header-fields string-list parser splits on commas inside
header values, so X-Plex-Device on Apple hardware (model ids like
Mac17,9) produced a colon-less garbage header line that Plex rejects
with 400 'Error parsing HTTP request' — first hit by music direct play
on macOS, latent in the mpv video path too. Deliver headers via
change-list append items (open) and per-entry -clr/-append loadfile
options (gapless arming), each item verbatim and %len%-quoted against
the outer option-list split.
2026-07-05 23:58:59 +02:00
edde746 4da489d4dc i18n(music): translate music player strings to all locales 2026-07-05 22:16:36 +02:00
edde746 db18ee4b34 feat(music): android background playback via media_controls foreground service
Bumps os_media_controls to 4f4b28f3: MediaStyle foreground service with
a JUnit-tested promote/demote/stop policy, artwork URL download (also
fixes video lock-screen art), and task-removal teardown that can't leak
orphan notifications. The music service opts into background mode per
session and requests POST_NOTIFICATIONS before first playback.
2026-07-05 21:52:58 +02:00
edde746 764345f021 feat(music): audio downloads and music home rows
Album/artist downloads expand to tracks with pinned parent metadata,
aggregate progress, container deletes with reference-counted album
covers, and a Music tab on the downloads screen with fully offline
album playback. Home rows include music libraries (fixes plex hub
items being filtered to video types) and audio playlists join
download/sync rules.
2026-07-05 21:51:37 +02:00
edde746 7d6a747aa7 feat(music): now-playing, mini-player, queue and lyrics UI
Persistent mini-player over the profile navigator (route-aware
suppression, bottom-bar inset), slide-up now-playing screen with
mobile/desktop/TV layouts (blurred-art background, pause shape morphs,
dpad seek chain), queue sheet with reorder/remove/jump, synced lyrics
view, TV rail now-playing item, sleep timer, and audio playlist
playback. Adds download affordances on music surfaces.
2026-07-05 21:50:11 +02:00
edde746 422db75b5b feat(music): mpv audio playback engine with gapless queue service
Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
2026-07-05 19:26:28 +02:00
edde746 05a631415e feat(music): music browsing UI — square cards, library groupings, artist/album screens
Un-gates music libraries; adds CardShape.square plumbing (circular
artists), artists/albums/tracks browse groupings with per-grouping
sorts, audio playlists tab, artist + album detail screens with dpad
track rows, music context-menu actions, and a stub MusicPlaybackService
seam for the upcoming engine.
2026-07-05 17:20:28 +02:00
edde746 fff559da06 feat(music): add music data layer for plex and jellyfin
Backend-neutral fetchArtistAlbums/fetchAlbumTracks/fetchInstantMix/
fetchLyrics, lyrics model + LRC parser, audio quality presets, music
transcode (plex /music universal, jellyfin audio PlaybackInfo profile),
station/InstantMix radio, music search, playlist type derivation, and
audio download resolution.
2026-07-05 15:55:52 +02:00
edde746 c87a9060f9 chore: tidy formatting and local ignores 2026-07-05 14:32:21 +02:00
edde746 ee3c75ba9e fix(playback): remember last played media version for continue watching
close #1492
2026-07-05 14:29:57 +02:00
edde746 90c4fe5458 feat(audio): add Kodi-style stereo downmix with center channel boost 2026-07-05 14:29:57 +02:00
edde746 97901cba87 chore(deps): bump background_downloader 2026-07-05 14:29:57 +02:00
edde746 7f81a0d4fd fix(windows): retry connectivity listener registration outside input-sync calls
INetworkListManagerEvents Advise fails with
RPC_E_CANTCALLOUT_ININPUTSYNCCALL when the platform thread is inside an
input-synchronous call at subscribe time, and an OnListen error can
only surface in Dart as an uncatchable fatal FlutterError. The fork now
retries the registration via a message-loop timer and reports only
persistent failures through the event sink, where stream onError
handlers observe them.
2026-07-05 14:16:05 +02:00
edde746 173117602a fix(android): vendor saf_util with a sound pendingResult lifecycle
saf_util 2.0.0 leaves an already-answered MethodChannel.Result behind
in pendingResult: unrelated activity request codes consume (and answer)
the pending picker, and a failed picker launch replies without clearing
the pending state. The next delivery for request 1001 then replies a
second time — IllegalStateException "Reply already submitted" in
MainActivity.onActivityResult, crashing the process.

Vendored at packages/saf_util (BSD-3) with take-and-clear reply
ownership, request codes scoped to the plugin's own pickers, and a
guarded reply on the teardown race.
2026-07-05 14:16:05 +02:00
edde746 d9130cd932 fix(detail): guard on-deck fallback against a disposed screen
_ensureFallbackOnDeckEpisode is reached through unawaited fetch
continuations and reads providers through State.context — leaving the
screen before the season fetch settled crashed on the null-check in
the context getter.
2026-07-05 14:16:04 +02:00
edde746 be072365fc fix(player): scope event-channel cancel to the owning instance
Player backends share static per-backend EventChannel names, so two
overlapping instances (episode handoff, quick exit/reopen) collide:
the newer instance's listen displaces the older sink, the older
instance's late cancel then tears down the newer stream — silently
freezing its events — and the final cancel earns the engine's
"No active stream to cancel" reply, which the framework can only
report as an uncatchable fatal FlutterError (363 events).

Track the owning instance per channel: only the current owner sends
the native cancel; a displaced instance drops its dead subscription.
Residual benign teardown races are dropped in beforeSend.
2026-07-05 14:16:04 +02:00
edde746 61f7d33fdf fix(profiles): treat vault decrypt failures as lost credentials
A failed MAC check (key/ciphertext divergence: restored backup,
clobbered prefs, racing key generation across isolates) threw from
CredentialVault.reveal on the startup profile-settings path and
crash-looped the app until data was wiped — one device logged 31
fatals in 16 minutes on 2.8.0.

Decrypt failure now means the credential is lost, never a crash:
reveal() returns null, ProfileConnectionRegistry maps it onto the
existing empty-token lazy-fetch sentinel and heals the row so later
boots re-acquire the token instead of re-failing, and
revealConnectionConfig degrades tokens to empty strings without
marking them migrated. Key init also reloads prefs before deciding to
generate and re-reads after writing, adopting whatever landed so all
isolates converge on a single key instead of orphaning ciphertext.
2026-07-05 14:16:04 +02:00
edde746 3766b2c2d3 fix(remote): handle connect failures of losing race candidates
Race candidates created with IOWebSocketChannel.connect fail their
ready future on connect timeout or an unreachable address, and nothing
observed it — web_socket_channel reports connect errors there, so every
losing candidate surfaced as an unhandled fatal (the tracker's single
largest crash source, ~1300 events: bare 5s TimeoutException plus the
recurring no-route-to-host WebSocketChannelExceptions). The stream
listener's onError only covers post-connect errors. Swallow and log the
ready failure per candidate; the race outcome is unaffected.
2026-07-05 14:16:04 +02:00
edde746 0dfe013928 fix(home): include clip libraries in per-library home rows
close #1476
2026-07-05 09:43:34 +02:00
edde746 25c7893a8e fix(windows): report native crashes through the inproc sentry backend
Crashpad uploads minidumps straight to the DSN's /minidump endpoint,
which our tracker does not implement — every native Windows crash
(e.g. faults inside libmpv threads, #1490) was silently dropped.
The inproc handler stackwalks at crash time and reports through the
regular envelope endpoint, and crashpad_handler.exe/crashpad_wer.dll
leave the bundle.
2026-07-05 09:10:46 +02:00
edde746 28ec9cacb2 perf(tv): evict non-adjacent season episode pages on low-end hardware
Visited seasons' 200-item pages were retained for the screen's
lifetime — irrelevant for a 3-season show, tens of MB of Dart heap for
a 30-season one. Low-end TV now keeps the prefetch window (selected
season plus/minus one); evicted seasons transparently refetch through the
existing unloaded-hub path.

Ref #1349
2026-07-05 09:10:46 +02:00
edde746 aaf27a7f9e fix(android): extend the RSS eviction watchdog beyond desktop and log tier signals
Android trim callbacks are best-effort — LMK can kill without ever
delivering one — yet the RSS watchdog only ran on desktop with a fixed
1.5GB bar no 2GB TV box ever reaches before dying. The watchdog now
runs on Android with a threshold scaled to device RAM, an eviction
floor and cooldown against refetch churn, and a lower backgrounded bar
since a paused app is LMK's first candidate.

The logs-screen header and startup line now record the renderer, the
effects tier with its raw hardware signals, and the boot RSS, so
uploaded reports answer whether the reduced tier engaged.

Ref #1349
2026-07-05 09:10:36 +02:00
edde746 0fb0c7fb4c perf(android): cap engine memory pools on low-RAM devices
Flutter's imageCache budget only bounds decoded CPU bitmaps: Skia's GPU
resource cache is sized from the surface area (hundreds of MB on a
4K-composited TV) and the Dart old gen defaults to a large fraction of
physical RAM. Both drive LMK kills on 2GB boxes. Cap them via engine
shell args on hardware matching the reduced-tier triple.

Ref #1349
2026-07-05 09:10:27 +02:00
edde746 f97ab03111 fix(images): bound artwork decodes on both axes and never fetch unsized originals
The decode clamp was height-only, so ultra-wide originals could decode
far past the display budget, and two small-slot fallbacks skipped
server-side resizing entirely — handing multi-megapixel originals to
the decoder behind tiny slots. The TV spotlight background's offline
branch had no decode bound at all. All artwork now flows through a
shared both-axes bound (ResizeImagePolicy.fit: aspect-preserving,
scale-down only), and the reduced tier gets matching thumb/poster
decode caps.

Ref #1349
2026-07-05 09:10:20 +02:00
edde746 3981fac0db feat(settings): surface Manage Libraries and raise the desktop sheet cap
Extract the manage/reorder-libraries sheet from the libraries screen into
a shared widget with a public launcher, and add a settings tile (top
group, gated on having libraries) that opens it. The libraries screen
keeps its side-nav refresh and re-select-on-hide behavior via launcher
hooks; the TV dialog path lives in the launcher so the entry works there
too.

Desktop overlay sheets defaulted to a fixed 400px max height, which left
the reorder sheet ~280px tall. Both default-constraint sites now use 75%
of window height, matching mobile.

close #1468
2026-07-05 09:08:29 +02:00
edde746 4a43ae2771 fix(android): auto-recover frozen video after pause/resume on stalling decoders
Some TV SoCs (Amlogic Mi Box class) stall the MediaCodec output path
after pause/resume: audio and the clock keep advancing but the picture
freezes until a seek flushes the codec. Arm a watchdog on every
transition to playing with a warm decoder; if rendered frames stop
advancing while the position moves, recover with a 250ms seek-back
(same-position seeks are short-circuited without a codec flush),
capped per session and logged for field diagnosis.

close #1454
2026-07-05 09:08:29 +02:00
edde746 c4184c6148 fix(ci): restore sanity checks 2026-07-05 08:34:55 +02:00
edde746 87eaafe3c4 chore(deps): update MPVKit to v1.0.9
close #1420, close #1457, close #1484
2026-07-05 08:15:00 +02:00
edde746 aee48f6956 feat: extend Force TV mode to desktop for home-theater setups
Initialize TvDetectionService on every platform so the existing
force_tv_mode setting drives the 10-foot TV interface on Windows,
macOS, and Linux, and surface the toggle in appearance settings there.

Desktop keeps real-mouse behavior in TV mode: InputModeTracker still
flips between pointer and keyboard modes (cursor hidden while
keyboard-driven), segmented controls keep their hover affordance, and
the settings backup section stays available (only Android TV lacks a
document picker).

Adds PlatformDetector.debugSetIsDesktopOSOverride so TV-device
simulations in widget tests don't inherit the desktop test host's
platform.

close #1409
2026-07-05 07:08:40 +02:00
edde746 3d5a1ec966 fix: build break from Sentry.configureScope FutureOr return
The pinned sentry fork declares configureScope as FutureOr<void>,
which unawaited() rejects; wrap in Future.sync to keep the
fire-and-forget behavior.
2026-07-05 06:49:46 +02:00
edde746 2e4ba352f4 fix: multi-trigger audio recovery after Windows sleep/wake
The two prior attempts at #783 failed for lack of a reliable trigger:
re-setting audio-device to its own value is a no-op, and gating
recovery on audio-device-list changes while current-ao is null never
fires in the real flows (a paused player only discovers the dead
WASAPI session at unpause, when the device list is already stable;
a zombie session that consumes samples silently never goes null).

Replace the single gate with a recovery scheduler on the mpv event
thread, ticked every wait-event timeout:

- WM_POWERBROADCAST resume (both PBT variants, via the plugin's
  existing window-proc delegate) requests unconditional ao-reloads at
  +1.5s and +6s after wake — the only trigger that reaches zombie
  sessions; the second shot covers a first reload landing while the
  audio stack is still restoring. The handoff is a single atomic
  flag: no mpv calls or timers from the platform thread, so there is
  nothing to clean up on dispose.
- current-ao falling to null arms clock-driven retries (budget 5,
  x2 backoff capped at 8s); retries must be clock-driven because a
  failed ao-reload falls back to null without a property change
  event. A device-list change while null refreshes the budget, and
  audio-device-list is now observed natively so recovery does not
  depend on the Dart side.

Every trigger, attempt, and outcome logs as [mpv:audio-recovery] at
warn level so exported logs from the reporter are actionable if this
still misses. Verified with synthetic PBT_APMRESUMEAUTOMATIC while
playing and while paused: full WASAPI reinit both times, audio
restarts on unpause.

Ref #783
2026-07-05 06:49:38 +02:00
edde746 31e8c2e1e0 fix(plex): coerce PlexHomeUser fields against account-API drift
Same clients.plex.tv surface that broke in #1488: /home/users powers
first sign-in and profile setup, so a drifted scalar shape would kill
the whole fetch. Coerce strings, bools, and ints tolerantly instead of
hard-casting.
2026-07-05 06:23:41 +02:00
edde746 96510f8aac fix(plex): tolerate account-API schema drift in profile and switch parsing
Around July 3 plex.tv started returning the profile language-list fields
(defaultAudioLanguages, defaultSubtitleLanguages, mediaReviewsLanguages)
as comma-separated strings instead of arrays. The generated cast threw on
the successful 201 /switch response, dropping the freshly minted Home
user token: every rebind failed, the binder retried a /switch mint every
1-2s, and the app sat permanently in offline mode even after re-signing
in. Accounts without language prefs set were unaffected, which is why
the breakage looked sporadic.

Parse the language lists with a CSV-aware coercion, and make
UserSwitchResponse.fromJson strict only about authToken: decorative
fields now coerce tolerantly and a broken profile blob falls back to
defaults, so account-API drift can never brick token minting again.

close #1488
2026-07-05 06:23:34 +02:00
edde746 6e83aeda88 fix(hubs): drop deleted items from recommended and home continue watching
The library Recommended tab never subscribed to deletion events, so
"Delete from server" left the episode sitting in Continue Watching
until a full reload. Make the tab DeletionAware (remove in place across
all hubs, then resync) and give DiscoverProvider the same subscription
so the home row and hubs drop deleted items too.

close #1486
2026-07-05 05:58:52 +02:00
edde746 7b1c150e18 feat(android): enable impeller on capable TV devices
Impeller was blanket-disabled on TV in e759dafa as a precaution when the
Tensor/NVIDIA fallbacks landed, and #749 (API 30 Fire TV Cube freezing
until the Skia build) validated it. Flutter has since deprecated the
Impeller opt-out, so start walking the disable back where the hardware
can take it: TV devices on Android 12+ with a Vulkan 1.1 driver use
Impeller, while Fire OS (modern API levels on GPUs whose drivers can't
back it up), pre-12 boxes, and the existing NVIDIA/Huawei/e-waste quirks
stay on Skia. Tag Sentry events with the active renderer so TV
regressions are attributable while this rolls out.
2026-07-05 02:28:51 +02:00
KakarottoCakeandedde746 cd8212fb53 perf(tv): cap image cache at 64MB on TV
TV boxes share limited RAM with 4K video decode buffers, so the 100MB
phone budget crowds out the decoder. Low-RAM TVs already on the reduced
tier keep the tighter 48MB cap.

From #1479.
2026-07-05 02:28:43 +02:00
edde746 11f7fd766d perf(library): cut scroll jank in card grids and hub rows
Profile traces showed 100-370ms UI-thread frames while scrolling the
library screen, dominated by rebuilding and inflating media cards.

- Gate per-card focus/pointer chrome on input mode: FocusableWrapper
  skips the scale/border wrappers and creates its AnimationController
  lazily outside keyboard mode, and ClickableCursor plus the card tap
  region only build MouseRegion/InkWell machinery on desktop - TV and
  touch use a bare GestureDetector. Hub cards also drop their outer
  gesture wrapper outside keyboard mode; the card's own tap region
  always won the gesture arena anyway.

- Memoize sliver children (SliverChildMemo): browse/collections grids
  and hub rows return identical widget instances for unchanged items,
  so delegate swaps from pagination, watch-state, and deletion
  setStates no longer rebuild every realized card inside layout. The
  browse tab prunes the memo in lockstep with focus-node eviction so a
  cached card can never resurrect a disposed FocusNode.

- Budget fresh inflation (CardInflationBudget): while a scrollable is
  moving in pointer/touch mode at most one new card inflates per frame,
  the rest render as SkeletonMediaCard and upgrade on following frames.
  Hub rows also stop pre-inflating 250px of off-screen cards on entry.

Device traces: worst frame 373ms -> 103ms, per-card build 3.6ms ->
2.4ms median; remaining row-entry work is spread across frames.
2026-07-05 02:23:42 +02:00
edde746 d80a1ed15a feat: report real device identity to media servers
Resolve platform, hardware model, and friendly device name once via a
shared DeviceIdentityService and send them to both backends: Plex gets
a real X-Plex-Platform plus X-Plex-Device/X-Plex-Device-Name (shown as
Player in dashboards/Tautulli), Jellyfin gets the device name in the
MediaBrowser auth header. Transcode and live-TV decision requests keep
their pinned platform names, which Plex validates server-side.

close #1270
2026-07-05 01:22:20 +02:00
edde746 f1422feff1 fix(detail): rework hero scrim into full-height eased dim 2026-07-05 00:35:36 +02:00
edde746 d7c0af9ad0 fix(player): fit the performance overlay on short screens
Anchor the overlay to the screen and seekbar clearance, widen the card
on height-limited screens so sections pack side by side, and scale down
as a last resort instead of clipping the bottom sections.

close #1469
2026-07-04 23:53:50 +02:00
edde746 87aea49ab6 fix(servers): stop rebinds from flashing an empty home screen after sign-in
Signing in triggered two back-to-back profile rebinds; the second re-added
the same Jellyfin connection, which tore down the live client and aborted
the home screen's in-flight fetches. The aborted pass was committed as
loaded-empty, flashing 'no content available' until the follow-up load
landed. Fix at the root instead of patching the sign-in window:

- addJellyfinConnection now reuses the live client when the connection is
  unchanged (token, deviceId, URL set), matching the existing Plex
  refreshTokensForProfile behavior; material changes still recreate it.
- Cancelled requests are classified end-to-end: the client's
  treat-as-empty helpers rethrow cancellations, and the aggregation
  fan-outs report cancelledServerIds alongside succeededServerIds.
- A fetch pass in which zero servers succeeded is never authoritative:
  it keeps existing content instead of wiping it (also fixes the
  pre-existing blanking of home/sidebar on a totally failed refresh),
  stays in loading while disrupted (cancellation or binding in flight),
  and only commits loaded-empty on a settled failure.
2026-07-04 23:44:38 +02:00
edde746 c8ca8a7875 fix(discover): restore hero indicators and bottom fade on non-TV
The hero dots/pause row was gated on live input mode, so any navigation
key event (Android back key, BT keyboards, gamepad-source noise) hid it
until the next pointer event - on phones it ended up permanently hidden.
Gate on the TV platform instead (issue #600's actual scope); the TV
layout never renders the carousel, so nothing changes there.

The bottom fade lost its guaranteed opaque band when the section-level
gradient was removed (686a61ac): the per-item overlay only reached full
background at the literal last pixel, letting artwork ghost through the
final 15% and read as a hard cut against the content below - worst on
phones, where square hero art is bright at the bottom. Finish the fade
at solid background from the 0.94 stop (~32px band on a phone hero).
2026-07-04 23:18:33 +02:00
edde746 d9b365b012 fix: failing tests 2026-07-04 23:09:41 +02:00
edde746 05e70ab56d chore: remove caddy 2026-07-04 23:09:41 +02:00
edde746 f9abcd44fe fix(playback): hold timeline preview until playback settles at the target
The preview cleared unconditionally 2s after a committed seek; on a device
still buffering, the live position hasn't caught up yet, so the next key
press re-based off the stale position and re-targeted the same spot -
discarding the seek exactly on the slow hardware the coalescing targets.
Re-arm the clear until the live position is near the target, with a 10s
ceiling backstop (same settle pattern as LiveSeekAccumulator).
2026-07-04 22:58:19 +02:00
edde746 3cad91736b fix(playback): raise timeline scrub debounce above the key-repeat delay
350ms sits below the typical KeyDown-to-first-repeat gap (~400-500ms on
Android/TV), so every hold committed an intermediate seek before repeats
even began. 800ms matches Jellyfin ATV (media3's DefaultTimeBar uses 1s);
key release still flushes synchronously, so taps are unaffected.
2026-07-04 22:57:27 +02:00
edde746 632d66f6ac fix(playback): clear timeline seek dedup state when a gesture finalizes
_lastDispatchedTimelineSeek was set by the drag throttle and never reset, so
with key-seeks now routed through onSeekEnd a coalesced flush targeting the
same position as a stale drag (realistic at the 0/duration clamp boundaries)
skipped the real seek while still reporting completion.
2026-07-04 22:57:00 +02:00
Christian Tismerandedde746 ed843b4b89 fix: coalesce held timeline seeks on direct play
A held fast-forward fired a real seek on every key-repeat for direct/
progressive streams, flooding the source with overlapping range requests and
wedging low-power devices (e.g. Fire TV Stick) in BUFFERING until the player
was restarted. Route direct play through the same preview/debounce path the
transcode case already uses: move only the timeline preview while the key is
held and commit a single seek on release or pause.

close #1424
2026-07-04 22:56:43 +02:00
edde746 27596bb919 fix(windows): enable WinHTTP IPv6 fast fallback
Dual-stack hostnames with unreachable IPv6 stalled past the endpoint
probe budget because WinHTTP tries addresses sequentially. Enable
WINHTTP_OPTION_IPV6_FAST_FALLBACK (Happy Eyeballs) via win_http 0.2.2
so IPv4 is raced after the IPv6 attempt, matching curl/Cronet/NSURLSession.

close #1128
2026-07-04 21:56:41 +02:00
edde746 6b7aac3372 fix(tv): handle remote play/pause at the screen focus level
When focus drifts to the player's screen-level Focus node (after an
overlay sheet closes with the controls hidden), a hardware play/pause
key only revealed the chrome and leaked to Android's MediaSession,
which pauses unreliably when its state is throttled or suspended.
Intercept media play/pause at the screen node on TV-style navigation:
toggle via the same playback-intent path as the Apple TV bridge and
consume the event.

close #1375
2026-07-04 21:33:45 +02:00
edde746 6af4289289 fix(tv): keep sidebar focused when Back returns to the home tab
Backing out to home selected the home tab but let focusActiveTabIfReady
steal real focus into the content while _isSidebarFocused stayed true,
leaving the rail stuck expanded over a shifted content box that clipped
the end of every hub row. Skip the content-focus grab while the sidebar
owns focus, move the sidebar focus ring to the Home item on back-to-home,
and self-heal the flag whenever the content scope actually gains focus.

close #1411
2026-07-04 20:56:04 +02:00
edde746 eb8796fa26 feat(library): indicate active filters in browse view
Badge the mobile Library options icon with a dot while filters are
active, and replace the "This library is empty" state with a
"No items match the active filters" message plus a reset button.

close #1470
2026-07-04 20:36:23 +02:00
edde746 8fe66f6e36 fix(android): keep the activity alive when a BT keyboard connects
close #1455
2026-07-04 19:54:19 +02:00
edde746 837fdf4031 fix(playback): canonicalize base URL scheme case
FFmpeg's protocol lookup is case-sensitive, so a stored "Https://" base
URL reaches mpv verbatim through the direct-play string concat and fails
with "Protocol not found" (API calls survive because Dart's Uri
lowercases the scheme). Canonicalize at Jellyfin URL intake and in the
connection constructor so persisted configs self-heal on load, and
register mpv-escaped (https\://) redaction variants so option-value
logs stop leaking the server host.

close #1465
2026-07-04 19:43:32 +02:00
edde746 dd09e421bf fix(android): force codec re-init on TV surface changes to recover video after screensaver
close #1481
2026-07-04 19:25:56 +02:00
edde746 50261398bf fix(android): serialize mpv writes to restore fallback ordering
close #1482
2026-07-04 19:15:34 +02:00
edde746 7632d4bea7 feat(jellyfin): favorites library filter + heart favorite in the rate sheet
close #1485
2026-07-04 18:33:58 +02:00
edde746 30de8be2cd fix(images): release artwork slots on non-200 responses 2026-07-04 17:31:52 +02:00
edde746 536e8e3d2a fix(playback): keep the Jellyfin shuffle queue across episodes
close #1466
2026-07-03 10:20:13 +02:00
edde746 3f9c25fe5e fix(ui): hide Play Version while the item's server is unreachable
At most one version of an item can be downloaded, and plain Play now
targets it directly, so an offline Play Version picker is a no-op
detour offering versions that cannot play (#1440). Backend
capabilities are static, so the menu gate and the quality picker in
_handlePlayVersion both get a liveness check — a server dropping
between menu open and tap must not offer transcodes either.
2026-07-03 10:19:55 +02:00
edde746 fd4291aafb fix(playback): play the downloaded version when offline close #1440
Plain Play requests the default media version (index 0 or the saved
preference), but the offline resolvers rejected the single downloaded
row when a non-default version was downloaded, then threw "No video
URL available" with no client to fall back to.

Three-part fix sharing one matcher (downloadedVersionMatches):
- getPlaybackData falls back to the downloaded version when there is no
  client to stream from; the result now carries the effective
  mediaIndex/mediaSourceId so cached media info and the committed
  session describe the file actually played.
- navigateToVideoPlayer seeds the selection from the download record
  for isOffline plays with no explicit version, covering the external
  player branch and offline-library plays with a reachable server.
- PlaybackSession.fromContext prefers the result source id over the
  requested one, keeping in-player state in sync after a fallback.

Online pinning is untouched: with a live client an explicitly requested
non-downloaded version still streams from the server.
2026-07-03 10:19:55 +02:00
edde746 8fa91fe444 chore(apple): pin MPVKit 1.0.8
AV1 perf: NEON dav1d (upstream prebuilt shipped C-only) + fused
software-frame upload in the avfoundation VO (patch 0009).
2026-07-03 08:51:19 +02:00
edde746 7972743a2f fix(windows): stop mpv from double-handling hardware media keys 2026-07-02 18:30:20 +02:00
edde746 6d95e35448 feat(tvos): AVPlayer Atmos sink wiring + Atmos output diagnostics
Re-enables Audio Passthrough on Apple TV against the MPVKit EAC3-JOC
AVPlayer sink (pin bump to v1.0.8 follows), adds an AVPlayer test
harness + diagnostics screen for #1300, and makes loudnorm yield while
passthrough is active (fixes a latent spdif conflict on desktop too).
2026-07-02 15:52:10 +02:00
edde746 86abf3e9da fix(watch-together): retry guest media switches until they commit
Guest switch dispatch pre-marked its dedup key and fired-and-forgot, so
any failure (fetch error, reload busy with an auto-advance, navigation
race with the host exiting) silently stranded the guest on the old media.
A CurrentPlaybackDispatcher now marks a key handled only after the sink
reports success against the committed identity, with a serialized
in-flight slot, timeout, and generation reset; the reconciler re-offers
unattached media on every host heartbeat, making the heartbeat the retry
channel. Fetches that outlive their dispatch are re-validated against the
current snapshot so a stale switch can't override the live one.
hostExitedPlayer now rides the controller's ordered message queue with
host authentication instead of racing state handling in the provider.
2026-07-02 12:44:15 +02:00
edde746 0e3c592205 fix(windows): marshal mpv events onto the platform thread
close #1453

Events were delivered to the Flutter event sink directly on the mpv event
thread while method replies were already marshaled; concurrent messenger
use from two threads intermittently crashed guests during the event burst
of an in-place media switch. Also joins the event thread before draining
platform tasks in the plugin destructor (reverse member destruction order
would otherwise let the event thread enqueue into a destroyed queue) and
coalesces the wakeup PostMessage behind a retry-safe flag.
2026-07-02 12:43:10 +02:00
edde746 5f49dddb4d feat(ui): M3E restyle for settings, auth, and profile screens 2026-07-02 11:47:43 +02:00
edde746 074f92d144 fix(tv): bleed the unfocused-rail dim under the side nav
The rail dim (and its top fade band) was clipped to the rail's footprint,
so it terminated in a vertical seam at the nav edge while the backdrop
continued behind the nav. Generalize _RailBackgroundBleed into
_RailBleedPositioned and render the dim through it, full-width like the
background gradient. Row stripes now apply only while the rail itself is
focused — under the full-width dim they'd re-introduce level seams at the
nav edge (and read as noise anyway).
2026-07-02 11:41:25 +02:00
edde746 6274facc3c fix(tv): fade dim-scrim edges instead of hard rect boundaries
Unlike the content opacity it replaced, the dim quad also darkens the
backdrop showing through around the rows, so its rectangle edge read as
a hard line across the spotlight artwork. Ramp the dim in over narrow
top/bottom gradient bands (flat quad interior, non-AA to avoid seams);
band area keeps the gradient-shader cost negligible on weak TV GPUs.
Browse pass on the Mali-G31 box unchanged: draw p50 7.2ms, 2% janky.
2026-07-02 11:41:25 +02:00
edde746 b08b3b846a perf(tv): rasterize axis-aligned gradients into strip textures
Skia's dithered gradient shaders cost ~10ms per full-screen pass on
Mali-class TV GPUs: the two spotlight scrims alone were ~20ms of a 27ms
raster frame, while flat blended quads at the same coverage are ~free.
Bake axis-aligned LinearGradients once into cached 1x1024 premultiplied
strip textures drawn as stretched quads (shader fallback for unsupported
shapes and the first frame). Converted the spotlight, TV detail backdrop,
app-bar scrims, rail bleed, person-card overlay, and the video-controls
scrim, which now also keeps one widget type across hasFrame flips so the
controls subtree survives in-place source switches.

Scripted-browse on a Mali-G31 box: draw p50 27.5ms -> 7.1ms, swap block
20.3ms -> 0.9ms, janky draws 69% -> 2%; screenshots pixel-identical.
2026-07-02 11:41:25 +02:00
edde746 1059658515 refactor(profiles): remove dead registry surface
Delete unused registry members (ProfileConnectionRegistry.insertIfAbsent /
removeAllForProfile, ProfileRegistry.reorder, ProfilesView.countFor,
ConnectionRegistry.getDefault) and their orphan tests, refreshing the stale
docs that named removeAllForProfile as the profile-delete cleanup path.

Repair the "one default per profile" join-row invariant: the connectionId FK
cascade (foreign_keys=ON) silently drops a profile's default row while its
other rows survive, leaving it defaultless. Add promoteMissingDefaults, share
a deterministic re-promotion helper with remove(), and re-promote in
removeAllForConnection.
2026-07-02 11:41:25 +02:00
edde746 d193633a5d fix(remote): live auth-context refresh + lifecycle races
Keep a running companion-remote host's crypto identity in sync with the
active profile: subscribe to PlexHome/connection/join streams and rebuild
auth contexts (restarting the broadcast when they change) so a removed
home user or revoked borrowed connection stops controlling the host.

Serialize host start/stop/crypto-rebuild through a lifecycle lock, clean
up peer subscriptions before re-listening, and guard the replaced-client
onDone against clobbering the new client's session. Drop the context.mounted
guards that aborted an app-level host start, fix the DiscoveryView
init-flash/stuck-on-throw, and delete the dead initialize*Crypto helpers.
2026-07-02 11:41:25 +02:00
edde746 2b7142bdcd fix(downloads): profile-scoped ownership and watch-sync integrity 2026-07-02 11:41:25 +02:00
edde746 44be03d39c refactor(profiles): shared auth/mint flows + screen fixes 2026-07-02 11:41:25 +02:00
edde746 f9123eb40f fix(profiles): binder settle, active-id, and rebind correctness 2026-07-02 11:41:25 +02:00
edde746 2025e1f9fd fix(profiles): clean up orphaned connections on Plex sign-out
close #1423
2026-07-02 11:41:25 +02:00
edde746 6e8320f782 fix(profiles): parse real Plex Home uuids + migrate pref scopes 2026-07-02 11:41:25 +02:00
edde746 bfcd11538e fix(security): redact Plex Home PIN from logs 2026-07-02 11:41:25 +02:00
edde746 72d37f2362 perf(tv): dim action bars once at row level while unfocused
In d-pad mode every unfocused FocusableActionBar button held its own
AnimatedOpacity(0.6) — 4-5 permanent saveLayers (one render pass each
on tiled TV GPUs) on discover and detail alike, every frame. While no
button in the row has focus (the entire time the user browses content)
the whole row now shares a single dim layer; per-button dims apply only
while the row holds focus.
2026-07-02 11:41:25 +02:00
edde746 e8a99c1108 fix(tv): harden spotlight crossfade broken-artwork path
The base image shared the incoming's fade-riding errorBuilder, so a
404'd backdrop flashed transparent on the next swap, could start the
fade before any frame existed, and an errored provider got promoted
into the base (re-resolve loop). Base now uses a static fallback,
error/frame state is tracked per incoming (keyed by provider), and
promotion settles on the fallback color when no frame ever arrived.
2026-07-02 11:41:25 +02:00
edde746 48568fd412 perf(tv): exclude inactive hub rows from semantics
Their cards can't take focus until the row activates (locked-hub
model), and the per-frame semantics pass scales with node count.
2026-07-02 11:41:25 +02:00
edde746 320bde7f33 perf(tv): spotlight backdrop cross-fades by paint alpha
The whole-stack AnimatedSwitcher kept two full-screen saveLayers (each
compositing backdrop + two full-screen gradients) blending on every
focus move — with CachedNetworkImage's own fade nested inside, the
dominant GPU load while browsing. Gradients are identical between items
so they never fade now; the incoming artwork fades in via Image.opacity
over the still-opaque previous one (no placeholder flash), and only the
bounded info block keeps a real cross-fade.
2026-07-02 11:41:25 +02:00
edde746 868a1469a3 perf: slim per-card build cost
Memoize the per-image SHA-1 disk-cache key (ran on the UI thread on
every image build), fade images in via paint alpha instead of an
AnimatedSwitcher saveLayer per in-flight tile, create SettingsBuilder's
merged listenable once instead of per build, and drop the pointer
chrome (InkWell ink machinery, cursor MouseRegions) from cards on TV
where no pointer exists.
2026-07-02 11:41:25 +02:00
edde746 43ab5d3246 perf: shrink semantics tree on media lists
Poster images are decorative (the card exposes one merged node), and
the default per-child AutomaticKeepAlive/_SelectionKeepAlive/
IndexedSemantics wrappers are inert here — no keep-alive clients or
SelectionArea exist. The semantics phase ran ~9ms every frame on TV
devices with an accessibility service; it scales with node count.
2026-07-02 11:41:25 +02:00
edde746 c38f24aee9 perf(tv): isolate rail rebuilds; dim rows via scrims, not opacity
Hub moves, focus flips and item selects now flow through the rail focus
model + ListenableSelectors (two headers/cards repaint instead of every
visible card rebuilding). DiscoverScreen memoizes the hubs list, gates
provider notifies on a render signature and caches the rail widget so
no-op refreshes skip the subtree entirely. The whole-rail 0.6 and
inactive-row 0.7 AnimatedOpacity layers are replaced by AnimatedDimScrim
quads, removing the permanent per-frame saveLayers that kept low-end TV
GPUs raster-bound.
2026-07-02 11:41:25 +02:00
edde746 a5041cb3db perf(tv): opaque detail route + isolate focused-episode updates
Covered routes stop painting/building once the fade settles, and d-pad
episode scrubbing rebuilds only the foreground info panel instead of
the whole screen with its rail.
2026-07-02 11:41:25 +02:00
edde746 7143bb380f fix(tv): skip desktop hover-arrows machinery on TV 2026-07-02 11:41:25 +02:00
edde746 d5df426ab6 build(libass): bump to 0.18.2 2026-07-02 11:41:24 +02:00
edde746 1d2dea214c ci(windows): set up arm64 Flutter 3.44.0 from git tag 2026-07-01 07:10:24 +02:00
edde746 e07f54921c ci(windows): install patched DComp engine for x64 and arm64 2026-07-01 06:37:51 +02:00
edde746 ba112d7aa6 refactor(windows): remove legacy mpv compositing, require DComp engine 2026-07-01 06:37:35 +02:00
edde746 28e6e349b3 fix(android): restore ExoPlayer audio for tunneled decoded PCM 2026-06-30 18:58:08 +02:00
edde746 2d30686857 fix: decouple library grouping from provider context 2026-06-30 06:57:21 +02:00
edde746 ea86ce5a4b fix(android): preserve bitmap subtitle aspect
close #1444
2026-06-30 02:31:18 +02:00
edde746 4cf94683ae fix: send correct streamID for same-language forced subtitles
close #1443
2026-06-29 07:10:25 +02:00
edde746 6c96830033 fix(android): never-drop multi-page subtitle atlas 2026-06-29 02:31:14 +02:00
edde746 ec1473af5e fix(android): tile oversized subtitle images so the atlas never drops them 2026-06-28 21:49:19 +02:00
github-actions[bot] 675886673e chore: update cask to 2.8.0 2026-06-28 08:07:38 +00:00
github-actions[bot] 19806d9b54 chore: bump version to 2.8.0 2026-06-28 07:44:28 +00:00
edde746 79b75483bd style: fix CI formatting 2026-06-27 10:36:21 +02:00
edde746 78090d8bd6 refactor(trackers): unify session and account store across services
Replace the per-tracker json_serializable session classes and account stores with a shared TrackerSession (service-aware persisted validation), a single TrackerAccountStore, a shared TrackerHttpClient, and an AnimeListTrackerBase mixin for MAL/AniList. Scope the per-service rebind guard so a disconnect can't abort a racing profile load. Add migration-safety, MAL refresh, episode-count cache, and content-type tests.
2026-06-27 10:05:03 +02:00
edde746 2fe0fdb975 fix: hide player queue spoilers
close #1426
2026-06-27 07:22:21 +02:00
edde746 930534ee31 fix: avoid duplicate timeline seek
close #1421
2026-06-27 07:05:06 +02:00
edde746 3b611a1e18 fix: avoid early playback completion 2026-06-27 06:46:32 +02:00
edde746 120749e442 fix(android): regenerate libass prebuilt archive 2026-06-27 06:43:37 +02:00
edde746 d333ac2382 fix(apple): pin MPVKit 1.0.6 2026-06-27 06:03:07 +02:00
edde746 c3ca6e4c49 fix: clear subtitle overlays when disabling subtitles
close #1387
2026-06-26 10:09:45 +02:00
edde746 ad6ed04901 style: abbreviate episode date on TV media detail
close #1380
2026-06-26 08:48:30 +02:00
edde746 25e5ea042f fix: server-qualify TvBrowseRail hub keys to avoid duplicate GlobalKey 2026-06-26 08:34:54 +02:00
edde746 4462b44e9b fix: show server name on TV hubs with multiple servers
close #1319
2026-06-26 07:57:21 +02:00
edde746 7df969cb90 style: compact episode rows, align thumbnails to page margin 2026-06-26 07:40:51 +02:00
edde746 719f317b5d feat: show genres on the media detail screen
close #1390
2026-06-26 07:16:39 +02:00
edde746 c32806d987 fix: show server name on all hubs with multiple servers
close #1319
2026-06-26 05:16:41 +02:00
edde746 86637012dc fix: show server name on all libraries with multiple servers
close #1319
2026-06-26 04:54:28 +02:00
edde746 bd7f37a3f6 test: align spoiler progress expectation 2026-06-26 01:30:06 +02:00
edde746 d5c575cbfe fix: keep download toggle label on one line 2026-06-26 01:19:38 +02:00
edde746 f29030c3a0 fix: correct jellyfin trickplay sheet geometry
close #1417
2026-06-26 00:26:26 +02:00
edde746 f04691d321 feat: add "Include Specials" toggle to the show download dialog
The aired-order rework can sweep correctly-placed Specials into "download
next N"; this toggle lets users opt out. Shown only for whole shows
(reusing FocusableSwitchListTile via an optional toggle on the shared
option-picker dialog), remembered across opens via a BoolPref
(default on = unchanged behavior). Filters Specials at the single
collect choke point (_collectPlayable), with a guard so explicitly
downloading the Specials season still queues its episodes.
2026-06-25 23:41:31 +02:00
edde746 dbe804f8db fix: order episodes by air date for offline, Jellyfin, and downloads
Extends the Plex play-queue fix (#1416) to the shared episode comparator
so Specials interleave by air date everywhere, not just Plex streaming:
offline next/prev, the Jellyfin online queue, offline OnDeck, and the
download/sync "next N" selection. Undated episodes fall back to
Specials-last, preserving the #1414 "never front-load the Specials
folder" guarantee. Jellyfin queue now requests PremiereDate.
2026-06-25 23:05:16 +02:00
edde746 37e4153602 fix(plex): interleave Specials in show play queue
close #1416
2026-06-25 19:45:58 +02:00
edde746 b0b14bcd18 fix: hide episode spoilers until marked watched
close #1397
2026-06-25 11:13:57 +02:00
Huzama AhmadandGitHub 7b917462c7 fix(plex): show PGS subtitles when transcoding (#1399)
* fix(plex): show PGS subtitles when transcoding

Bitmap subtitles (PGS/VOBSUB) disappeared from the menu while transcoding on Plex, leaving only "Off". The MKV transcode already supports them, so embed them in the stream like text subs. Now they show up and can be turned on.

* fix(plex): give bitmap subtitle codecs the right sidecar extension

getSubtitleExtension fell back to .srt for vobsub, pgssub and dvb subs. Map them to .sub/.sup so it stays consistent with isImageSubtitleCodec and any sidecar URL or download uses the correct extension.
2026-06-25 10:50:38 +02:00
edde746 791433bceb fix: download and queue episodes in watch order, Specials last
close #1414
2026-06-25 10:47:13 +02:00
edde746 e90835c234 fix(ci): restore sanity checks 2026-06-25 07:21:16 +02:00
edde746 8affbb7af6 fix(tvos): align embedded framework minimum os 2026-06-25 06:19:29 +02:00
edde746 21c4ab430e feat(android): tune frame rate and subtitle rendering 2026-06-24 01:34:03 +02:00
edde746 c9dc15b1d8 fix(android): improve libass frame timing 2026-06-24 01:26:46 +02:00
edde746 b4967ca075 fix: expose offline settings 2026-06-23 05:11:39 +02:00
edde746 cd1978ee1c fix: scope hidden libraries to profiles
close #1388
2026-06-23 00:21:01 +02:00
edde746 a5e422d284 fix(trackers): parse new Fribb imdb_id/themoviedb_id shapes
close #1402
2026-06-22 23:58:17 +02:00
edde746 5a1d378b42 fix(player): stop writing show-wide track defaults 2026-06-21 16:23:54 +02:00
Huzama AhmadandGitHub 19abd7831f fix(plex): report transcode session on the timeline (#1381)
Send the playback X-Plex-Session-Identifier on /:/timeline so the server reports Transcode instead of Direct Play.
2026-06-20 03:32:02 +02:00
edde746 0d78020fbf fix(images): reduce artwork decode pressure 2026-06-18 18:00:23 +02:00
edde746 7e1d5ad4c4 fix: add Jellyfin episode-added sort
close #1274
2026-06-18 16:22:01 +02:00
edde746 94f2daa22e feat: add episode action setting
close #1351
2026-06-18 11:16:38 +02:00
edde746 075e4aeb32 fix(player): restore OSD auto-hide
close #1360
2026-06-18 10:18:57 +02:00
edde746 52d70dd97d fix(libraries): add Plex TV date added sort
close #1369
2026-06-18 09:05:09 +02:00
edde746 d9b03160f0 fix(video-controls): activate skip marker on select when controls hidden
close #1374
2026-06-18 07:59:26 +02:00
edde746 b11c63f557 fix: restore sidebar focus visibility 2026-06-18 04:29:34 +02:00
edde746 a75cf5fd35 fix(player): dismiss prompts on back
close #1373
2026-06-18 03:25:30 +02:00
edde746 6f39a286d3 fix(profiles): remount profile session
close #1371
2026-06-18 00:53:48 +02:00
edde746 66b80a33ba fix(libraries): preserve Jellyfin alpha focus
close #1367
2026-06-17 20:55:26 +02:00
edde746 84b9eac09b fix(ci): restore sanity checks 2026-06-16 22:43:56 +02:00
edde746 39788ab64a fix(android): guard missing HDR capabilities 2026-06-16 22:00:19 +02:00
edde746 4cd289b4b8 fix(profiles): forget removed Jellyfin connections
close #1355
2026-06-16 20:08:47 +02:00
edde746 d49bd20fa0 fix(libraries): restore top-row focus escape
close #1354
2026-06-16 16:03:18 +02:00
edde746 618251b8eb fix(player): attach external subtitles during open 2026-06-15 17:21:08 +02:00
edde746 9437956b83 fix(livetv): keep timeline heartbeat on active endpoint 2026-06-15 14:59:06 +02:00
edde746 f602925896 fix(trakt): persist refreshed auth sessions
close #1347
2026-06-15 14:42:24 +02:00
edde746 1aecb9d2d2 fix(trakt): publish refreshed sessions 2026-06-15 14:42:18 +02:00
edde746 4a5731976a refactor(platform): centralize Apple TV detection 2026-06-15 13:32:54 +02:00
edde746 8198ea9522 fix(android): wire ExoPlayer passthrough control 2026-06-15 13:31:42 +02:00
edde746 e069d30555 fix(android): block direct audio without passthrough 2026-06-15 13:28:54 +02:00
edde746 1aa06ffe90 fix(tv): restore passthrough setting visibility 2026-06-15 13:28:38 +02:00
edde746 1b29cb3ef7 fix(player): apply stored passthrough setting 2026-06-15 13:28:23 +02:00
edde746 ba22dca9e5 fix(platform): gate audio passthrough support 2026-06-15 13:28:10 +02:00
edde746 b5a9f09f12 fix(player): wait for autoplay subtitle tracks
close #1348
2026-06-15 13:09:09 +02:00
edde746 8bf5dbb379 fix(player): resume mpv startup frame after subtitles 2026-06-15 12:06:04 +02:00
edde746 5fc599439e fix(player): wire playback streams before open 2026-06-15 12:05:48 +02:00
edde746 27723e6f29 fix(video): sync first-frame background state 2026-06-15 12:05:29 +02:00
edde746 a4c444435f fix(player): avoid overlapping performance stat fetches 2026-06-15 12:05:15 +02:00
edde746 6292685b5d fix(player): fetch Android mpv stats off main thread 2026-06-15 12:04:58 +02:00
edde746 7195702242 fix(player): repair Plex transcode seeking
close #1341
2026-06-14 23:39:05 +02:00
github-actions[bot] b459c5150c chore: update cask to 2.7.1 2026-06-14 16:57:48 +00:00
github-actions[bot] dba292cbfb chore: bump version to 2.7.1 2026-06-14 16:35:08 +00:00
edde746 01a5d11ef5 fix(jellyfin): use season episode endpoint 2026-06-14 18:34:12 +02:00
edde746 5e2bd4efa8 fix(video): release scrub chrome hold
close #1340
2026-06-14 18:34:12 +02:00
github-actions[bot] b23ba4c839 chore: update cask to 2.7.0 2026-06-14 13:49:45 +00:00
github-actions[bot] 06bd9d775d chore: bump version to 2.7.0 2026-06-14 13:22:49 +00:00
edde746 d6487e24c9 fix(tv): keep sidebar transparent 2026-06-14 14:53:56 +02:00
edde746 c404589382 style(native): fix formatting 2026-06-14 14:04:52 +02:00
edde746 f1689a7794 fix(tvos): keep audio passthrough opt-in 2026-06-14 13:49:57 +02:00
edde746 a861a4110f feat(player): add mobile edge adjustments 2026-06-14 13:40:16 +02:00
edde746 e7809b3160 fix(player): harden pinch zoom activation 2026-06-14 11:08:14 +02:00
edde746 edd0618e64 fix: harden playback and discover state 2026-06-14 09:44:04 +02:00
edde746 849e62df7e fix(jellyfin): honor continue watching action
close #1334
2026-06-14 07:25:40 +02:00
edde746 e1c0df3bd7 fix(ci): resolve sanity check failures 2026-06-14 06:55:58 +02:00
edde746 349b5b0513 fix(tv): stop playback behind menu 2026-06-14 06:27:00 +02:00
edde746 3ad049cb28 fix(player): clear stale mpv fallback switch 2026-06-14 00:27:41 +02:00
edde746 656cf1158b fix(libraries): restore tvOS empty tab focus
close #1330
2026-06-14 00:05:44 +02:00
edde746 f3cb508649 fix(player): wait for mpv subtitles after load
close #1331
2026-06-13 23:52:19 +02:00
edde746 c39b5a5cf1 feat(player): show cache limit in perf overlay 2026-06-13 21:21:15 +02:00
edde746 9ff3099a9d fix(player): update MPVKit and stream buffering 2026-06-13 20:51:14 +02:00
edde746 c24a2aade0 fix(images): preserve media image filter quality 2026-06-13 18:19:23 +02:00
edde746 8f862d1ca0 fix(jellyfin): sort series episodes
close #1329
2026-06-13 17:12:48 +02:00
edde746 b56608f94f fix(media): show source rating badges on TV
close #1327
2026-06-13 15:58:36 +02:00
edde746 2876e1bf78 fix(media): catch menu client and refresh failures 2026-06-13 13:25:48 +02:00
edde746 ebd850b0d5 fix(profiles): guard pin dialog route pops 2026-06-13 13:25:41 +02:00
edde746 f3f962a7c9 fix(database): swallow legacy documents lookup failures 2026-06-13 13:25:34 +02:00
edde746 49ae1fdc34 fix(cache): use app-private image cache 2026-06-13 13:25:26 +02:00
edde746 dc769bda41 fix(android): fall back when cronet init fails 2026-06-13 13:23:41 +02:00
edde746 75ba6b0627 fix(player): isolate dispose teardown races 2026-06-13 13:23:29 +02:00
edde746 1e40d21bd8 fix(discovery): handle udp datagram socket errors 2026-06-13 13:23:22 +02:00
edde746 3c43874342 fix(tvos): update mpvkit audio passthrough 2026-06-13 10:24:15 +02:00
edde746 4f422f1485 fix(plex): persist transcode audio stream switches
close #1326
2026-06-13 10:15:08 +02:00
edde746 85d9b6ebab fix(tvos): hide unsupported playback options 2026-06-13 10:13:02 +02:00
edde746 90662e2173 fix(player): scale mpv stream ring buffer for poorly interleaved mp4/mov 2026-06-12 22:21:17 +02:00
edde746 24980aff95 feat(subs): render resolution setting for the avfoundation VO 2026-06-12 21:05:28 +02:00
edde746 804261605b docs(player): note live session and progress tracker client pinning 2026-06-12 17:12:25 +02:00
edde746 8a65c78999 fix(plex): retry related hubs and skip unchanged threshold mirror 2026-06-12 17:12:25 +02:00
edde746 979db254d1 perf(providers): delta-fetch newly-online servers instead of full refetch
Discover and libraries now fetch only the servers that came online since
the last pass and merge the results; already-loaded servers are not
refetched. Both providers also own their online-servers listener
(registered in the constructor, removed in dispose), so profile switches
no longer leak listeners that kept disposed providers refetching.
2026-06-12 17:12:18 +02:00
edde746 18ebe77806 fix(jellyfin): page show/season folder children incrementally 2026-06-12 17:12:09 +02:00
edde746 0de856e929 fix(settings): refuse overlapping runAsync at runtime 2026-06-12 17:12:03 +02:00
edde746 a6d82b4637 fix(livetv): stop abandoned sessions when a tune is superseded or unmounts 2026-06-12 17:11:58 +02:00
edde746 acfb79c857 refactor(livetv): tune protocol methods go private behind the session 2026-06-12 16:18:09 +02:00
edde746 dcfe0f8308 refactor(player): live playback drives LiveTvPlaybackSession 2026-06-12 16:15:20 +02:00
edde746 23a423fe85 refactor(livetv): playback session lifecycle behind LiveTvSupport 2026-06-12 16:06:12 +02:00
edde746 b2cb874b73 fix(discover): refresh content when a server comes online mid-session 2026-06-12 15:56:09 +02:00
edde746 f671f367ea refactor(discover): DiscoverProvider owns hubs and continue-watching 2026-06-12 15:53:26 +02:00
edde746 665603b314 fix(jellyfin): bounded transient retry on hub surfaces 2026-06-12 15:33:58 +02:00
edde746 0d7dd093c4 fix(jellyfin): flip server status when all endpoints are exhausted 2026-06-12 15:26:39 +02:00
edde746 c849a213f0 refactor(net): shared FailoverHttpClient for both backends 2026-06-12 15:22:52 +02:00
edde746 49ff0854c0 refactor(profiles): profile-scoped provider subtree keyed by active profile 2026-06-12 14:44:10 +02:00
edde746 5047057521 test(cache): cover per-backend applyWatchState mutations 2026-06-12 14:35:50 +02:00
edde746 e28e2b9371 refactor(media): folder browsing via MediaServerClient interface 2026-06-12 14:33:38 +02:00
edde746 bb92b6083b refactor(player): clarify requested vs session-effective media source id 2026-06-12 14:21:12 +02:00
edde746 acdae74587 refactor(offline): single connectivity subscription in OfflineModeProvider 2026-06-12 14:18:49 +02:00
edde746 5e4449247b fix(downloads): inject visibility-aware client resolver 2026-06-12 14:16:24 +02:00
edde746 c2fe142293 refactor(watch): WatchActions owns mark-watched orchestration and events 2026-06-12 14:09:21 +02:00
edde746 431b0f9b0c refactor(ui): shared watched indicator overlay 2026-06-12 13:56:47 +02:00
edde746 959e6e9398 refactor(watch): single watched-progress predicate 2026-06-12 13:49:46 +02:00
edde746 18e47d2327 refactor(ui): share android keyboard-mode system-back guard 2026-06-12 13:48:06 +02:00
edde746 b2e5e02204 refactor(library): reuse grid column formula in fetch-size estimate 2026-06-12 13:48:06 +02:00
edde746 e6a488a4e2 fix(settings): resolve tv-dependent defaults at read time 2026-06-12 13:45:37 +02:00
edde746 75d0164a8b fix(offline): persist plex watched threshold for offline use 2026-06-12 13:43:29 +02:00
edde746 05251edaf3 fix(tv): close virtual keyboard when its text field unmounts
The keyboard is a navigator route, so a screen swapping out its form
left it floating over the new content. showTvVirtualKeyboard now
returns a route handle and the field host closes it on dispose.
2026-06-12 13:36:01 +02:00
edde746 892dfee381 fix(tv): keyboard no longer opens over jellyfin quick connect screen
Auto-starting quick connect from inside the probe's runAsync let the
probe's finally clear busy mid-initiate; the re-enabled form then took
focus fallback on the URL field, auto-opening the TV keyboard. Sequence
the two busy scopes and debug-assert against overlapping runAsync.
2026-06-12 13:35:55 +02:00
edde746 7f1eb3a818 fix(focus): back over a dialog no longer pops the underlying screen 2026-06-12 13:19:29 +02:00
edde746 bd0a0c5d6b feat(android): loudness normalization for exoplayer via audiofx
close #1289
2026-06-12 12:38:57 +02:00
edde746 71b05fd4b2 fix(ui): unwatched count badge grows as pill, caps at 999+
close #1310
2026-06-12 09:11:56 +02:00
edde746 587609d7f6 fix(tv): hug poster with focus border and add focus glow toggle
close #1278
2026-06-12 08:50:29 +02:00
edde746 cceba28d0a fix(i18n): request localized Plex labels
close #1311
2026-06-12 07:59:35 +02:00
edde746 03c623e8c7 perf(grids): rebuild media grids on width change, not every scroll tick 2026-06-12 07:18:34 +02:00
edde746 2b34f4a9d7 perf(tv): reduced visual-effects tier for low-end devices 2026-06-12 06:30:38 +02:00
edde746 ad3cc6b986 feat(player): language-first audio & subtitle track labels
close #1307
2026-06-12 04:16:39 +02:00
edde746 133db721a2 fix(livetv): auto-catch-up stale guide window to now
close #1297
2026-06-12 04:13:59 +02:00
edde746 a03d30fa45 fix(android): eliminate per-byte buffer ops in DV bitstream sanitizer 2026-06-12 04:05:35 +02:00
edde746 62abd3c30a feat(jellyfin): simpler, code-first Quick Connect screen 2026-06-12 03:16:58 +02:00
edde746 97ba02f45e fix: flashbang 2026-06-12 02:40:42 +02:00
edde746 0f1bf5e011 perf(jellyfin): skip per-folder user data in folder tree listings 2026-06-12 02:18:28 +02:00
edde746 d816d82a38 fix(folders): row context menus, dpad left to sidebar, faster Jellyfin listings 2026-06-12 02:00:42 +02:00
edde746 c89c88647e fix(android): package libmpv's NDK r29 libc++_shared.so deterministically 2026-06-12 01:51:51 +02:00
edde746 3599b0b0e1 fix(startup): no-servers flash on fresh login + faster connect splash
- start binder before navigating from auth, mark isBinding synchronously
- overlap plex.tv resource refresh with optimistic cached-metadata connect,
  reconcile tokens/membership in background once it lands
- cached endpoint probe gets a head start on the race instead of serially
  blocking it
- defer phase-1 HTTPS upgrade off the splash critical path
- per-server connect progress stream for incremental splash checkmarks
- startup timing instrumentation (bind/fetch/race/connect elapsedMs)
2026-06-12 01:46:43 +02:00
edde746 1adb89c6b3 feat(android): frame-accurate libass pipeline + optimized native core 2026-06-12 01:45:19 +02:00
edde746 7ec1c2ebdc fix(player): anchor libass overlay to video rect for correct zoom/cover positioning 2026-06-11 12:49:21 +02:00
edde746 7ec062deaf fix(player): pinch zoom flicker from per-tick relayout and filter churn
close #1261
2026-06-11 12:29:34 +02:00
edde746 a5c7d5b52a refactor(watch-together): host-authoritative declarative sync protocol
Replaces the imperative play/pause/seek/positionSync message soup with a
single host-authored PlaybackState (seq-ordered, anchor-extrapolated,
phase machine: loading/waitingForPeers/paused/playing) that doubles as
the heartbeat, plus guest status reports and host-applied control
requests. Fixes the guest seek-back loop while the host loads (readiness
was keyed on a pre-load !buffering snapshot and heartbeats broadcast
frozen positions), adds real group buffering coordination (stall grace,
scheduled simultaneous resumes, 15s safety timeout), rate-nudge drift
correction with passthrough-aware seek fallback, session-scoped message
handling (no lost messages during episode-switch detach gaps), and an
expected-state ledger replacing the racy remote-action flag.
2026-06-11 09:42:38 +02:00
edde746 e54bbd11f1 feat(player): passthrough state tracking + sync-layer position/passthrough getters
Includes the mpv passthrough rate-suspend wiring (passthrough cannot
scaletempo; suspended while rate != 1.0) that the new getters report.
2026-06-11 09:16:01 +02:00
edde746 38afdab2e4 fix: support mx player variants
close #1304
2026-06-11 08:35:19 +02:00
edde746 f214c50c15 fix(player): responsive double-tap skip stacking
single tap stacks while the skip pill is visible, feedback shows before
the seek (instant on transcode restarts), fade-out hide timer is tracked so
a re-shown pill isn't killed mid-spree, and tap pairing uses the platform
kDoubleTapTimeout
2026-06-11 07:15:31 +02:00
edde746 1fc081ed6b fix(player): eager timeline scrub gesture and stable slider tree
close #1302
2026-06-11 07:15:31 +02:00
edde746 cbaed8cdfb fix(livetv): tune current guide programs on tv
close #1298
2026-06-11 07:15:31 +02:00
github-actions[bot] eac0e26696 chore: update cask to 2.6.0 2026-06-10 19:45:34 +00:00
github-actions[bot] 77af810d1f chore: bump version to 2.6.0 2026-06-10 19:04:45 +00:00
edde746 1292d7ac34 fix(dovi): bump MPVKit to v1.0.2 for profile 8 DV hardware decode
close #1262
2026-06-10 20:26:11 +02:00
edde746 e75ab94eb9 fix(tv): make osk search key focus results
The TV keyboard dialog froze the field's callbacks at open time, so the
search screen's done key fell through to unfocus() once results arrived.
Resolve callbacks against the latest field widget at invoke time, and
give the search screen a TV submit handler that focuses the first result
or flushes the pending debounce.
2026-06-10 16:37:35 +02:00
edde746 804bea8e9a fix(android): keep only the active dynamic HDR metadata in DV bitstreams
close #1296
2026-06-10 15:39:51 +02:00
edde746 0502dd7ba0 fix(discover): resolve hero watch state in build 2026-06-10 15:10:52 +02:00
edde746 3221a0cb1a feat: add continue watching details navigation
close #1295
2026-06-10 12:50:32 +02:00
edde746 e7efa458e5 fix(images): preserve fallback placeholder size
close #1293
2026-06-10 09:55:22 +02:00
edde746 9b48f26463 refactor(watch): shared point-of-use resolution across consumers
Adds context.withFreshWatchState/readFreshWatchState and sweeps the
remaining session-critical readers: context menu actions, folder tree
rows, discover hero, and TV spotlight minutes-left.
2026-06-10 05:38:33 +02:00
edde746 903b8eced8 refactor(detail): drop hand-patched watch state for store resolution
Delete the per-event list patching, parent-count adjustment, and the
screen-local progress overlay; rows and the hero resolve against
WatchStateStore at build. Also backfill parentId on playable-descendant
pages so hierarchy resolution covers season detail pages.
2026-06-10 05:30:52 +02:00
edde746 3753967caf fix(watch): resolve watch state per item at cards and playback open
Cards consult the hierarchy-aware store so container marks flip episode
rows instantly and a stale per-episode patch can no longer shadow a newer
show/season mark; playback opens with the session-fresh resume offset.
2026-06-10 05:23:09 +02:00
edde746 516925cf50 refactor(watch): promote overlay to hierarchy-aware WatchStateStore
Resolution now considers parentChain ancestors (newest event wins) and
watched patches set container leaf counts, so container marks reach
descendant cards and vice versa.
2026-06-10 05:18:02 +02:00
edde746 cd9498abb8 refactor(player): centralize playback opening
close #1280
2026-06-10 02:26:26 +02:00
edde746 18642cae15 fix(tv): correct grid calculation
close #1288
2026-06-09 12:47:47 +02:00
edde746 92063bd58f fix(playback): skip redundant mark-watched
close #1287
2026-06-09 12:19:27 +02:00
edde746 726281ed44 fix(tvos): top shelf sync 2026-06-09 08:06:42 +02:00
edde746 e02daa89b8 fix(tvos): stop Home nav auto-playing continue watching
close #1281
2026-06-09 06:10:22 +02:00
edde746 7de7802368 fix(hubs): keep recently-added detail scoped to its library
close #1282
2026-06-08 22:03:28 +02:00
edde746 d44a8994ce fix(discover): open server tasks with remote
close #1272
2026-06-08 08:23:57 +02:00
edde746 42d9168941 fix(player): stabilize mobile content strip 2026-06-08 00:50:22 +02:00
edde746 38616770e5 fix(i18n): localize season & episode labels in downloads tree 2026-06-07 21:46:44 +02:00
edde746 bf7558f80d fix(i18n): localize generic season names from server
close #1271
2026-06-07 21:46:24 +02:00
edde746 381e82d61a fix(detail): highlight next unwatched ep when not in continue watching
close #1269
2026-06-07 18:42:41 +02:00
edde746 9cebcb97c5 feat(plex): add Plays and User Rating sort options
close #1264
2026-06-07 17:50:36 +02:00
edde746 eef73e6447 fix: expand home hub previews
close #1268
2026-06-07 17:35:56 +02:00
edde746 e8df18e7b2 fix(jellyfin): order continue watching + up next by recency 2026-06-07 16:34:18 +02:00
github-actions[bot] 54512b8f93 chore: update cask to 2.5.0 2026-06-07 12:45:47 +00:00
github-actions[bot]andedde746 1e0988e381 chore: bump version to 2.5.0 2026-06-07 14:19:17 +02:00
edde746 8efd8f1dc8 fix(jellyfin): upload artwork as binary 2026-06-07 13:32:07 +02:00
edde746 38868e0078 fix: use device name for companion remote
close #1256
2026-06-07 12:58:13 +02:00
edde746 273d0c7985 fix(back): don't short-circuit side-nav back chain to home
The side-nav host's onSystemBack called _handleMainBack on the system
route-pop path, jumping straight to the home tab and skipping the dpad
back chain (content -> top tabs -> sidebar). Restore the original inert
behavior: canPop:false blocks the route-pop and the key path owns the
chain; the host still closes an open sheet on back. Bottom-nav unchanged.
2026-06-07 10:05:57 +02:00
edde746 4728564e0c refactor(back): host-owned system back in main_screen
Both main_screen hosts (side-nav + bottom-nav) drop their PopScopes and
pass canPop:false + onSystemBack to OverlaySheetHost, so a system back
with a sheet open closes the sheet instead of running tab-nav/exit. Key
(dpad) back path unchanged; onSystemBack mirrors it for a pure popRoute.
2026-06-07 10:05:57 +02:00
edde746 489bf914af refactor(back): host-owned sheet back in hub + media detail
hub_detail and media_detail (TV + non-TV) drop their hand-rolled PopScopes
and pass canPop/onSystemBack to OverlaySheetHost. Keeps appbar-focus-first
and Android-keyboard block behavior; fixes system-back-with-sheet-open
leaking to a screen pop. iOS swipe-back preserved.
2026-06-07 09:20:02 +02:00
edde746 248b5fd91a refactor(back): adopt host-owned sheet back in watch-together + player
watch_together, video_player, mobile_remote and live_tv_show_schedule now
pass canPop/onSystemBack to OverlaySheetHost instead of hand-rolling a
PopScope. Restores the iOS swipe-back on watch_together and centralizes
the sheet-close-on-back logic.
2026-06-07 09:13:49 +02:00
edde746 bbf7b1f9da feat(overlay-sheet): opt-in PopScope so the host owns system back
Adds nullable canPop + onSystemBack to OverlaySheetHost. When canPop is
set, the host installs its own PopScope that closes an open sheet on a
system/route back instead of popping the screen; null keeps today's
behavior. Lets screens drop their hand-rolled sheet-back PopScopes.
2026-06-07 09:06:29 +02:00
edde746 83899a38f3 fix(tvos): sync version 2026-06-07 08:51:02 +02:00
edde746 cda4c1e78a fix(watch-together): enable dpad long-press to open recent room actions
close #1259
2026-06-07 08:51:02 +02:00
edde746 01e5814943 fix(video): consolidate auto-skip cancellation 2026-06-07 06:13:14 +02:00
edde746 3cca29512d fix: bump MPVKit to v1.0.1 for profile 8 Dolby Vision decode
Routes all single-layer DV profile 8 (incl. compat 0) through the Apple
DV decode path, so VideoToolbox decodes it in hardware as true Dolby
Vision instead of failing over to software HDR10. Refs #1262.
2026-06-06 16:06:31 +02:00
edde746 8139fc2b17 fix(tvos): make HDR toggle disable Dolby Vision
HDR-off now forces SDR display criteria for DV like it does for HDR10,
and the runtime toggle re-applies the criteria. Refs #1262 (part A).
2026-06-06 14:17:52 +02:00
edde746 1ef4032666 feat: add startup section setting
close #1240
2026-06-06 13:08:02 +02:00
edde746 4c4b18e2cd fix(i18n): localize hardcoded UI strings
close #1257
2026-06-06 11:45:34 +02:00
edde746 f74314bbf0 fix(livetv): debounce rapid time-shift skips to stop overshoot 2026-06-06 10:48:51 +02:00
edde746 32421e4c84 fix(tv): reach app bar actions via up from tabs
close #1202
2026-06-06 08:51:08 +02:00
edde746 d90cc61541 fix(tv): symmetric full-card focus glow via overlay
close #1231
2026-06-06 05:09:07 +02:00
edde746 6b3e49d410 fix(ios): recover frozen video after Control Center
close #1232
2026-06-06 00:49:19 +02:00
edde746 a9b812f66c fix(test): restore CI unit tests 2026-06-05 20:34:44 +02:00
edde746 0fd0506551 feat(media): lazily page detail episodes
close #1245
2026-06-05 19:26:02 +02:00
edde746 e6ca5f254d fix(ios): correct anamorphic video aspect ratio
close #1244
2026-06-05 10:15:03 +02:00
edde746 de3219abb9 fix: align bottom sheet back header 2026-06-05 06:30:26 +02:00
edde746 8201d8dcda feat(libraries): add mobile browse options sheet 2026-06-04 20:41:34 +02:00
edde746 aed420525c feat(nav): move mobile settings to home menu 2026-06-04 16:41:02 +02:00
edde746 75b1c86306 feat(ui): add m3 app menus 2026-06-04 16:22:31 +02:00
edde746 43012da2b8 feat(tvos): add top shelf support 2026-06-04 15:38:12 +02:00
edde746 145881318e fix: harden server-scoped state 2026-06-04 13:47:06 +02:00
edde746 79c06b82d3 fix(jellyfin): align default track selection 2026-06-04 10:35:14 +02:00
edde746 e5bbdc06f9 feat(jellyfin): show media quality labels 2026-06-04 09:56:10 +02:00
edde746 0e7ff609b6 feat(media): show video and audio quality labels
close #1115
2026-06-04 08:20:12 +02:00
edde746 f3e108e867 fix(tvos): suppress duplicate select playback 2026-06-03 17:31:15 +02:00
edde746 92bfae322e fix(tvos): handle play pause remote
close #1230
2026-06-03 14:19:23 +02:00
edde746 08491511e9 fix(video): prevent play next EOF loop
close #1228
2026-06-02 15:52:14 +02:00
edde746 24b5080c38 fix(video): stabilize player chrome overlays 2026-06-02 14:20:24 +02:00
edde746 985c279b1f fix(exoplayer): correct transcode seek position double-count 2026-06-02 06:23:08 +02:00
edde746 cb2e56442d fix(subtitles): show search without subtitle tracks
close #1219
2026-06-01 19:08:17 +02:00
edde746 a425798bae fix(video): iOS video zoom 2026-06-01 15:21:41 +02:00
edde746 1020188883 fix(video): restore stretch fit startup 2026-06-01 12:40:59 +02:00
edde746 caf192fd78 fix(jellyfin): defer initial URL keyboard
close #1217
2026-06-01 11:49:21 +02:00
edde746 74b8dc4561 refactor: type server identifiers 2026-06-01 11:06:03 +02:00
edde746 7b59dac26b fix: stabilize detail action focus
close #1218
2026-06-01 08:34:38 +02:00
edde746 09f5121732 feat(metadata): add Jellyfin edit support 2026-06-01 08:34:38 +02:00
edde746 8f9d20a2e4 feat(jellyfin): add extras support 2026-06-01 08:34:38 +02:00
edde746 273f129b68 fix(i18n): remove unsupported newline guidance 2026-06-01 08:34:38 +02:00
github-actions[bot] 483c0b504e chore: update cask to 2.4.1 2026-06-01 01:13:01 +00:00
github-actions[bot] 7c38668a51 chore: bump version to 2.4.1 2026-06-01 00:56:52 +00:00
edde746 eba553413c fix(macos): allow LuaJIT under hardened runtime
close #1214
2026-06-01 02:46:37 +02:00
edde746 f8658027b8 fix(android): guard display HDR mode API
close #1215
2026-06-01 02:46:37 +02:00
github-actions[bot] 58bceb29cb chore: update cask to 2.4.0 2026-05-31 19:54:37 +00:00
github-actions[bot] c5d8f9c62b chore: bump version to 2.4.0 2026-05-31 19:30:17 +00:00
edde746 4ffa3d30f6 fix(player): preserve shader toggle preset
close #1208
2026-05-31 21:25:46 +02:00
edde746 d5c8778074 fix: harden reconnect and playback regressions 2026-05-31 20:33:38 +02:00
edde746 c6c76d6828 fix(dialogs): own text input resources 2026-05-31 15:34:00 +02:00
edde746 9cfc6c0f4a fix(tv): stabilize osk dismissal 2026-05-31 15:22:13 +02:00
edde746 2eae450aa0 fix(jellyfin): preserve add screen dpad focus 2026-05-31 15:00:35 +02:00
edde746 1b5334e72f fix(jellyfin): harden local discovery flow 2026-05-31 14:33:31 +02:00
edde746 e3db54940d feat(jellyfin): add local server discovery
close #1188
2026-05-31 10:51:08 +02:00
edde746 656d512858 ci: add build platform toggles 2026-05-31 09:26:55 +02:00
edde746 d9135e0d25 fix(player): log display HDR capabilities 2026-05-31 08:48:21 +02:00
edde746 4f636dfffd fix(ci): install libmpv scripting deps 2026-05-31 07:54:37 +02:00
edde746 6438b444bf fix(player): clarify Dolby Vision playback logging 2026-05-31 06:56:18 +02:00
edde746 8413e1e5a7 fix(player): reduce sync offset dpad step 2026-05-31 06:38:25 +02:00
edde746 5671f6e5d1 fix(plex): send playback session stream header 2026-05-31 06:30:51 +02:00
edde746 a4a0475fdb fix(player): let macOS shortcuts pass through
close #1206
2026-05-31 03:03:52 +02:00
edde746 9a740b91c1 fix(plex): select available playback version 2026-05-31 02:21:15 +02:00
edde746 63ab80fa82 fix(player): switch refresh rate before ExoPlayer open 2026-05-31 00:28:54 +02:00
edde746 0bb085c70c feat(macos): support start in fullscreen setting
close #1200
2026-05-30 23:58:38 +02:00
edde746 9825ce3c7c fix: select first playable Plex Part instead of always Part[0]
close #1201
2026-05-30 21:37:01 +02:00
edde746 f5012b36ea fix(media-detail): collapse action buttons that overflow small screens 2026-05-30 17:20:16 +02:00
edde746 6111c381af fix(libraries): reload when a server connects after initial load 2026-05-30 14:01:01 +02:00
edde746 00fce3a997 fix(downloads): cap aggregate progress below 100% until all episodes complete
Rounding the smoothed per-episode progress could fill the ring to 100% while a download was still finishing; only report 100% once every queued episode is complete. Also drop the stale episode-count diagnostic log that read leafCount/_totalEpisodeCounts solely for logging after the denominator change.
2026-05-30 08:48:44 +02:00
edde746 b8417f0277 style(downloads): apply dart format to download_utils.dart
Merged via #1195 without formatting; aligns it with the repo's dart format pre-commit gate.
2026-05-30 08:48:03 +02:00
DarkmaddaandGitHub 4148c1c8ff fix(downloads): scope show progress to queued episodes; manage completed downloads (#1195)
Progress ring now measures completion against the episodes actually queued (episodes.length) instead of the show's full leafCount, and advances smoothly using per-episode progress. The completed show/season download button reopens the download-options dialog (with a Delete row) so users can fetch more episodes or switch to sync instead of only deleting.
2026-05-30 08:40:17 +02:00
edde746 9e5098f356 fix(trackers): respect server completion threshold
close #1194
2026-05-30 08:30:06 +02:00
edde746 139eed9ca9 fix(templates): require substantive issue-form fields 2026-05-30 07:33:06 +02:00
23efb02636 fix(browse): keep show in browse after deleting its downloads
The browse grid listens for DeletionEvents and decrements each show's
leafCount, evicting the show when the count hits zero. Download-only
deletions (isDownloadOnly: true) fired one event per episode, so deleting
every downloaded episode of a show drove its leafCount to zero and removed
it from browse - even though the content still exists on the server.

Browse is online-only (the Libraries tab is hidden when offline) and always
reflects server-side content, so it must ignore download-only deletions.
Add a guard at the top of onDeletionEvent, matching the existing
isDownloadOnly contract already honored by media_detail_screen.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:20:54 +02:00
Timo KoxandGitHub 294996eac1 feat(player): add end-of-video sleep timer option (#1111)
close #540
2026-05-30 07:16:01 +02:00
edde746 a4f0b92b4e fix(windows): stop taskbar icon flashing on minimize after playback 2026-05-30 03:01:48 +02:00
Micah MorrisonandGitHub 7cac050500 feat(playback): add option to exit fullscreen on player close (#1174)
Adds a setting to automatically exit fullscreen when the video player is closed.
2026-05-30 02:38:35 +02:00
edde746 1685ecc991 fix(playback): prevent native subtitle autoselect 2026-05-30 00:05:29 +02:00
edde746 a71711de5f fix(ci): update database schema expectation 2026-05-29 23:45:01 +02:00
edde746 7534a6c60a fix(downloads): apply progress watch snapshots 2026-05-29 21:37:27 +02:00
edde746 dba01f14bb fix(playback): harden watch progress edge cases 2026-05-29 21:16:01 +02:00
edde746 d93ea9813f fix(playback): harden offline source reporting 2026-05-29 19:55:01 +02:00
edde746 7501f461b9 fix(playback): sync downloaded watch progress
close #1171, close #1183
2026-05-29 19:55:01 +02:00
edde746 5e5702c961 fix: resume playback after iOS audio route changes
close #1185
2026-05-29 11:04:27 +02:00
edde746 4b477a4fe3 build(tvos): bump engine to 3.44.0+3 2026-05-29 08:29:18 +02:00
edde746 50ff514be8 feat(tv): wrap on-screen keyboard focus at edges 2026-05-29 05:03:10 +02:00
edde746 1c7a083d6c build(linux): enable lua and javascript in libmpv 2026-05-29 03:32:45 +02:00
edde746 097e48d705 fix(tv): trap d-pad focus at button row edges
close #1181
2026-05-29 03:31:53 +02:00
edde746 a931d5910b fix(tv): let text fields fall back to directional traversal
close #1176
2026-05-29 03:30:54 +02:00
edde746 1cd585eecb feat(android): sync external player progress
close #1175
2026-05-29 00:58:45 +02:00
edde746 9981b277c6 test: cover jellyfin trickplay aspect ratio 2026-05-29 00:18:53 +02:00
edde746 2b7ea5fe02 feat(video): add custom zoom controls
close #1159
2026-05-29 00:06:47 +02:00
edde746 47276e1bac feat(tv): add optional full-card layouts 2026-05-28 22:33:06 +02:00
edde746andGitHub c8670a8d6a Merge pull request #1178 from micahmo/fix/plex-bif-aspect-ratio
Fix scrub preview aspect ratio
2026-05-28 22:05:37 +02:00
Micah Morrison 9cc32e125b Fix BIF scrub preview aspect ratio and raise desktop size limit 2026-05-28 15:46:08 -04:00
edde746 4217da1a8f fix(android): recover ExoPlayer audio failures 2026-05-28 09:31:19 +02:00
edde746 704c37f97f fix(ui): tighten media detail spacing 2026-05-28 08:16:27 +02:00
edde746 0c8e0fc2ec fix(i18n): update Bulgarian generated strings 2026-05-28 05:55:55 +02:00
edde746 d6315a0f75 feat(desktop): improve snackbar behavior 2026-05-28 04:48:36 +02:00
edde746 92cdbdd723 chore: remove local MPVKit checkout 2026-05-28 04:07:27 +02:00
edde746andDimitar Topper Maznekov 3939ba809f feat(i18n): add Bulgarian language
Co-authored-by: Dimitar "Topper" Maznekov <23097319+TopperBG@users.noreply.github.com>
2026-05-28 04:01:37 +02:00
edde746 6642348a96 feat(jellyfin): support multiple server urls 2026-05-28 03:43:46 +02:00
edde746 0b29673a5e fix(plex): use provider continue watching hub
close #1166
2026-05-28 01:29:29 +02:00
edde746 3747a74cb3 fix(pip): guard callbacks after dispose 2026-05-28 01:14:38 +02:00
edde746 bcb7f1d3c1 fix(android): disable flutter deeplink routing 2026-05-28 00:48:59 +02:00
edde746 7b56b90f24 fix(main): guard tvOS menu updates after dispose 2026-05-28 00:37:07 +02:00
edde746 ba62129414 fix(playback): support Apple DV conversion 2026-05-28 00:33:20 +02:00
edde746 e559a6125d fix(detail): isolate overlay sheet scrolling 2026-05-28 00:29:13 +02:00
edde746 869b4fe230 chore: bump MPVKit 2026-05-28 00:14:06 +02:00
edde746 3b4110d7e5 fix(android): improve Dolby Vision fallback 2026-05-27 21:02:48 +02:00
edde746 7b4e399013 chore: bump MPVKit 2026-05-27 06:52:03 +02:00
edde746 5788d93e89 feat(profiles): sort by recent use
close #1151
2026-05-27 06:52:03 +02:00
edde746 08a806e457 fix(playback): restore system ui after video
close #1152
2026-05-27 06:52:03 +02:00
edde746 e45bfadc14 feat(libraries): add nav quick picker
close #1144
2026-05-27 06:52:03 +02:00
edde746 56cfb95af5 fix(playback): apply default quality to jellyfin
close #1150
2026-05-27 00:02:23 +02:00
github-actions[bot] 8e8942fb36 chore: update cask to 2.3.0 2026-05-26 21:34:32 +00:00
github-actions[bot] 02f2657d18 chore: bump version to 2.3.0 2026-05-26 21:13:32 +00:00
edde746 39709e5c42 test(tv): update keyboard focus expectations 2026-05-26 23:00:44 +02:00
edde746 a101ee76d6 fix(downloads): prevent duplicate native tasks
close #1141
2026-05-26 22:43:05 +02:00
edde746 4eadec5b17 fix(tv): pass root menu to system 2026-05-26 18:27:26 +02:00
edde746 be86a33057 fix(discover): hide duplicate continue watching
close #1113
2026-05-26 14:48:16 +02:00
edde746 ce19b9dfa7 fix(ui): theme recommendation text colors 2026-05-26 14:05:26 +02:00
edde746 15cc85147a fix(tv): improve virtual keyboard input 2026-05-26 14:01:20 +02:00
edde746 3fc3baef83 fix(tv): reveal details without input 2026-05-26 12:02:48 +02:00
edde746 91b926acac fix(android): respect vtt subtitle position 2026-05-26 06:34:59 +02:00
edde746 8b2ff18842 fix(android): improve mpeg-ts seeking 2026-05-26 03:14:19 +02:00
edde746 1958f8ca7e feat(jellyfin): add folder browsing and playback 2026-05-26 01:39:39 +02:00
edde746 8e53232961 fix(jellyfin): avoid side-loading negotiated subtitles 2026-05-25 23:39:55 +02:00
edde746 cec8993b80 fix: update win_http 2026-05-25 23:19:59 +02:00
edde746 366ae67341 feat(jellyfin): add unwatched library filter
close #1116
2026-05-25 22:43:39 +02:00
edde746 50be8565d9 fix(tv): prefer show art for episode backdrops
close #1123
2026-05-25 21:08:33 +02:00
edde746 9365fcf246 fix(tv): keep alpha bar alphabet visible 2026-05-25 16:17:27 +02:00
edde746 8d4ef2757a fix(ios): update MPVKit PiP subtitles 2026-05-25 15:31:41 +02:00
edde746 6b147e2053 fix(ios): enable PiP subtitles 2026-05-25 04:47:55 +02:00
1839 changed files with 371050 additions and 67586 deletions
+7
View File
@@ -0,0 +1,7 @@
**
!.maestro/
!.maestro/jellyfin-demo/
!.maestro/jellyfin-demo/**
!scripts/
!scripts/maestro_fixtures.py
!scripts/maestro_real_jellyfin.py
+14
View File
@@ -1,3 +1,17 @@
*.sh text eol=lf
*.Dockerfile text eol=lf
Dockerfile text eol=lf
*.bat text eol=lf
# Byte-exact generated Dart checked by scripts/check_codegen.py.
lib/data/ducet_order.dart text eol=lf
lib/data/hid_key_labels.dart text eol=lf
lib/data/iso_639_data.dart text eol=lf
lib/**/*.g.dart text eol=lf
lib/**/*.freezed.dart text eol=lf
# Byte-exact wakelock inputs hashed in packages/wakelock_plus/provenance.json.
packages/wakelock_plus/pigeons/messages.dart text eol=lf
packages/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt text eol=lf
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h text eol=lf
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/messages.g.m text eol=lf
+4
View File
@@ -82,6 +82,8 @@ body:
options:
- Jellyfin
- Plex
validations:
required: true
- type: input
id: device
@@ -89,6 +91,8 @@ body:
label: Device Information
description: What device are you using?
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
validations:
required: true
- type: textarea
id: logs
+7 -2
View File
@@ -41,6 +41,8 @@ body:
1. As a user, I would...
2. This would help me...
3. The benefit would be...
validations:
required: true
- type: dropdown
id: platform
@@ -57,17 +59,20 @@ body:
- tvOS
- Linux
- All platforms
validations:
required: true
- type: dropdown
id: media-server
attributes:
label: Media Server
description: Is this for Jellyfin, Plex, or both?
description: Which media server(s) is this for?
multiple: true
options:
- Jellyfin
- Plex
- Both
validations:
required: true
- type: textarea
id: additional
@@ -0,0 +1,46 @@
name: Set up Flutter from git
description: >-
Clone the pinned Flutter SDK from its release tag and put it on PATH, for
runners without a published archive. Flutter ships no windows-arm64 SDK, so
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
in the stable manifest) and `channel: master` would clone master HEAD, whose
engine is not the patched revision install-patched-engine.ps1 asserts
(4c525dac). This file is the only place that pin lives: the tag is fetched so
the SDK reports its own version, then verified against the immutable commit
and against the version Flutter itself reports, so a moved tag fails the job
instead of quietly changing SDKs.
runs:
using: composite
steps:
- name: Clone Flutter from its immutable commit
shell: pwsh
run: |
$version = "3.44.0"
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
$root = "$env:RUNNER_TEMP\flutter"
git init $root
git -C $root remote add origin https://github.com/flutter/flutter.git
git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"
git -C $root checkout --detach "refs/tags/$version"
$actualCommit = git -C $root rev-parse HEAD
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
throw "Flutter $version resolved to $actualCommit, expected $expectedCommit"
}
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
& "$root\bin\flutter.bat" --version
if ($LASTEXITCODE -ne 0) {
throw "Unable to bootstrap the Flutter SDK"
}
$versionOutput = & "$root\bin\flutter.bat" --version --machine
if ($LASTEXITCODE -ne 0) {
throw "Unable to resolve the Flutter SDK version"
}
$versionJson = $versionOutput -join "`n"
if ([string]::IsNullOrWhiteSpace($versionJson)) {
throw "Flutter did not report machine-readable version JSON"
}
$reportedVersion = ($versionJson | ConvertFrom-Json).frameworkVersion
if ($reportedVersion -ne $version) {
throw "Flutter reported version $reportedVersion, expected $version"
}
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
File diff suppressed because it is too large Load Diff
+454 -48
View File
@@ -4,11 +4,16 @@ 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:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
jobs:
analyze:
name: Code Analysis
@@ -17,18 +22,20 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: "3.44.0"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
@@ -39,44 +46,31 @@ jobs:
flutter pub get
- name: Install wakelock_plus development dependencies
working-directory: packages/wakelock_plus
run: flutter pub get --enforce-lockfile --no-example
- name: Verify generated files committed
run: |
scripts/codegen.sh
if ! git diff --exit-code lib/; then
echo "::error::Generated files (.g.dart / .freezed.dart) are out of date."
echo "Run 'scripts/codegen.sh' and commit the result."
exit 1
fi
run: scripts/codegen.sh --check
- name: Verify translation hygiene
run: python3 scripts/clean_translations.py --check --strict
- name: Verify workflow and script guards
run: bash scripts/ci_guard_checks.sh
- name: Verify formatting
run: |
# Find all Dart files excluding generated files
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do
files_found=true
break
done
if [ "$files_found" != "true" ]; then
echo "No Dart files found to format"
exit 0
fi
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
- name: Analyze code
run: |
# Run flutter analyze
# Fails on errors or warnings only (info messages are allowed)
flutter analyze 2>&1 | tee analyze_output.txt || true
paths=(lib)
[ ! -d test ] || paths+=(test)
find "${paths[@]}" -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f -print0 |
xargs -0 -r dart format --output=none --set-exit-if-changed
# Check output for errors or warnings
if grep -q "error •" analyze_output.txt; then
echo "❌ Analysis failed with errors"
exit 1
elif grep -q "warning •" analyze_output.txt; then
echo "⚠️ Analysis completed with warnings"
exit 1
else
echo "✅ Analysis passed!"
exit 0
fi
- name: Verify icon consistency
run: dart run scripts/check_icon_consistency.dart
- name: Analyze code
run: dart run scripts/check_analyzer.dart
- name: Check for unused code
run: |
@@ -109,18 +103,20 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: "3.44.0"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
@@ -134,11 +130,100 @@ jobs:
- name: Run tests
run: |
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
flutter test
scripts/run_tests.sh
else
echo "No tests found, skipping test execution"
fi
- name: Install wakelock_plus test dependencies
working-directory: packages/wakelock_plus
run: flutter pub get --enforce-lockfile
- name: Run wakelock_plus VM tests
working-directory: packages/wakelock_plus
run: flutter test test/wakelock_plus_linux_plugin_test.dart
- name: Run wakelock_plus Chrome tests
working-directory: packages/wakelock_plus
run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart
# The vendored atomic-write patch has to keep the upstream contract, not
# just the new behaviour. Upstream's own suites are the check for that.
- name: Run the vendored desktop preference store tests
run: |
for pkg in shared_preferences_linux shared_preferences_windows; do
(cd "packages/$pkg" && flutter pub get --enforce-lockfile && flutter test)
done
android-test:
name: Android JVM and Native Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- 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
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install dependencies
run: flutter pub get
- name: Configure Android local properties
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
- name: Configure Android host native tests
run: |
cmake -S android/app/src/test/cpp -B build/android-host-tests \
-DCMAKE_BUILD_TYPE=Debug
- name: Build Android host native tests
run: cmake --build build/android-host-tests --parallel 2
- name: Run Android host native tests
run: |
ctest --test-dir build/android-host-tests \
--output-on-failure --no-tests=error
- name: Run Android JVM unit tests
working-directory: android
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
- name: Check Android API compatibility
working-directory: android
run: ./gradlew :app:lintDebug
native-format:
name: Native Formatting
runs-on: ubuntu-latest
@@ -146,10 +231,12 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: "temurin"
java-version: "17"
@@ -157,6 +244,283 @@ jobs:
- name: Verify native formatting
run: scripts/format_native.sh --check
- name: Verify Linux native acquisition integrity
run: bash linux/packaging/build-libmpv_test.sh
linux-native-test:
name: Linux native reliability (${{ matrix.sanitizer }})
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- sanitizer: address
lifecycle_sanitizers: ON
- sanitizer: thread
lifecycle_sanitizers: OFF
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
- name: Install Linux native test dependencies
run: |
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
- name: Prepare Flutter Linux configuration
run: |
flutter pub get --enforce-lockfile --no-example
flutter build linux --debug --config-only --no-pub
- name: Configure Linux native reliability tests
run: |
cmake -S linux -B build/linux-native-${{ matrix.sanitizer }} -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DPLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS=ON \
-DPLEZY_MPV_LIFECYCLE_SANITIZERS=${{ matrix.lifecycle_sanitizers }} \
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
- name: Build Linux native reliability tests
run: |
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
mpv_player_lifecycle_test \
mpv_property_result_contract_test \
mpv_gpu_bootstrap_test
- name: Run Linux native reliability tests
run: |
ctest --test-dir build/linux-native-${{ matrix.sanitizer }} \
--output-on-failure --no-tests=error
apple-native-test:
name: Apple native reliability (${{ matrix.platform }})
runs-on: macos-26
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- platform: iOS
project_directory: ios
workspace: ios/Runner.xcworkspace
simulator_runtime: iOS
simulator_platform: iOS
static_destination: ""
- platform: macOS
project_directory: macos
workspace: macos/Runner.xcworkspace
simulator_runtime: ""
simulator_platform: ""
static_destination: platform=macOS
- platform: tvOS
project_directory: tvos
workspace: tvos/Runner.xcworkspace
simulator_runtime: tvOS
simulator_platform: tvOS
static_destination: ""
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
- name: Install locked Dart dependencies
run: flutter pub get --enforce-lockfile --no-example
- name: Record committed CocoaPods lockfile
if: matrix.platform != 'tvOS'
env:
PODFILE_LOCK: ${{ matrix.project_directory }}/Podfile.lock
run: |
test -f "$PODFILE_LOCK"
shasum -a 256 "$PODFILE_LOCK" > "$RUNNER_TEMP/plezy-podfile-lock.sha256"
- name: Prepare iOS Flutter build settings
if: matrix.platform == 'iOS'
run: flutter build ios --config-only --simulator --debug --no-pub
- name: Prepare macOS Flutter build settings
if: matrix.platform == 'macOS'
run: flutter build macos --config-only --debug --no-pub
- name: Prepare tvOS Flutter engine
if: matrix.platform == 'tvOS'
run: tvos/scripts/fetch_engine.sh
- name: Verify Flutter configuration preserved CocoaPods lockfile
if: matrix.platform != 'tvOS'
run: shasum -a 256 --check "$RUNNER_TEMP/plezy-podfile-lock.sha256"
- name: Install locked CocoaPods dependencies
if: matrix.platform != 'tvOS'
working-directory: ${{ matrix.project_directory }}
run: pod install --deployment
- name: Install tvOS CocoaPods dependencies
if: matrix.platform == 'tvOS'
run: tvos/scripts/pod_install.sh
- name: Verify tvOS project wiring
if: matrix.platform == 'tvOS'
run: ruby tvos/scripts/test_wire_mpv.rb
- name: Select Apple test destination
env:
SIMULATOR_RUNTIME: ${{ matrix.simulator_runtime }}
SIMULATOR_PLATFORM: ${{ matrix.simulator_platform }}
STATIC_DESTINATION: ${{ matrix.static_destination }}
run: |
python3 - <<'PY'
import json
import os
import subprocess
destination = os.environ["STATIC_DESTINATION"]
if not destination:
runtime_name = os.environ["SIMULATOR_RUNTIME"]
payload = json.loads(
subprocess.check_output(
["xcrun", "simctl", "list", "devices", "available", "-j"],
text=True,
)
)
devices = [
device
for runtime, candidates in payload["devices"].items()
if f".{runtime_name}-" in runtime
for device in candidates
if device.get("isAvailable", False)
]
if not devices:
raise SystemExit(f"no available {runtime_name} simulator")
destination = (
f"platform={os.environ['SIMULATOR_PLATFORM']} Simulator,"
f"id={devices[0]['udid']}"
)
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
output.write(f"APPLE_TEST_DESTINATION={destination}\n")
PY
- name: Run Apple native reliability tests
run: |
xcodebuild test \
-workspace "${{ matrix.workspace }}" \
-scheme Runner \
-configuration Debug \
-destination "$APPLE_TEST_DESTINATION" \
-disableAutomaticPackageResolution \
CODE_SIGNING_ALLOWED=NO \
COMPILER_INDEX_STORE_ENABLE=NO
windows-native-test:
name: Windows native reliability (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-latest
flutter_setup: action
- arch: arm64
runner: windows-11-arm
flutter_setup: git
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install 7-Zip
if: matrix.arch == 'arm64'
shell: pwsh
run: choco install 7zip -y
- name: Setup Flutter
if: matrix.flutter_setup == 'action'
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Set up Flutter from its pinned commit
if: matrix.flutter_setup == 'git'
uses: ./.github/actions/setup-flutter-git
- name: Install locked Dart dependencies
shell: pwsh
run: flutter pub get --enforce-lockfile --no-example
# Windows replacement semantics (`MoveFileExW` with
# MOVEFILE_REPLACE_EXISTING) cannot be proven on the POSIX runners, and a
# memory file system proves nothing about either. The vendored
# shared_preferences_windows store write is the fix for #1732, so exercise
# it here, on a real NTFS volume, against the real backend.
- name: Run the vendored Windows preference store tests
shell: pwsh
run: flutter test test/services/prefs_store_atomic_write_windows_test.dart
- name: Install patched Flutter engine
shell: pwsh
run: |
flutter precache --windows
.\windows\tool\install-patched-engine.ps1
- name: Prepare Flutter Windows configuration
shell: pwsh
run: flutter build windows --debug --config-only --no-pub
- name: Configure Windows native reliability tests
shell: pwsh
run: |
$buildDir = "build/windows/${{ matrix.arch }}"
cmake -S windows -B $buildDir `
-DPLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS=ON `
-DPLEZY_BUILD_DISPLAY_RECOVERY_TESTS=ON
- name: Build Windows native reliability tests
shell: pwsh
run: |
$buildDir = "build/windows/${{ matrix.arch }}"
cmake --build $buildDir --config Debug --parallel 2 --target `
mpv_property_result_contract_test `
mpv_player_property_contract_test `
display_mode_manager_test
- name: Run Windows native reliability tests
shell: pwsh
run: |
$buildDir = "build/windows/${{ matrix.arch }}"
ctest --test-dir "$buildDir/runner" -C Debug --output-on-failure --no-tests=error
dependency-check:
name: Dependency Validation
runs-on: ubuntu-latest
@@ -164,18 +528,20 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: "3.44.0"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
@@ -186,3 +552,43 @@ jobs:
flutter clean
flutter pub get
flutter pub outdated
server:
name: Server checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run server checks
run: scripts/ci_server_checks.sh
website:
name: Website checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Run website checks
run: scripts/ci_website_checks.sh
+324
View File
@@ -0,0 +1,324 @@
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
+12 -6
View File
@@ -17,9 +17,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Require the protected main branch
run: |
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "Release automation may only run from refs/heads/main." >&2
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: main
fetch-depth: 0
- name: Validate version format
@@ -32,10 +40,8 @@ jobs:
- name: Extract current build number
id: get_build
run: |
# Extract current build number from pubspec.yaml
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //')
CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2)
NEW_BUILD=$((CURRENT_BUILD + 1))
CURRENT_BUILD=$(python3 scripts/pubspec_version.py --build-number pubspec.yaml)
NEW_BUILD=$((10#$CURRENT_BUILD + 1))
echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT
echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT
@@ -71,7 +77,7 @@ jobs:
- name: Trigger build workflow
run: |
curl -L \
curl --fail-with-body -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
+74 -13
View File
@@ -4,14 +4,71 @@ on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: Published release tag to update packages for
required: true
type: string
jobs:
resolve-release:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
steps:
- name: Require the default branch for manual runs
if: github.event_name == 'workflow_dispatch'
run: |
if [[ "$GITHUB_REF" != "refs/heads/${{ github.event.repository.default_branch }}" ]]; then
echo "Manual package updates may only run from the default branch." >&2
exit 1
fi
- name: Resolve published release tag
id: release
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.release_tag }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = "release" ]; then
REQUESTED_TAG="$EVENT_TAG"
else
REQUESTED_TAG="$INPUT_TAG"
fi
if [ -z "$REQUESTED_TAG" ]; then
echo "A release tag is required." >&2
exit 1
fi
RELEASE=$(gh release view "$REQUESTED_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,publishedAt)
RESOLVED_TAG=$(jq -r '.tagName' <<< "$RELEASE")
IS_DRAFT=$(jq -r '.isDraft' <<< "$RELEASE")
PUBLISHED_AT=$(jq -r '.publishedAt // empty' <<< "$RELEASE")
if [ "$RESOLVED_TAG" != "$REQUESTED_TAG" ] || \
[ "$IS_DRAFT" != "false" ] || [ -z "$PUBLISHED_AT" ]; then
echo "Tag '$REQUESTED_TAG' is not an exact published release tag." >&2
exit 1
fi
echo "tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT"
update-homebrew:
needs: resolve-release
runs-on: macos-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
@@ -19,16 +76,14 @@ jobs:
- name: Download and hash release
id: release
run: |
VERSION="${{ github.event.release.tag_name || github.ref_name }}"
curl -L -o plezy-macos.dmg \
"https://github.com/edde746/plezy/releases/download/$VERSION/plezy-macos.dmg"
curl --fail-with-body -L -o plezy-macos.dmg \
"https://github.com/edde746/plezy/releases/download/$RELEASE_TAG/plezy-macos.dmg"
SHA256=$(shasum -a 256 plezy-macos.dmg | cut -d' ' -f1)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
- name: Update cask
run: |
sed -i '' "s/version \".*\"/version \"${{ steps.release.outputs.version }}\"/" Casks/plezy.rb
sed -i '' "s/version \".*\"/version \"$RELEASE_TAG\"/" Casks/plezy.rb
sed -i '' "s/sha256 \".*\"/sha256 \"${{ steps.release.outputs.sha256 }}\"/" Casks/plezy.rb
- name: Commit
@@ -38,41 +93,47 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/plezy.rb
if ! git diff --staged --quiet; then
git commit -m "chore: update cask to ${{ steps.release.outputs.version }}"
git commit -m "chore: update cask to $RELEASE_TAG"
git push origin "HEAD:$BRANCH"
else
echo "No cask changes to commit."
fi
update-winget:
needs: resolve-release
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@v2
- uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: edde746.Plezy
installers-regex: 'plezy-windows-installer\.exe$'
release-tag: ${{ needs.resolve-release.outputs.tag }}
token: ${{ secrets.WINGET_TOKEN }}
update-appcast-branch:
needs: resolve-release
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Download appcast.xml from release
run: |
gh release download "${{ github.event.release.tag_name }}" \
gh release download "$RELEASE_TAG" \
--pattern "appcast.xml"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Push to appcast branch
run: |
VERSION="${{ github.event.release.tag_name }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BLOB=$(git hash-object -w appcast.xml)
TREE=$(printf "100644 blob %s\tappcast.xml\n" "$BLOB" | git mktree)
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $VERSION")
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $RELEASE_TAG")
git push origin "$COMMIT:refs/heads/appcast" --force
+7 -1
View File
@@ -34,6 +34,11 @@ migrate_working_dir/
/build/
/debug-info/
# Windows Store packaging output (windows/build-msix.ps1)
/staging-msix/
/plezy-windows.msixbundle
/AppxManifest.*.xml
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
tvos/build/
tvos/Pods/
@@ -70,4 +75,5 @@ duplication-report/
**/fastlane/report.xml
maestro/**/*.png
server/deploy.sh
server/deploy.sh
server/Caddyfile
+15
View File
@@ -0,0 +1,15 @@
flows:
- "flows/*.yaml"
testOutputDir: "build/maestro"
executionOrder:
continueOnFailure: false
flowsOrder:
- Fresh install authentication choices
- Jellyfin onboarding reaches Home
- Browse library and open media details
- Search media and open a result
- Start and exit video playback
- Open empty downloads state
- Manage profiles and open settings
- Logout returns to authentication
- Download and play a movie from Downloads
+43
View File
@@ -0,0 +1,43 @@
appId: com.edde746.plezy
name: Fresh install authentication choices
tags:
- e2e
- auth
---
- retry:
maxRetries: 1
commands:
- launchApp:
clearState: true
permissions:
all: allow
- extendedWaitUntil:
visible: "(?s)^(?:Sign in with Plex|Wait).*"
timeout: 30000
- tapOn:
text: "Wait"
optional: true
- extendedWaitUntil:
visible: "Sign in with Plex"
timeout: 30000
- assertVisible: "Show QR Code"
- assertVisible: "Connect to Jellyfin"
- tapOn: "Connect to Jellyfin"
- extendedWaitUntil:
visible: "Add Jellyfin server"
timeout: 10000
- assertVisible: "(?s)Server URLs.*"
- assertVisible: "Find server"
- runFlow:
when:
platform: iOS
commands:
- tapOn: "Back"
- runFlow:
when:
platform: Android
commands:
- hideKeyboard:
optional: true
- back
- assertVisible: "Sign in with Plex"
+13
View File
@@ -0,0 +1,13 @@
appId: com.edde746.plezy
name: Jellyfin onboarding reaches Home
tags:
- e2e
- onboarding
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- assertVisible: "(?s)^Home.*"
- assertVisible: "(?s)^Recently Added.*"
- assertVisible: "(?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).*"
- assertVisible: "(?s)^Libraries.*"
- assertVisible: "(?s)^Search.*"
- assertVisible: "(?s)^Downloads.*"
+33
View File
@@ -0,0 +1,33 @@
appId: com.edde746.plezy
name: Browse library and open media details
tags:
- e2e
- library
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^Libraries.*"
- tapOn: "Browse"
- extendedWaitUntil:
visible: "(?s).*Maestro Movies.*"
timeout: 15000
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
- tapOn: "(?s).*Alpha Archive.*"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- assertVisible: "A deterministic title for TV alphabet focus coverage."
- assertVisible: "Play"
- runFlow:
when:
platform: iOS
commands:
- tapOn:
point: "6%, 9%"
- runFlow:
when:
platform: Android
commands:
- back
- assertVisible: "(?s).*Maestro Movies.*"
+33
View File
@@ -0,0 +1,33 @@
appId: com.edde746.plezy
name: Search media and open a result
tags:
- e2e
- search
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^Search.*"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
- tapOn: "Search movies, shows, music..."
- inputText: "Maestro"
- runFlow:
when:
platform: iOS
commands:
- pressKey: ENTER
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
- extendedWaitUntil:
visible: "(?s).*Maestro Movie.*"
timeout: 15000
- tapOn: "(?s).*Maestro Movie.*"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows."
- back
- assertVisible: "(?s)^Maestro Movie.*"
+57
View File
@@ -0,0 +1,57 @@
appId: com.edde746.plezy
name: Start and exit video playback
tags:
- e2e
- playback
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^Libraries.*"
- tapOn: "Browse"
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
- tapOn: "(?s).*Alpha Archive.*"
- extendedWaitUntil:
visible: "Play"
timeout: 15000
- tapOn:
text: "Play"
waitToSettleTimeoutMs: 1000
- runFlow:
when:
platform: iOS
commands:
- extendedWaitUntil:
visible: "Video timeline"
timeout: 20000
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*"
timeout: 10000
- runFlow:
when:
platform: Android
commands:
- extendedWaitUntil:
visible: "Pause"
timeout: 20000
- runFlow:
when:
platform: iOS
commands:
- tapOn:
point: "90%, 5%"
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+11
View File
@@ -0,0 +1,11 @@
appId: com.edde746.plezy
name: Open empty downloads state
tags:
- e2e
- downloads
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^Downloads.*"
- extendedWaitUntil:
visible: "No downloads"
timeout: 15000
+87
View File
@@ -0,0 +1,87 @@
appId: com.edde746.plezy
name: Manage profiles and open settings
tags:
- e2e
- profiles
- settings
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
- tapOn:
text: "Profiles"
above:
text: "Settings"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 10000
- assertVisible: "(?s).*Maestro.*"
- tapOn: "Add Plezy profile"
- extendedWaitUntil:
visible: "New profile"
timeout: 10000
- tapOn: "e.g. Guests, Kids, Family Room"
- inputText: "E2E Guest"
- runFlow:
when:
platform: iOS
commands:
- pressKey: ENTER
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Add to E2E Guest"
timeout: 10000
- assertVisible: "(?s)^Sign in with Plex.*"
- assertVisible: "(?s)^Connect to Jellyfin.*"
- runFlow:
when:
platform: iOS
commands:
- tapOn: "Back"
- runFlow:
when:
platform: Android
commands:
- back
- assertVisible: "Switch Profile"
- assertVisible: "(?s).*E2E Guest.*"
- runFlow:
when:
platform: iOS
commands:
- tapOn: "Back"
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: "(?s)^Home.*"
timeout: 10000
- waitForAnimationToEnd:
timeout: 5000
- repeat:
times: 2
while:
notVisible: "Settings"
commands:
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Settings"
below:
text: "Profiles"
- extendedWaitUntil:
visible: "(?s)^Appearance.*"
timeout: 10000
- assertVisible: "(?s)^Video Playback.*"
- assertVisible: "(?s)^Connections.*"
- runFlow:
when:
platform: Android
file: ../subflows/settings_deep_checks.yaml
+23
View File
@@ -0,0 +1,23 @@
appId: com.edde746.plezy
name: Logout returns to authentication
tags:
- e2e
- auth
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
- tapOn:
text: "Log out"
below:
text: "Settings"
- extendedWaitUntil:
visible: "Are you sure you want to log out?"
timeout: 10000
- tapOn:
text: "Log out"
below:
text: "Are you sure you want to log out?"
- extendedWaitUntil:
visible: "Sign in with Plex"
timeout: 20000
- assertVisible: "Connect to Jellyfin"
@@ -0,0 +1,77 @@
appId: com.edde746.plezy
name: Download and play a movie from Downloads
tags:
- e2e
- downloads
- playback
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- tapOn: "(?s)^Libraries.*"
- tapOn: "Browse"
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
- tapOn: "(?s).*Alpha Archive.*"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- tapOn: "Download"
- runFlow:
when:
platform: iOS
commands:
- tapOn:
point: "6%, 9%"
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 10000
- tapOn: "(?s)^Downloads.*"
- tapOn:
text: "Movies"
waitToSettleTimeoutMs: 3000
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 60000
- runScript:
file: ../scripts/set_jellyfin_offline.js
env:
JELLYFIN_CONTROL_URL: ${JELLYFIN_CONTROL_URL}
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "(?s).*Alpha Archive.*"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- tapOn:
text: "Play"
waitToSettleTimeoutMs: 1000
- extendedWaitUntil:
visible: "Pause"
timeout: 30000
- runFlow:
when:
platform: iOS
commands:
- tapOn:
point: "90%, 5%"
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1.7
ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
FROM ${JELLYFIN_IMAGE} AS seed
USER root
RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates python3 \
&& 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 .maestro/jellyfin-demo/seed.sh ./seed.sh
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14
ARG PLEZY_DEMO_MEDIA_DURATION=300
RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \
&& python3 maestro_real_jellyfin.py download-codecs \
--base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \
--output-dir /tmp/plezy-codecs \
&& PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \
/tmp/plezy-codecs /tmp/plezy-codecs-prepared \
--duration "${PLEZY_DEMO_MEDIA_DURATION}" \
--extend av1_opus_ass_srt.mkv \
--extend h264_eac3_multisub.mkv \
--extend hevc10_flac_ass.mkv \
&& python3 maestro_real_jellyfin.py prepare \
--output-dir /media \
--codec-source-dir /tmp/plezy-codecs-prepared \
--include-codecs \
&& rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \
&& chmod -R a=rX /media
RUN ./seed.sh
FROM ${JELLYFIN_IMAGE}
LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \
org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \
org.opencontainers.image.source="https://github.com/edde746/plezy"
COPY --from=seed /media /media
COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config
COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint
RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \
&& chmod -R a=rX /media /opt/plezy-demo/seed-config
ENV TZ=UTC \
JELLYFIN_PublishedServerUrl=http://localhost:8096
EXPOSE 8096
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"]
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
SEED_CONFIG=/opt/plezy-demo/seed-config
SEED_MARKER=.plezy-demo-seed
if [ ! -f "/config/${SEED_MARKER}" ]; then
existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)"
if [ -n "${existing}" ]; then
echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2
echo "Start this image with a new or empty /config volume." >&2
exit 1
fi
cp -a "${SEED_CONFIG}/." /config/
fi
exec /jellyfin/jellyfin "$@"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
SEED_ROOT=/opt/plezy-demo
SEED_CONFIG="${SEED_ROOT}/seed-config"
SEED_CACHE="${SEED_ROOT}/seed-cache"
JELLYFIN_PID=""
stop_jellyfin() {
if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then
kill -TERM "${JELLYFIN_PID}"
wait "${JELLYFIN_PID}" || true
fi
}
on_exit() {
exit_status=$?
if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then
cat "${SEED_ROOT}/seed-jellyfin.log" >&2
fi
stop_jellyfin
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}"
JELLYFIN_DATA_DIR="${SEED_CONFIG}" \
JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \
JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \
JELLYFIN_CACHE_DIR="${SEED_CACHE}" \
XDG_CACHE_HOME="${SEED_CACHE}" \
JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \
/jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 &
JELLYFIN_PID=$!
python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \
--url http://127.0.0.1:8096 \
--timeout 180 \
--include-codecs
stop_jellyfin
JELLYFIN_PID=""
rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log"
printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed"
trap - EXIT INT TERM
+12
View File
@@ -0,0 +1,12 @@
flows:
- "*.yaml"
testOutputDir: "build/maestro-media"
executionOrder:
continueOnFailure: false
flowsOrder:
- Codec sample - UHD Dolby Vision TrueHD and PGS
- Codec sample - Web Dolby Vision EAC3 Atmos and SRT
- Codec sample - AV1 Opus ASS and SRT
- Codec sample - H264 High10 DTS-HD and ASS
- Codec sample - H264 EAC3 multilingual SRT
- Codec sample - HEVC10 FLAC and ASS
+28
View File
@@ -0,0 +1,28 @@
appId: com.edde746.plezy
name: Codec sample - UHD Dolby Vision TrueHD and PGS
tags:
- e2e
- media
- dolby-vision
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec DV UHD TrueHD PGS"
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
- runFlow:
file: ../subflows/switch_codec_tracks.yaml
env:
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$"
SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+28
View File
@@ -0,0 +1,28 @@
appId: com.edde746.plezy
name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT
tags:
- e2e
- media
- dolby-vision
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec DV Web EAC3 SRT"
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles."
- runFlow:
file: ../subflows/switch_codec_tracks.yaml
env:
AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$"
SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+28
View File
@@ -0,0 +1,28 @@
appId: com.edde746.plezy
name: Codec sample - AV1 Opus ASS and SRT
tags:
- e2e
- media
- av1
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec AV1 Opus ASS"
SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts."
- runFlow:
file: ../subflows/switch_codec_tracks.yaml
env:
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$"
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+28
View File
@@ -0,0 +1,28 @@
appId: com.edde746.plezy
name: Codec sample - H264 High10 DTS-HD and ASS
tags:
- e2e
- media
- h264-high10
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS"
SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts."
- runFlow:
file: ../subflows/switch_codec_tracks.yaml
env:
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$"
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+29
View File
@@ -0,0 +1,29 @@
appId: com.edde746.plezy
name: Codec sample - H264 EAC3 multilingual SRT
tags:
- e2e
- media
- subtitles
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
- runFlow:
file: ../subflows/switch_codec_tracks_deep.yaml
env:
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+28
View File
@@ -0,0 +1,28 @@
appId: com.edde746.plezy
name: Codec sample - HEVC10 FLAC and ASS
tags:
- e2e
- media
- hevc
---
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec HEVC10 FLAC ASS"
SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts."
- runFlow:
file: ../subflows/switch_codec_tracks.yaml
env:
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$"
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+38
View File
@@ -0,0 +1,38 @@
appId: com.edde746.plezy
name: Real Jellyfin imports and opens codec media
tags:
- e2e
- real-jellyfin
- media
---
- runFlow: ../subflows/ensure_onboarded.yaml
- tapOn: "(?s)^Search.*"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
- tapOn: "Search movies, shows, music..."
- inputText: "Codec DV UHD"
- hideKeyboard
- extendedWaitUntil:
visible: "(?s).*Codec DV UHD TrueHD PGS.*"
timeout: 15000
- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
- tapOn:
text: "Play"
waitToSettleTimeoutMs: 1000
- extendedWaitUntil:
notVisible: "Overview"
timeout: 15000
- back
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 10000
+54
View File
@@ -0,0 +1,54 @@
appId: com.edde746.plezy
name: Jellyfin music browsing survives grouping reloads
tags:
- e2e
- real-jellyfin
- music
- regression
---
- runFlow: ../subflows/ensure_onboarded.yaml
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
- assertVisible: "(?s)^Regression Album.*"
- tapOn: "(?s)^Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
- tapOn: "Maestro Movies"
- extendedWaitUntil:
visible: "Maestro Music"
timeout: 10000
- tapOn: "Maestro Music"
- extendedWaitUntil:
visible: "(?s)^Regression Album.*"
timeout: 15000
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
- tapOn: "Browse"
- runFlow:
when:
visible: "Retry"
commands:
- tapOn: "Retry"
- extendedWaitUntil:
visible: "Maestro Artist"
timeout: 15000
- assertNotVisible: "(?i).*connection timeout.*"
- tapOn: "Library options"
- tapOn: "(?s)^Grouping.*Artists$"
- tapOn: "Albums"
- extendedWaitUntil:
visible: "(?s)^Regression Album.*"
timeout: 15000
- tapOn: "Library options"
- tapOn: "(?s)^Grouping.*Albums$"
- tapOn: "Tracks"
- extendedWaitUntil:
visible: "(?s)^Resilient Track.*"
timeout: 15000
- tapOn: "Library options"
- tapOn: "(?s)^Grouping.*Tracks$"
- tapOn: "Folders"
- extendedWaitUntil:
visible: "Maestro Artist"
timeout: 15000
- assertNotVisible: "(?i).*connection timeout.*"
@@ -0,0 +1,66 @@
appId: com.edde746.plezy
name: Profile switching isolates Jellyfin content and libraries
tags:
- e2e
- regression
- profiles
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- assertVisible: "(?s).*Zulu Zone.*"
- tapOn: "(?s)^Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
- assertNotVisible: "Guest Movies"
- tapOn: "(?s)^Home.*"
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
- tapOn: "(?s)^E\nE2E Guest\n.*"
- extendedWaitUntil:
visible: "(?s).*Guest Galaxy.*"
timeout: 30000
- assertNotVisible: "(?s).*Zulu Zone.*"
- tapOn: "(?s)^Libraries.*"
- extendedWaitUntil:
visible: "Guest Movies"
timeout: 15000
- assertNotVisible: "Maestro Movies"
- tapOn: "(?s)^Home.*"
- extendedWaitUntil:
visible: "(?s).*Guest Galaxy.*"
timeout: 15000
- tapOn: "E"
- tapOn:
text: "Profiles"
above:
text: "Settings"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 10000
- tapOn: "(?s)^M\nMaestro\n.*"
- extendedWaitUntil:
visible: "(?s).*Zulu Zone.*"
timeout: 30000
- assertNotVisible: "(?s).*Guest Galaxy.*"
- tapOn: "(?s)^Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
- assertNotVisible: "Guest Movies"
- tapOn: "(?s)^Home.*"
- extendedWaitUntil:
visible: "(?s).*Zulu Zone.*"
timeout: 15000
- tapOn: "M"
- tapOn:
text: "Profiles"
above:
text: "Settings"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 10000
- tapOn: "(?s)^E\nE2E Guest\n.*"
- extendedWaitUntil:
visible: "(?s).*Guest Galaxy.*"
timeout: 30000
- assertNotVisible: "(?s).*Zulu Zone.*"
@@ -0,0 +1,51 @@
appId: com.edde746.plezy
name: Removing a Jellyfin profile connection leaves no orphaned session
tags:
- e2e
- regression
- profiles
- teardown
---
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
- tapOn:
text: "Manage"
index: 1
- tapOn: "Manage"
- extendedWaitUntil:
visible: "Profile name"
timeout: 10000
- assertVisible: "(?s)^E2E Guest.*"
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$"
- tapOn: "Manage"
- extendedWaitUntil:
visible: "Remove"
timeout: 10000
- tapOn: "Remove"
- extendedWaitUntil:
visible: "^Remove connection\\?$"
timeout: 10000
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
- tapOn: "Remove"
- extendedWaitUntil:
notVisible: "(?s)^Maestro Jellyfin.*"
timeout: 15000
- assertVisible: "Delete profile"
- retry:
maxRetries: 2
commands:
- tapOn: "Delete profile"
- extendedWaitUntil:
visible: "^Delete profile\\?$"
timeout: 10000
- tapOn: "^Delete$"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 30000
- assertNotVisible: "(?s).*E2E Guest.*"
- assertVisible: "(?s)^M\nMaestro\nActive.*"
- back
- extendedWaitUntil:
visible: "(?s)^Recently Added in Maestro Movies.*"
timeout: 30000
@@ -0,0 +1,77 @@
appId: com.edde746.plezy
name: TV alphabet rail retains focus until returning to the media grid
tags:
- e2e
- regression
- tv
- focus
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- pressKey: "Remote Dpad Left"
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Recommended"
timeout: 15000
- pressKey: "Remote Dpad Right"
- pressKey: "Remote Dpad Up"
- pressKey: "Remote Dpad Right"
- pressKey: "Remote Dpad Center"
- 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.
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
- pressKey: "Remote Dpad Center"
- 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.
- assertNotVisible: "(?s).*Alpha Archive.*"
- pressKey: "Remote Dpad Center"
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
notVisible: "Sort By"
timeout: 10000
- pressKey: "Remote Dpad Down"
- extendedWaitUntil:
visible: "(?s).*Alpha Archive.*"
timeout: 15000
- repeat:
times: 8
commands:
- pressKey: "Remote Dpad Right"
- repeat:
times: 13
commands:
- pressKey: "Remote Dpad Down"
- extendedWaitUntil:
visible: "(?s).*Mike Matrix.*"
timeout: 15000
- assertNotVisible: "(?s).*Alpha Archive.*"
- repeat:
times: 13
commands:
- pressKey: "Remote Dpad Down"
- extendedWaitUntil:
visible: "(?s).*Zulu Zone.*"
timeout: 15000
- assertNotVisible: "(?s).*Mike Matrix.*"
- pressKey: "Remote Dpad Left"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Play"
timeout: 15000
- assertVisible: "(?s).*Zulu Zone.*"
- pressKey: "back"
- extendedWaitUntil:
visible: "Browse"
timeout: 15000
- assertVisible: "(?s).*Zulu Zone.*"
@@ -0,0 +1,73 @@
appId: com.edde746.plezy
name: TV hardware media and back keys follow player chrome state
tags:
- e2e
- regression
- tv
- playback
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- pressKey: "Remote Dpad Center"
- waitForAnimationToEnd:
timeout: 5000
- runFlow:
when:
visible: "(?s)^(Play|Resume).*$"
commands:
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Pause"
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.
- 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.
- pressKey: "Remote Dpad Right"
- extendedWaitUntil:
visible: "(?s)^Seek forward 10 seconds$"
timeout: 10000
- assertNotVisible: "(?s)^Play$"
- pressKey: "Remote Dpad Right"
- extendedWaitUntil:
visible: "(?s)^Seek forward 20 seconds$"
timeout: 10000
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Playing"
timeout: 10000
- assertNotVisible: "(?s)^Pause$"
# Select stays the deliberate way to bring the chrome back.
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^(Play|Pause)$"
timeout: 10000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "(?s)^Pause$"
timeout: 10000
- assertNotVisible: "Overview"
- pressKey: "back"
- waitForAnimationToEnd:
timeout: 5000
- runFlow:
when:
visible: "Overview"
commands:
- pressKey: "back"
- extendedWaitUntil:
visible:
id: "tv_browse_rail_selection"
timeout: 15000
@@ -0,0 +1,50 @@
appId: com.edde746.plezy
name: TV Back dismisses Next Episode without leaving playback
tags:
- e2e
- regression
- tv
- playback
---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
- tapOn:
point: "6%,22%"
- tapOn: "Search"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
- tapOn: "Search movies, shows, music..."
- inputText: "Maestro Show"
- tapOn:
point: "87%,90%"
- extendedWaitUntil:
visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
timeout: 15000
- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
- waitForAnimationToEnd:
timeout: 5000
- tapOn: "(?s)^Play S1E1$"
- extendedWaitUntil:
visible: "Pause"
timeout: 30000
- pressKey: "Remote Media Fast Forward"
- pressKey: "Remote Media Fast Forward"
- extendedWaitUntil:
visible: "Next Episode"
timeout: 20000
- runFlow:
when:
platform: iOS
commands:
- tapOn: "Cancel"
- runFlow:
when:
platform: Android
commands:
- pressKey: "back"
- assertNotVisible: "Next Episode"
- assertNotVisible: "Overview"
- pressKey: "Remote Dpad Up"
- extendedWaitUntil:
visible: "(?s).*Maestro Episode 1.*"
timeout: 10000
@@ -0,0 +1,36 @@
appId: com.edde746.plezy
name: Playback recovers after a transient stream failure
tags:
- e2e
- regression
- playback
- recovery
---
- 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.
- 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
- tapOn: "(?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, .*"
- extendedWaitUntil:
visible: "Play"
timeout: 15000
- tapOn:
text: "Play"
waitToSettleTimeoutMs: 1000
- extendedWaitUntil:
visible: "Pause"
timeout: 45000
- assertNotVisible: "Overview"
- back
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
@@ -0,0 +1,94 @@
appId: com.edde746.plezy
name: System back dismisses a hosted sheet without leaving Settings
tags:
- e2e
- regression
- 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.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml
- assertVisible: "(?s)^Services.*"
- assertVisible: "(?s)^Video Playback.*"
- tapOn: "(?s)^Manage Libraries.*"
- 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.
- assertNotVisible: "(?s)^Services.*"
- assertNotVisible: "(?s)^Video Playback.*"
- assertNotVisible: "(?s)^Appearance.*"
- assertNotVisible: "(?s)^Connections.*"
- back
- 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.
- tapOn: "(?s)^Manage Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
# The per-library menu is a nested page of the same sheet now that the host
# exists, so Back returns to the library list instead of tearing the sheet down.
- tapOn: "Library options"
- extendedWaitUntil:
visible: "(?s).*Refresh Metadata.*"
timeout: 15000
- back
- waitForAnimationToEnd:
timeout: 3000
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 10000
- assertNotVisible: "(?s).*Refresh Metadata.*"
# Back at the library list, one more Back leaves the sheet entirely.
- back
- waitForAnimationToEnd:
timeout: 3000
- assertNotVisible: "Maestro Movies"
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 10000
- tapOn: "(?s)^Manage Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
- back
- waitForAnimationToEnd:
timeout: 3000
- assertNotVisible: "Maestro Movies"
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 10000
# With no sheet open the host must let the route pop normally.
- back
- extendedWaitUntil:
visible: "Discover"
timeout: 15000
- assertNotVisible: "(?s)^Manage Libraries.*"
@@ -0,0 +1,112 @@
appId: com.edde746.plezy
name: An explicit track choice survives the pending automatic pass
tags:
- e2e
- regression
- 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.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow:
file: ../subflows/open_codec_sample.yaml
env:
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
- assertVisible: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
- tapOn:
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
# German is the only German subtitle in the set and is never the automatic
# pick, so a revert to the preferred language or to Off is unambiguous.
- repeat:
times: 12
while:
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
commands:
- swipe:
start: 70%, 82%
end: 70%, 68%
duration: 300
- tapOn:
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
# The pending pass is wall-clock driven and a paused frame settles instantly,
# so playback has to run for the wait below to consume real time.
- runFlow:
when:
visible: "Play"
commands:
- tapOn: "Play"
- extendedWaitUntil:
visible: "Pause"
timeout: 20000
- repeat:
times: 14
commands:
- waitForAnimationToEnd:
timeout: 3000
# Chrome auto-hides after 30s even in Maestro builds, so bring it back.
- tapOn:
point: 50%, 50%
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
# The tracks button announces the live selection, so this catches a revert
# without reopening the sheet.
- assertVisible: "(?is)^English[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*,.*Audio & Subtitles$"
- tapOn: "(?s)^.*Audio & Subtitles$"
- assertVisible:
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
selected: true
- assertNotVisible:
text: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
selected: true
- repeat:
times: 12
while:
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
commands:
- swipe:
start: 70%, 82%
end: 70%, 68%
duration: 300
- assertVisible:
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
selected: true
- assertNotVisible:
text: "Off"
selected: true
- back
- waitForAnimationToEnd:
timeout: 3000
- back
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "Overview"
commands:
- back
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
@@ -0,0 +1,90 @@
appId: com.edde746.plezy
name: Language picker offers the new locales and switching one relocalizes the app
tags:
- e2e
- regression
- 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.
- runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml
- tapOn: "(?s)^Appearance.*"
- extendedWaitUntil:
visible: "(?s).*Theme.*"
timeout: 15000
- tapOn: "(?s)^Language.*"
- extendedWaitUntil:
visible: "Azərbaycanca"
timeout: 10000
- assertVisible: "English"
- assertVisible: "Қазақша"
# The dialog holds 22 endonyms and only the first 16 fit on a phone, so the two
# locales that sort last need scrolling into view.
- repeat:
times: 8
while:
notVisible: "Türkçe"
commands:
- swipe:
start: 50%, 80%
end: 50%, 45%
duration: 300
- assertVisible: "Türkçe"
- assertVisible: "Oʻzbekcha"
- tapOn: "Türkçe"
# Selecting a language rebuilds the app from the root route, so everything
# below runs against the Turkish tree.
- extendedWaitUntil:
visible: "(?s)^Kitaplıklar.*"
timeout: 30000
- assertVisible: "(?s)^Ana Sayfa.*"
- assertVisible: "(?s)^Ara.*"
- assertVisible: "(?s)^İndirmeler.*"
- 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.
- repeat:
times: 3
while:
notVisible: "Ayarlar"
commands:
- tapOn: "(?s)^M(?:.*Profiller.*)?$"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Ayarlar"
below:
text: "Profiller"
- extendedWaitUntil:
visible: "(?s)^Görünüm.*"
timeout: 15000
- assertVisible: "(?s)^Servisler.*"
- assertVisible: "(?s)^Video Oynatma.*"
- tapOn: "(?s)^Görünüm.*"
- extendedWaitUntil:
visible: "(?s).*Tema.*"
timeout: 15000
- tapOn: "(?s)^Dil.*"
- extendedWaitUntil:
visible: "English"
timeout: 10000
- tapOn: "English"
- extendedWaitUntil:
visible: "(?s)^Libraries.*"
timeout: 30000
- assertVisible: "Discover"
- assertNotVisible: "(?s)^Kitaplıklar.*"
@@ -0,0 +1,142 @@
appId: com.edde746.plezy
name: TV settings stay navigable and dismiss their overlays
tags:
- e2e
- regression
- 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.
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
- pressKey: "Remote Dpad Left"
- extendedWaitUntil:
visible: "Settings"
timeout: 15000
- repeat:
times: 12
while:
notVisible:
text: "Settings"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^Appearance.*"
timeout: 15000
- assertVisible: "(?s)^Video Playback.*"
- assertVisible: "(?s)^Manage Libraries.*"
- assertVisible: "(?s)^Services.*"
# The Connections card sits below the first card, so reaching it also proves
# the list scrolled or fits — not that any particular row height applies.
- assertVisible: "(?s)^Add connection.*"
- assertVisible: "(?s)^Profiles.*"
# Manage Libraries takes the TV dialog path, not the overlay-sheet path, and
# one Back has to return to Settings rather than leave it.
- repeat:
times: 6
while:
notVisible:
text: "(?s)^Manage Libraries.*"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 15000
- assertVisible: "Maestro Shows"
- assertNotVisible: "(?s)^Services.*"
- assertNotVisible: "(?s)^Video Playback.*"
- pressKey: "back"
- waitForAnimationToEnd:
timeout: 3000
- assertNotVisible: "Maestro Movies"
- extendedWaitUntil:
visible: "(?s)^Services.*"
timeout: 15000
- assertVisible: "(?s)^Manage Libraries.*"
# Services keeps every hub row after losing its own density overrides.
- repeat:
times: 6
while:
notVisible:
text: "(?s)^Services.*"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^Trakt.*"
timeout: 15000
- assertVisible: "(?s)^MyAnimeList.*"
- assertVisible: "(?s)^AniList.*"
- assertVisible: "(?s)^Simkl.*"
- assertVisible: "(?s)^Seerr.*"
- pressKey: "back"
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 15000
# Video Playback: the Atmos test screen is gated to Apple TV, and the D-pad
# numeric dialog must expose the spinner's restored accessibility labels.
- repeat:
times: 8
while:
notVisible:
text: "(?s)^Video Playback.*"
focused: true
commands:
- pressKey: "Remote Dpad Up"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^Player.*"
timeout: 15000
- assertNotVisible: "(?s)^Atmos Output Test.*"
- repeat:
times: 30
while:
notVisible:
text: "(?s)^Small Skip Duration.*"
focused: true
commands:
- pressKey: "Remote Dpad Down"
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "Decrease"
timeout: 15000
- assertVisible: "Increase"
- assertVisible: "(?s)^Enter duration.*"
- pressKey: "back"
- extendedWaitUntil:
visible: "(?s)^Small Skip Duration.*"
timeout: 15000
- pressKey: "back"
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 15000
# Settings is a rail tab on TV rather than a pushed route, so Back hands focus
# back to the rail and expands it instead of leaving the screen.
- pressKey: "back"
- extendedWaitUntil:
visible:
text: "Settings"
focused: true
timeout: 20000
- assertVisible: "Home"
- assertVisible: "Libraries"
- assertVisible: "Downloads"
+11
View File
@@ -0,0 +1,11 @@
const controlUrl = String(JELLYFIN_CONTROL_URL).trim();
if (controlUrl !== "" && controlUrl !== "undefined" && controlUrl !== "null") {
const response = http.post(`${controlUrl}/__maestro/offline`, {
headers: {"Content-Type": "application/json"},
body: JSON.stringify({enabled: true}),
});
if (response.status !== 204) {
throw new Error(`Could not put Jellyfin fixture offline: HTTP ${response.status}`);
}
}
@@ -0,0 +1,43 @@
appId: com.edde746.plezy
---
- tapOn: "M"
- tapOn:
text: "Profiles"
above:
text: "Settings"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 10000
- tapOn: "Add Plezy profile"
- extendedWaitUntil:
visible: "New profile"
timeout: 10000
- tapOn: "e.g. Guests, Kids, Family Room"
- inputText: "E2E Guest"
- hideKeyboard
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Add to E2E Guest"
timeout: 10000
- tapOn: "(?s)^Connect to Jellyfin.*"
- extendedWaitUntil:
visible: "Add Jellyfin server"
timeout: 10000
- tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL}
- hideKeyboard
- tapOn: "Find server"
- extendedWaitUntil:
visible: "Maestro Jellyfin"
timeout: 15000
- tapOn: "Username"
- inputText: "guest"
- tapOn: "Password"
- inputText: "guest"
- hideKeyboard
- tapOn: "Sign in"
- extendedWaitUntil:
visible: "Switch Profile"
timeout: 30000
- assertVisible: "(?s)^M\nMaestro\n.*"
- assertVisible: "(?s)^E\nE2E Guest\n.*"
+27
View File
@@ -0,0 +1,27 @@
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.
- 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.
- extendedWaitUntil:
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
timeout: 30000
- runFlow:
when:
notVisible: "Discover"
file: onboard_jellyfin.yaml
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
+50
View File
@@ -0,0 +1,50 @@
appId: com.edde746.plezy
---
- retry:
maxRetries: 1
commands:
- launchApp:
clearState: true
permissions:
all: allow
- 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.
- runFlow:
when:
visible: "Wait"
commands:
- tapOn: "Wait"
- extendedWaitUntil:
visible: "Connect to Jellyfin"
timeout: 30000
- tapOn: "Connect to Jellyfin"
- extendedWaitUntil:
visible: "Add Jellyfin server"
timeout: 10000
- tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL}
- tapOn: "Find server"
- extendedWaitUntil:
visible: "Maestro Jellyfin"
timeout: 15000
- tapOn: "Username"
- inputText: "maestro"
- tapOn: "Password"
- inputText: "maestro"
- pressKey: ENTER
- retry:
maxRetries: 1
commands:
- runFlow:
when:
visible: "Sign in"
commands:
- tapOn: "Sign in"
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
@@ -0,0 +1,55 @@
appId: com.edde746.plezy
---
- retry:
maxRetries: 1
commands:
- launchApp:
appId: com.edde746.plezy
clearState: true
permissions:
all: allow
- extendedWaitUntil:
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
timeout: 30000
- tapOn:
text: "Wait"
optional: true
- extendedWaitUntil:
visible: "Connect to Jellyfin"
timeout: 30000
- tapOn: "Connect to Jellyfin"
- extendedWaitUntil:
visible: "Add Jellyfin server"
timeout: 10000
- tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL}
- tapOn: "Find server"
- extendedWaitUntil:
visible: "Username"
timeout: 15000
- tapOn: "Username"
- inputText: "maestro"
- tapOn: "Password"
- inputText: "maestro"
- tapOn: "Sign in"
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
- tapOn: "M"
- tapOn:
text: "Settings"
below:
text: "Profiles"
- extendedWaitUntil:
visible: "(?s)^Appearance.*"
timeout: 10000
- tapOn: "(?s)^Appearance.*"
- swipe:
start: 50%, 85%
end: 50%, 25%
duration: 500
- tapOn: "(?s)^Force TV mode.*"
- extendedWaitUntil:
visible:
id: "tv_browse_rail_selection"
timeout: 30000
@@ -0,0 +1,57 @@
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.
- retry:
maxRetries: 1
commands:
- launchApp:
clearState: true
permissions:
all: allow
- extendedWaitUntil:
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
timeout: 30000
- runFlow:
when:
visible: "Wait"
commands:
- tapOn: "Wait"
- extendedWaitUntil:
visible: "Connect to Jellyfin"
timeout: 30000
- tapOn: "Connect to Jellyfin"
- extendedWaitUntil:
visible: "Add Jellyfin server"
timeout: 15000
- 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.
- extendedWaitUntil:
visible: "(?s)^(?:Username|Cancel)$"
timeout: 30000
- runFlow:
when:
notVisible: "Username"
commands:
- tapOn: "Cancel"
- extendedWaitUntil:
visible: "Username"
timeout: 20000
- tapOn: "Username"
- inputText: "maestro"
- tapOn: "Password"
- inputText: "maestro"
- tapOn: "Sign in"
- extendedWaitUntil:
visible:
id: "tv_browse_rail_selection"
timeout: 45000
+48
View File
@@ -0,0 +1,48 @@
appId: com.edde746.plezy
---
- runFlow: ensure_onboarded.yaml
- tapOn: "(?s)^Search.*"
- extendedWaitUntil:
visible: "Search movies, shows, music..."
timeout: 10000
- tapOn: "Search movies, shows, music..."
- inputText: "${SAMPLE_TITLE}"
- runFlow:
when:
platform: iOS
commands:
- pressKey: ENTER
- runFlow:
when:
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.
- extendedWaitUntil:
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
timeout: 15000
- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
- extendedWaitUntil:
visible: "Overview"
timeout: 15000
- assertVisible: "${SAMPLE_OVERVIEW}"
- tapOn:
text: "Play"
waitToSettleTimeoutMs: 1000
- extendedWaitUntil:
notVisible: "Overview"
timeout: 15000
- extendedWaitUntil:
visible: "Pause"
timeout: 30000
- tapOn: "Pause"
- extendedWaitUntil:
visible: "Play"
timeout: 5000
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
+27
View File
@@ -0,0 +1,27 @@
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.
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
- repeat:
times: 3
while:
notVisible: "Settings"
commands:
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Settings"
below:
text: "Profiles"
- extendedWaitUntil:
visible: "(?s)^Appearance.*"
timeout: 15000
- assertVisible: "(?s)^Manage Libraries.*"
@@ -0,0 +1,60 @@
appId: com.edde746.plezy
---
- tapOn: "(?s)^Appearance.*"
- extendedWaitUntil:
visible: "(?s).*Theme.*"
timeout: 10000
- assertVisible: "(?s).*Library Density.*"
- back
- assertVisible: "(?s)^Appearance.*"
- tapOn: "(?s)^Video Playback.*"
- extendedWaitUntil:
visible: "(?s)^Player.*"
timeout: 10000
- repeat:
times: 4
while:
notVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
commands:
- swipe:
start: 50%, 80%
end: 50%, 65%
duration: 800
- waitForAnimationToEnd:
timeout: 1500
- assertVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
- repeat:
times: 4
while:
notVisible: "(?s)^Seek & Timing.*"
commands:
- swipe:
start: 50%, 80%
end: 50%, 65%
duration: 800
- waitForAnimationToEnd:
timeout: 1500
- assertVisible: "(?s)^Seek & Timing.*"
- back
- extendedWaitUntil:
visible: "(?s)^Manage Libraries.*"
timeout: 10000
- tapOn: "(?s)^Manage Libraries.*"
- extendedWaitUntil:
visible: "Maestro Movies"
timeout: 10000
- back
- extendedWaitUntil:
visible: "(?s)^Services.*"
timeout: 10000
- tapOn: "(?s)^Services.*"
- extendedWaitUntil:
visible: "Services"
timeout: 10000
- assertVisible: "(?s)^Trakt.*"
- assertVisible: "(?s)^MyAnimeList.*"
- assertVisible: "(?s)^AniList.*"
- assertVisible: "(?s)^Simkl.*"
- assertVisible: "(?s)^Seerr.*"
- back
- assertVisible: "(?s)^Services.*"
@@ -0,0 +1,23 @@
appId: com.edde746.plezy
---
- assertVisible: "${AUDIO_TRACK}"
- tapOn:
text: "${AUDIO_TRACK}"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
- assertVisible: "${SUBTITLE_TRACK}"
- tapOn:
text: "${SUBTITLE_TRACK}"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
- assertVisible:
text: "${AUDIO_TRACK}"
selected: true
- assertVisible:
text: "${SUBTITLE_TRACK}"
selected: true
- back
@@ -0,0 +1,41 @@
appId: com.edde746.plezy
---
- assertVisible: "${AUDIO_TRACK}"
- tapOn:
text: "${AUDIO_TRACK}"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
- repeat:
times: 12
while:
notVisible: "${SUBTITLE_TRACK}"
commands:
- swipe:
start: 70%, 82%
end: 70%, 68%
duration: 300
- assertVisible: "${SUBTITLE_TRACK}"
- tapOn:
text: "${SUBTITLE_TRACK}"
- extendedWaitUntil:
visible: "(?s)^.*Audio & Subtitles$"
timeout: 30000
- tapOn: "(?s)^.*Audio & Subtitles$"
- assertVisible:
text: "${AUDIO_TRACK}"
selected: true
- repeat:
times: 12
while:
notVisible: "${SUBTITLE_TRACK}"
commands:
- swipe:
start: 70%, 82%
end: 70%, 68%
duration: 300
- assertVisible:
text: "${SUBTITLE_TRACK}"
selected: true
- back
+77 -4
View File
@@ -4,7 +4,7 @@
1. Fork and clone the repository
2. Run `flutter pub get` to install dependencies
3. Run `dart run build_runner build` to generate code
3. Run `scripts/codegen.sh` to generate translations and Dart model code
4. Start developing!
## Development
@@ -13,7 +13,7 @@
- Run `dart format .` to format Dart code (note: generated files like `*.g.dart` are excluded from CI checks)
- Run `scripts/format_native.sh --fix` to format Kotlin, Swift, C++, C, Objective-C, and native headers
- Run `flutter analyze` before submitting to check for issues
- Run `flutter test` if tests are available
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
- Test your changes thoroughly
### Code Quality Checks
@@ -30,11 +30,84 @@ The project includes automated CI checks that run on all pull requests:
- Run locally: `flutter analyze`
- Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`)
3. **Tests**: Runs unit and widget tests (when available)
- Run locally: `flutter test`
3. **Generated Code**: Ensures generated translations and model files are current
- Run locally: `scripts/codegen.sh --check`
4. **Tests**: Runs unit and widget tests (when available)
- Run locally: `scripts/run_tests.sh`
- This is `flutter test` with `-j` set to the core count. The default is half your cores, which
leaves most of the machine idle because the suite is dominated by per-file compilation.
Arguments are forwarded, so `scripts/run_tests.sh test/widgets/some_test.dart` works.
All these checks must pass before your changes can be merged.
### Maestro end-to-end tests
Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container.
Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the
[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro).
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
```
Run one flow with `--flow`:
```bash
python3 scripts/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
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.
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
emulator, using the required opt-in:
```bash
python3 scripts/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
diagnostics directory under `build/maestro-profile-regressions/`.
### Production container image updates
Production images in `server/Dockerfile` and `server/docker-compose.yml` use a readable version or source-revision tag
plus an authoritative multi-platform index digest. The adjacent `Platforms` declaration records the supported
`linux/amd64` and `linux/arm64` variants. Never replace these references with a mutable tag or a single-platform child
manifest.
Update a production image only through a reviewed change:
1. For the Bugs service, first record the running container's image ID, repository digest, platform, and OCI source
revision without printing its environment. Prefer that reviewed running identity; selecting anything else is a
service upgrade, not a routine pin refresh.
2. Review the upstream source revision and changelog, provenance, vulnerability results, and manifest contents. Resolve
the readable tag and digest-qualified reference independently and confirm they identify the same OCI index in two
clean caches. The index must contain both declared platforms; provenance/attestation descriptors do not count as
runnable platforms.
3. Change the readable tag, full `sha256` index digest, and adjacent platform declaration together. Include the old and
new identities, manifest/platform evidence, review findings, smoke results, and rollback notes in the 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
`(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.
## Internationalization (i18n)
This project uses `slang` for internationalization with JSON files.
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.2.0"
sha256 "9bfd7397d27bb2dbfd0f4d3e47d6fb73d6a1135f9f181ec8c48767c95964f1e9"
version "2.11.0"
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+74 -31
View File
@@ -19,13 +19,13 @@ A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flu
## Download
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
<a href='https://apps.apple.com/app/apple-store/id6754315964?pt=128238902&ct=GitHub&mt=8'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy&referrer=utm_source%3Dgithub%26utm_campaign%3Dreadme_badge'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
<a href='https://www.amazon.com/gp/product/B0GK65CVS1'><img height='60' alt='Available at the Amazon App Store' src='./assets/amazon-badge.png'/></a>
<a href='https://get.microsoft.com/installer/download/9n5r1s1t68h7?referrer=appbadge&cid=github'><img height='60' alt='Get it from Microsoft' src='./assets/microsoft-badge.png'/></a>
| Platform | Download |
| --- | --- |
| Windows | [Installer (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe) · [Portable x64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-x64-portable.7z) · [Portable arm64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-arm64-portable.7z) |
| macOS | [DMG (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) |
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
@@ -50,36 +50,61 @@ Package managers:
## Features
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
- Libraries, collections, and playlists
- Libraries, collections, and playlists — video and audio
- Discover hub — Continue Watching, Next Up, trending, and recommendations
- Cross-server search
- Cross-server search across every connected Plex and Jellyfin 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]
- Extras — trailers, deleted scenes, behind-the-scenes
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
- Explore tab — watchlist, trending, popular, and recommendation rows from Plex Discover[^plex], Trakt, MyAnimeList, AniList, Simkl, and Seerr[^connect]
- Search any connected catalog source
- Catalog titles matched back to your own libraries by external ID
- Seerr — request movies and shows with per-season, 4K, and advanced destination options, and see request status inline
- Watchlist sync — add and remove titles on Plex, Trakt, MyAnimeList, AniList, and Simkl from anywhere in the app
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision[^1]
- HDR and Dolby Vision[^hdr]
- Direct play, or transcode presets from 240p/320 kbps to 1080p/20 Mbps
- Multi-version switching with per-version file details
- Full ASS/SSA subtitles with customizable styling
- Online subtitle search & download[^2]
- Audio & subtitle choices remembered per title
- Online subtitle search & download[^plex]
- Audio & subtitle choices remembered per title, or follow the server's per-episode selections
- Progress sync and resume
- Auto-play next episode with skip intro / skip credits
- Chapter navigation with thumbnail scrub previews
- Playback speed, audio sync offset, sleep timer
- Ambient lighting and GLSL shader presets[^3]
- Picture-in-Picture[^4]
- Refresh-rate matching[^5]
- External player launch (VLC, MX Player, etc.)
- Playback speed from 0.25x to 8x, audio sync offset, sleep timer (fixed durations or end of video)
- Video zoom 50-200% with pinch, presets, and hotkeys
- Audio passthrough[^pass], stereo downmix with center-channel boost, and loudness normalization
- File Info sheet — every version, file, and stream the server reports
- Ambient lighting and GLSL shader presets[^mpv]
- Picture-in-Picture[^pip]
- Refresh-rate matching[^rrm]
- External player launch (VLC, MX Player, etc.) with progress sync back[^android]
### <img src="assets/readme_icons/music.svg" height="20" alt="" align="center" /> Music
- Music libraries — artist, album, and track browsing with square artwork
- Album and artist screens with play, shuffle, and Instant Mix
- Gapless playback with a full play queue — reorder, remove, play next, add to queue
- Now Playing with synced lyrics[^lyrics], persistent mini-player, and sleep timer
- Background playback with lock-screen, media-key, and notification controls[^bgaudio]
- Offline playback of downloaded albums and tracks
- Streaming quality presets — Original, 320, 192, or 128 kbps
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
- Live TV channel browsing with favorites
- DVR support with EPG guide, recording rules, and scheduled recordings[^2]
- Live TV channel browsing, tuning, and favorites
- EPG guide with What's On and per-show schedules
- DVR recording rules, scheduled recordings, and a rememberable recording target library[^plex]
- Multi-server Live TV support where available
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
- Download media for offline viewing
- Download movies, shows, and music for offline playback[^dl]
- Background queue with pause / resume
- Sync rules for automatic downloads
- Sync rules for automatic downloads, with per-show "Include Specials"
- Offline browsing with watch state sync-back on reconnect
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
@@ -87,30 +112,42 @@ Package managers:
- Real-time play / pause / seek sync
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
- Discord Rich Presence[^7]
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
- Discord Rich Presence[^desktop]
- Trakt, MyAnimeList, AniList, and Simkl — ratings, watched sync, and real-time scrobbling[^rt]
- Plezy Remote — control desktop and TV from mobile
- Watch Next row[^6]
- Watch Next row and tvOS Top Shelf[^shelf]
### <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
- Customizable keyboard shortcuts[^7]
- Metadata and artwork editing[^2]
- Multiple servers at once — Plex and Jellyfin 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
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
- Customizable keyboard shortcuts[^desktop]
- Metadata and artwork editing
- Settings import/export
- Localized in English plus 14 translations
- Localized in English plus 21 translations
[^1]: Not available on Linux.
[^2]: Plex only.
[^3]: Not available on iOS or tvOS.
[^4]: Android, iOS, and macOS.
[^5]: Windows, Android, and tvOS.
[^6]: Android TV only.
[^7]: Desktop only.
[^jf]: Jellyfin 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.
[^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.
[^rrm]: Windows, Android, and tvOS.
[^android]: Progress sync on Android.
[^lyrics]: Where your server provides lyrics.
[^bgaudio]: tvOS pauses music when the app is backgrounded.
[^dl]: Not available on tvOS.
[^desktop]: Desktop only.
[^rt]: Real-time scrobbling on Trakt and Simkl; MyAnimeList and AniList update on completion.
[^shelf]: Android TV / Fire TV and tvOS.
## Building from Source
### Prerequisites
- Flutter SDK 3.38.4+
- Flutter SDK 3.44.0+
- A Plex account or Jellyfin server with user credentials
### Setup
@@ -149,6 +186,12 @@ To install the same pre-commit checks locally:
scripts/setup_hooks.sh
```
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
```bash
python3 scripts/run_maestro.py basic
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
+7 -2
View File
@@ -4,8 +4,6 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
plugins:
- dart_code_linter
linter:
rules:
@@ -17,7 +15,14 @@ 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.
dart_code_linter:
rules-exclude:
- "test/**"
metrics-exclude:
- "test/**"
extends:
- package:dart_code_linter/presets/recommended.yaml
-3
View File
@@ -1,8 +1,5 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
+409 -63
View File
@@ -1,5 +1,55 @@
import java.io.FileInputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.Properties
import java.util.UUID
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
fun verifySha256(file: File, expected: String, identity: String) {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
val actual = digest.digest().joinToString("") {
(it.toInt() and 0xff).toString(16).padStart(2, '0')
}
if (actual != expected) {
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
}
}
fun promoteDirectory(staging: File, destination: File) {
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
val hadDestination = destination.exists()
try {
if (hadDestination) {
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
}
try {
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (promotionFailure: Exception) {
if (hadDestination && backup.exists()) {
try {
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (restoreFailure: Exception) {
promotionFailure.addSuppressed(restoreFailure)
}
}
throw promotionFailure
}
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
}
} finally {
staging.deleteRecursively()
}
}
plugins {
id("com.android.application")
@@ -9,77 +59,274 @@ plugins {
}
val mpvVersion = "v1.0.7"
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
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 downloadLibmpv by tasks.registering {
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
val media3Version = "1.10.1"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
val downloadLibmpv = tasks.register("downloadLibmpv") {
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvDir, ".manifest")
inputs.property("version", mpvVersion)
inputs.property("sourceUrl", mpvUrl)
inputs.property("sha256", mpvSha256)
outputs.files(aar, manifest)
doLast {
mpvDir.mkdirs()
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
stamp.writeText(mpvVersion)
mpvDir.parentFile.mkdirs()
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
try {
staging.mkdirs()
val stagedAar = File(staging, mpvAar)
try {
providers.exec {
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
}
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
promoteDirectory(staging, mpvDir)
} finally {
staging.deleteRecursively()
}
}
}
val assVersion = "fp-3"
val assDir = layout.buildDirectory.dir("libass").get().asFile
val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar")
val downloadLibass by tasks.registering {
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val outDir = File(mpvDir, "libcxx")
inputs.file(aar)
outputs.dir(outDir)
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
outDir.mkdirs()
providers.exec {
commandLine(
"unzip",
"-q",
"-o",
aar.absolutePath,
"jni/*/libc++_shared.so",
"-d",
outDir.absolutePath
)
}.result.get().assertNormalExitValue()
}
}
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
// build-only; the APK contains one FFmpeg implementation for both players.
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val libraries = listOf("avcodec", "avutil", "swresample")
inputs.file(aar)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
outputs.files(
abis.flatMap { abi ->
libraries.map { library -> File(mpvFfmpegDevelopmentDir, "native/$abi/lib$library.so") }
}
)
outputs.files(
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
manifest
)
doLast {
val staging = File(
mpvFfmpegDevelopmentDir.parentFile,
"${mpvFfmpegDevelopmentDir.name}.staging-${UUID.randomUUID()}"
)
try {
val sourceArchive = File(staging, "ffmpeg-$mpvFfmpegVersion.tar.xz")
val includeDir = File(staging, "include")
val nativeDir = File(staging, "native")
staging.mkdirs()
try {
providers.exec {
commandLine("curl", "-sfL", mpvFfmpegSourceUrl, "-o", sourceArchive.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download FFmpeg $mpvFfmpegVersion headers", error)
}
verifySha256(sourceArchive, mpvFfmpegSourceSha256, "FFmpeg $mpvFfmpegVersion source")
val extractedSource = File(staging, "source").apply { mkdirs() }
try {
providers.exec {
commandLine(
"tar",
"-xJf",
sourceArchive.absolutePath,
"--strip-components=1",
"-C",
extractedSource.absolutePath
)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
}
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
project.copy {
from(File(extractedSource, library)) {
include("*.h")
}
into(File(includeDir, library))
}
}
File(includeDir, "libavutil/avconfig.h").writeText(
"""
|/* Generated for Plezy's little-endian Android ABIs. */
|#ifndef AVUTIL_AVCONFIG_H
|#define AVUTIL_AVCONFIG_H
|#define AV_HAVE_BIGENDIAN 0
|#define AV_HAVE_FAST_UNALIGNED 0
|#endif /* AVUTIL_AVCONFIG_H */
|
""".trimMargin()
)
project.copy {
from(zipTree(aar)) {
include(
"jni/*/libavcodec.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
)
eachFile {
path = path.removePrefix("jni/")
}
}
includeEmptyDirs = false
into(nativeDir)
}
val missing = abis.flatMap { abi ->
libraries.map { library -> File(nativeDir, "$abi/lib$library.so") }
}.filterNot(File::isFile)
if (missing.isNotEmpty()) {
throw GradleException(
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
)
}
File(staging, ".manifest").writeText(
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
)
sourceArchive.delete()
extractedSource.deleteRecursively()
promoteDirectory(staging, mpvFfmpegDevelopmentDir)
} finally {
staging.deleteRecursively()
}
stamp.writeText(assVersion)
}
}
val doviVersion = "2.3.1"
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
val doviAbis = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android"
val doviArtifacts = mapOf(
"arm64-v8a" to Pair(
"aarch64-linux-android",
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
),
"armeabi-v7a" to Pair(
"armv7-linux-androideabi",
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
),
"x86" to Pair(
"i686-linux-android",
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
),
"x86_64" to Pair(
"x86_64-linux-android",
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
)
)
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
val downloadLibdovi by tasks.registering {
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
val downloadLibdovi = tasks.register("downloadLibdovi") {
val manifest = File(doviDir, ".manifest")
inputs.property("version", doviVersion)
inputs.property("baseUrl", doviBaseUrl)
doviArtifacts.forEach { (abi, artifact) ->
inputs.property("$abi.triple", artifact.first)
inputs.property("$abi.sha256", artifact.second)
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
}
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
doLast {
doviDir.mkdirs()
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
doviAbis.forEach { (abi, triple) ->
val archive = File(doviDir, "$triple.tar.gz")
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
val outDir = File(doviDir, "$abi/lib")
outDir.mkdirs()
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
archive.delete()
doviDir.parentFile.mkdirs()
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
try {
staging.mkdirs()
val downloads = File(staging, ".downloads").apply { mkdirs() }
doviArtifacts.forEach { (abi, artifact) ->
val (triple, expectedSha256) = artifact
val archiveName = "libdovi-$triple.tar.gz"
val archive = File(downloads, archiveName)
val sourceUrl = "$doviBaseUrl/$archiveName"
try {
providers.exec {
commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $archiveName v$doviVersion", error)
}
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
try {
providers.exec {
commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to extract $archiveName", error)
}
if (!File(outDir, "libdovi.a").isFile) {
throw GradleException("$archiveName did not contain the expected libdovi.a")
}
}
if (!downloads.deleteRecursively()) {
throw GradleException("Failed to clean staged libdovi archives")
}
val manifestText = buildString {
append("version=$doviVersion\n")
doviArtifacts.forEach { (abi, artifact) ->
append("$abi=${artifact.first},${artifact.second}\n")
}
}
File(staging, ".manifest").writeText(manifestText)
promoteDirectory(staging, doviDir)
} finally {
staging.deleteRecursively()
}
stamp.writeText(doviVersion)
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
@@ -90,12 +337,14 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}",
"-DMPV_FFMPEG_ROOT=${mpvFfmpegDevelopmentDir.absolutePath}"
)
}
}
@@ -111,6 +360,7 @@ android {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
@@ -141,51 +391,147 @@ android {
debugSymbolLevel = "FULL"
}
}
// Instrumentation target that runs R8 (see testBuildType below).
//
// R8 only ever ran on `release`, so every gate in this repository exercised code
// the shipped APK does not contain: reflective lookups, JNI callbacks and native
// library loading can all break under shrinking while every debug check passes.
// #1703 shipped that way — DefaultRenderersFactory's Class.forName for the bundled
// FFmpeg audio renderer failed in release builds only.
//
// Inherits release's minification and keep rules (the Flutter plugin has already
// installed them by the time this block runs) but stays debuggable and debug-signed,
// so it is an ordinary test artifact and never a publishable one. Debuggable also
// makes the Flutter plugin treat it as debug mode, so it uses debug Dart artifacts.
create("minified") {
initWith(getByName("release"))
isDebuggable = true
// Resource shrinking is orthogonal to the reachability this variant guards and
// would only slow the instrumentation build down.
isShrinkResources = false
testProguardFiles("proguard-test-rules.pro")
proguardFile("proguard-instrumentation-rules.pro")
// Release has no signing config unless key.properties exists, which would leave
// this variant unsigned and uninstallable in CI.
signingConfig = signingConfigs.getByName("debug")
// Plugin subprojects only publish debug and release variants.
matchingFallbacks += listOf("debug", "release")
ndk {
debugSymbolLevel = "NONE"
}
}
}
// Instrumentation normally runs against `debug`; the R8 reachability gate opts into the
// minified variant with -Pplezy.testBuildType=minified. Only one build type can host
// androidTest, and the existing playback suites need media3 builder APIs the app itself
// never calls — which R8 legitimately shrinks — so they stay on debug.
testBuildType = (findProperty("plezy.testBuildType") as String?) ?: "debug"
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
// pickFirst only suppresses the duplicate libc++ merge error; the
// sourceSets rule below makes libmpv's newer runtime win for
// std::from_chars<float>, while older native consumers remain ABI-compatible.
pickFirsts.add("lib/*/libc++_shared.so")
}
}
sourceSets {
getByName("main") {
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
// order cannot accidentally select the older libc++ copy.
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
}
}
lint {
// Enforce the app-owned minSdk boundary without auditing upstream AndroidX.
checkDependencies = false
checkOnly += setOf("NewApi")
}
}
// BackgroundWorkDiagnostics routes users to background_downloader's private
// notification channel. Fail the build if an upstream ref changes that ID.
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
val expectedChannelId = "background_downloader"
val downloaderProject = rootProject.findProject(":background_downloader")
val notificationsSource = downloaderProject?.projectDir?.resolve(
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
)
notificationsSource?.let(inputs::file)
doLast {
if (notificationsSource == null) {
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
return@doLast
}
val actualChannelId = Regex(
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
).find(notificationsSource.readText())?.groupValues?.get(1)
?: throw GradleException("Could not locate background_downloader's notification channel ID")
if (actualChannelId != expectedChannelId) {
throw GradleException(
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
"update BackgroundWorkDiagnostics and its tests"
)
}
}
}
tasks.named("preBuild").configure {
dependsOn(verifyBackgroundDownloaderNotificationChannel)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
}
// Download libmpv and libass AARs before compilation
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(extractMpvLibcxx)
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
implementation("androidx.tvprovider:tvprovider:1.1.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
implementation("androidx.media3:media3-decoder:$media3Version")
implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation("androidx.media3:media3-common:$media3Version")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:$media3Version")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// Keeping libass in-project lets its static core share the app's native
// packaging rules.
implementation(project(":libass"))
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
testImplementation("junit:junit:4.13.2")
// 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")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
}
@@ -0,0 +1,9 @@
# Applied only to the `minified` variant, which exists to run R8 over the app under test.
#
# The instrumentation runner is loaded through the tested app's class loader, and its
# supertypes resolve from the app APK. Shrinking androidx.test there leaves the harness
# with an AndroidJUnitRunner it cannot link, and the run dies with ClassNotFoundException
# before a single test starts — reported as `tests="0"`, which is easy to mistake for a
# passing gate. No shipped build includes this file.
-keep class androidx.test.** { *; }
-dontwarn androidx.test.**
+20
View File
@@ -0,0 +1,20 @@
# Flutter turns minification on for every release build (FlutterPlugin sets
# releaseBuildType.isMinifyEnabled), and appends this file when it exists. Anything the
# app reaches only by name — reflection or JNI — therefore needs an explicit keep here.
# The bundled Media3 FFmpeg audio decoder (ALAC, DTS, DTS-HD, TrueHD, ...).
#
# DefaultRenderersFactory instantiates FfmpegAudioRenderer through Class.forName and no
# app code references it, so R8 shrinks the class away; media3's own consumer rules only
# -keepclassmembers its constructor, which neither keeps the class nor pins its name.
# ffmpeg_jni.cc separately resolves FfmpegAudioDecoder and its growOutputBuffer callback
# by name in JNI_OnLoad, and returns JNI_ERR when either is missing, which fails the whole
# System.loadLibrary("ffmpegJNI") call.
#
# Without these keeps a release build silently loses every codec this decoder adds:
# TrueHD/DTS-HD land on MediaCodecAudioRenderer, which has no decoder for them, and
# playback bails to the mpv fallback and loses ExoPlayer's Dolby Vision handling (#1703).
-keep class androidx.media3.decoder.ffmpeg.** { *; }
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
+11
View File
@@ -0,0 +1,11 @@
# Shrinker rules for the instrumentation APK of the `minified` variant.
#
# That variant exists to run R8 over the app under test, not over the harness. AGP
# applies the app's rules to the test APK too, which deletes the instrumentation runner
# and every test class the runner resolves by name — the run then dies with
# ClassNotFoundException before a single test starts. The harness is never shipped, so
# it has nothing to gain from shrinking.
-dontshrink
-dontoptimize
# The runner resolves the instrumentation class and every -e class filter by name.
-dontobfuscate
@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 Plezy contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.decoder.ffmpeg;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.audio.DefaultAudioSink;
import androidx.media3.exoplayer.source.MediaSource;
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.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class PlezyFfmpegPlaybackTest {
private static final String[] FIXTURES = {
"ffmpeg/stereo.flac",
"ffmpeg/surround_5_1.flac",
"ffmpeg/surround_7_1.flac",
"ffmpeg/planar_5_1.m4a",
"ffmpeg/surround_5_1_eac3.mka",
"ffmpeg/surround_5_1_dts.mka",
"ffmpeg/surround_5_1_truehd.mka"
};
@Test
public void sharedDecoderUsesLibmpvFfmpegAndPlaysAllFixtures() throws Exception {
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable());
String version = FfmpegLibrary.getVersion();
assertTrue("Expected libmpv's FFmpeg 8, got " + version, version != null && version.startsWith("Lavc62."));
for (String fixture : FIXTURES) {
playToEnd(fixture);
}
}
private static void playToEnd(String fixture) throws Exception {
Context instrumentationContext = InstrumentationRegistry.getInstrumentation().getContext();
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
File fixtureFile = copyFixture(instrumentationContext, context, fixture);
HandlerThread playbackThread = new HandlerThread("ffmpeg-test-player");
playbackThread.start();
Handler handler = new Handler(playbackThread.getLooper());
CountDownLatch completed = new CountDownLatch(1);
AtomicReference<ExoPlayer> playerReference = new AtomicReference<>();
AtomicReference<Throwable> errorReference = new AtomicReference<>();
handler.post(
() -> {
try {
RenderersFactory renderersFactory =
(eventHandler,
videoRendererEventListener,
audioRendererEventListener,
textRendererOutput,
metadataRendererOutput) ->
new Renderer[] {
new FfmpegAudioRenderer(
eventHandler, audioRendererEventListener, new DefaultAudioSink.Builder().build())
};
ExoPlayer player =
new ExoPlayer.Builder(context, renderersFactory)
.setLooper(playbackThread.getLooper())
.build();
playerReference.set(player);
player.addListener(
new Player.Listener() {
@Override
public void onPlayerError(PlaybackException error) {
errorReference.set(error);
completed.countDown();
}
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
if (playbackState == Player.STATE_ENDED) {
completed.countDown();
}
}
});
MediaSource source =
new ProgressiveMediaSource.Factory(
new DefaultDataSource.Factory(context), new DefaultExtractorsFactory())
.createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)));
player.setMediaSource(source);
player.prepare();
player.play();
} catch (Throwable error) {
errorReference.set(error);
completed.countDown();
}
});
boolean finished = completed.await(20, TimeUnit.SECONDS);
CountDownLatch released = new CountDownLatch(1);
handler.post(
() -> {
@Nullable ExoPlayer player = playerReference.get();
if (player != null) player.release();
playbackThread.quitSafely();
released.countDown();
});
boolean teardownFinished = released.await(5, TimeUnit.SECONDS);
playbackThread.join(5000);
boolean fixtureDeleted = fixtureFile.delete();
assertTrue("Player teardown timed out for " + fixture, teardownFinished);
assertTrue("Playback timed out for " + fixture, finished);
assertNull("Playback failed for " + fixture, errorReference.get());
assertTrue("Fixture cleanup failed for " + fixture, fixtureDeleted);
}
private static File copyFixture(
Context instrumentationContext, Context targetContext, String fixture) throws Exception {
File output = File.createTempFile("ffmpeg-fixture-", null, targetContext.getCacheDir());
try (InputStream input = instrumentationContext.getAssets().open(fixture);
OutputStream sink = new FileOutputStream(output)) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
sink.write(buffer, 0, count);
}
}
return output;
}
}
@@ -0,0 +1,72 @@
package androidx.media3.decoder.ffmpeg
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MimeTypes
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.exoplayer.PlezyRenderersFactory
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Asserts the bundled FFmpeg audio decoder is reachable the way production reaches it.
*
* Every path here is name-based, so R8 can sever it while the code still compiles and
* every debug check passes. #1703 shipped exactly that: the shrinker dropped
* FfmpegAudioRenderer and FfmpegAudioDecoder.growOutputBuffer, TrueHD and DTS-HD lost
* their only decoder, and 4K Dolby Vision files bailed to the mpv fallback.
*
* Run this against the `minified` build type (`-Pplezy.testBuildType=minified`); on an
* unminified variant it can only ever pass. Deliberately touches no ExoPlayer builder
* API, so no keep rule beyond the ones under test has to exist for it to run.
*
* Emptying `proguard-rules.pro` was verified to fail
* [nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode]; the renderer-list assertion
* kept passing, because something else in this variant still retains that class. Treat
* the JNI assertion as the load-bearing one, and `scripts/check_shrinker_rules.py` as the
* guard for the renderer's own keep.
*/
@RunWith(AndroidJUnit4::class)
class FfmpegDecoderReachabilityTest {
@Test
fun productionRendererListIncludesTheFfmpegAudioRenderer() {
// Goes through the app's own factory rather than repeating media3's Class.forName:
// the instrumentation APK shares a class loader with the app, so a direct reflective
// lookup can resolve a copy the harness carries even when the app APK lost its own.
// DefaultRenderersFactory swallows ClassNotFoundException as "built without the
// extension", so a shrunk renderer leaves no trace but missing codecs.
val context = InstrumentationRegistry.getInstrumentation().targetContext
val factory = PlezyRenderersFactory(context)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
val handler = Handler(Looper.getMainLooper())
val names = factory.createRenderers(
handler,
object : VideoRendererEventListener {},
object : AudioRendererEventListener {},
{ },
{ }
).map { it.name }
assertTrue("FfmpegAudioRenderer missing from $names", names.contains("FfmpegAudioRenderer"))
}
@Test
fun nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode() {
// isAvailable() covers the whole JNI handshake: the shared library loads, JNI_OnLoad
// resolves FfmpegAudioDecoder by name, and GetMethodID finds growOutputBuffer with a
// descriptor naming SimpleDecoderOutputBuffer. Any of those renamed or shrunk away
// makes this false.
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable())
// The formats MediaCodec has no decoder for on the affected devices.
assertTrue("no truehd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_TRUEHD))
assertTrue("no dts-hd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_DTS_HD))
}
}
@@ -0,0 +1,149 @@
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.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
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.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PlezyAudioModePlaybackTest {
private enum class AudioMode {
PASSTHROUGH_ALLOWED,
FORCE_DECODED,
DOWNMIX_NORMALIZED,
DOWNMIX_UNNORMALIZED,
NORMALIZATION
}
@Test
fun appAudioPipelinePlaysAcrossOutputModes() {
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
}
private fun playToEnd(fixture: String, mode: AudioMode) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
val handler = Handler(playbackThread.looper)
val completed = CountDownLatch(1)
val playerReference = AtomicReference<ExoPlayer>()
val errorReference = AtomicReference<Throwable>()
val outputPolicyConsulted = AtomicBoolean(false)
val normalizationAttachAttempted = AtomicBoolean(false)
val normalization = AudioNormalizationEffect { _, _, _ -> }
val downmixActive = AtomicBoolean(false)
handler.post {
try {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
shouldBlockDirectAudioOutput = { format ->
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
if (encoded) outputPolicyConsulted.set(true)
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
}
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
}
}
}
val player = ExoPlayer.Builder(context, factory)
.setLooper(playbackThread.looper)
.build()
playerReference.set(player)
player.addListener(object : Player.Listener {
override fun onAudioSessionIdChanged(audioSessionId: Int) {
if (mode == AudioMode.NORMALIZATION) {
normalizationAttachAttempted.set(true)
normalization.attach(audioSessionId, channelCount = 6)
}
}
override fun onPlayerError(error: PlaybackException) {
errorReference.set(error)
completed.countDown()
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
downmixActive.set(factory.channelMixProcessor.isActive)
completed.countDown()
}
}
})
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
player.setMediaSource(source)
player.prepare()
player.play()
} catch (error: Throwable) {
errorReference.set(error)
completed.countDown()
}
}
val finished = completed.await(20, TimeUnit.SECONDS)
val released = CountDownLatch(1)
handler.post {
playerReference.get()?.release()
normalization.release()
playbackThread.quitSafely()
released.countDown()
}
val teardownFinished = released.await(5, TimeUnit.SECONDS)
playbackThread.join(5_000)
val fixtureDeleted = fixtureFile.delete()
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
assertTrue("Playback timed out for $mode / $fixture", finished)
assertNull("Playback failed for $mode / $fixture", errorReference.get())
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
}
if (mode == AudioMode.NORMALIZATION) {
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
}
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
}
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
instrumentationContext.assets.open(fixture).use { input ->
output.outputStream().use(input::copyTo)
}
return output
}
}
+27 -1
View File
@@ -5,6 +5,7 @@
<!-- 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 -->
@@ -27,6 +28,7 @@
<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" />
@@ -40,6 +42,7 @@
android:banner="@drawable/tv_banner"
android:appCategory="video"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
@@ -48,7 +51,7 @@
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
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
@@ -59,6 +62,9 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -83,6 +89,21 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Launcher shelf artwork. Not exported: readers reach it only through the
per-URI read grants issued to CATEGORY_HOME packages by WatchNextProvider. -->
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="false"
android:grantUriPermissions="true" />
<receiver
android:name=".watchnext.SystemShelfUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -113,5 +134,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<!-- Required to identify HOME launchers as shelf artwork consumers. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
</queries>
</manifest>
+21 -1
View File
@@ -1,8 +1,9 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge)
project(plezy_native)
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
set(MPV_FFMPEG_ROOT "" CACHE PATH "Path to libmpv's FFmpeg headers and shared libraries")
add_library(dovi_bridge SHARED dovi_bridge.cpp)
@@ -17,3 +18,22 @@ else()
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
target_link_libraries(dovi_bridge log)
endif()
foreach(ffmpeg_library avcodec avutil swresample)
add_library(${ffmpeg_library} SHARED IMPORTED)
set_target_properties(${ffmpeg_library} PROPERTIES
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
endforeach()
add_library(ffmpegJNI SHARED
media3_ffmpeg_decoder/ffmpeg_jni.cc)
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
target_include_directories(ffmpegJNI PRIVATE
"${MPV_FFMPEG_ROOT}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
target_link_libraries(ffmpegJNI
avcodec
avutil
swresample
android
log)
+18 -32
View File
@@ -2,6 +2,7 @@
#include <jni.h>
#include <cstring>
#include <memory>
#include <new>
#include <vector>
@@ -18,6 +19,7 @@ static constexpr jint CONVERT_FAILED = -1;
static constexpr jint DESTINATION_TOO_SMALL = -2;
static constexpr jint MAX_RPU_INPUT_SIZE = 8192;
static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384;
static constexpr int MAX_ERROR_LOG_LENGTH = 256;
extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output,
@@ -62,60 +64,47 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
return CONVERT_FAILED;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
// The input is a complete (possibly escaped) HEVC UNSPEC62 NAL. Parsing it as
// a raw RPU after a framed-parser error can reinterpret malformed/truncated
// NAL bytes as valid metadata.
const auto rpu_len = static_cast<size_t>(payload_length);
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
using RpuPtr = std::unique_ptr<DoviRpuOpaque, decltype(&dovi_rpu_free)>;
RpuPtr rpu(dovi_parse_unspec62_nalu(scratch.data(), rpu_len), dovi_rpu_free);
if (rpu == nullptr) {
return CONVERT_FAILED;
}
const char* err = dovi_rpu_get_error(rpu);
const char* err = dovi_rpu_get_error(rpu.get());
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(scratch.data(), rpu_len);
if (rpu == nullptr) {
return CONVERT_FAILED;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
LOGW("RPU NAL parse failed: %.*s", MAX_ERROR_LOG_LENGTH, err);
return CONVERT_FAILED;
}
// Convert to target profile (mode 2 = P8.1 with no-op curves)
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
// Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op.
int32_t ret = dovi_convert_rpu_with_mode(rpu.get(), static_cast<uint8_t>(mode));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
err = dovi_rpu_get_error(rpu.get());
LOGW("RPU conversion failed (mode %d): %.*s", mode, MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
return CONVERT_FAILED;
}
// Write back as UNSPEC62 NAL
const DoviData* out = dovi_write_unspec62_nalu(rpu);
using DoviDataPtr = std::unique_ptr<const DoviData, decltype(&dovi_data_free)>;
DoviDataPtr out(dovi_write_unspec62_nalu(rpu.get()), dovi_data_free);
if (out == nullptr || out->data == nullptr || out->len == 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU write failed: %s", err ? err : "unknown");
if (out != nullptr) dovi_data_free(out);
dovi_rpu_free(rpu);
err = dovi_rpu_get_error(rpu.get());
LOGW("RPU write failed: %.*s", MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
return CONVERT_FAILED;
}
if (out->len > MAX_RPU_OUTPUT_SIZE) {
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
const auto writable = static_cast<size_t>(logical_output_len - output_offset);
if (out->len > writable) {
dovi_data_free(out);
dovi_rpu_free(rpu);
return DESTINATION_TOO_SMALL;
}
@@ -124,9 +113,6 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
const bool write_failed = env->ExceptionCheck();
const auto written = static_cast<jint>(out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return write_failed ? CONVERT_FAILED : written;
#endif
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Plezy contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef PLEZY_FFMPEG_AUDIO_BUFFER_H_
#define PLEZY_FFMPEG_AUDIO_BUFFER_H_
#include <limits.h>
#include <stdint.h>
namespace plezy {
namespace ffmpeg {
inline bool CheckedAudioByteCount(int sample_count, int channel_count, int bytes_per_sample, int* byte_count) {
if (sample_count < 0 || channel_count <= 0 || bytes_per_sample <= 0 || byte_count == nullptr) {
return false;
}
const int64_t size = static_cast<int64_t>(sample_count) * channel_count * bytes_per_sample;
if (size > INT_MAX) {
return false;
}
*byte_count = static_cast<int>(size);
return true;
}
inline bool CheckedAddByteCount(int current_size, int additional_size, int* total_size) {
if (current_size < 0 || additional_size < 0 || total_size == nullptr || current_size > INT_MAX - additional_size) {
return false;
}
*total_size = current_size + additional_size;
return true;
}
} // namespace ffmpeg
} // namespace plezy
#endif // PLEZY_FFMPEG_AUDIO_BUFFER_H_
@@ -0,0 +1,508 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include <android/log.h>
#include <jni.h>
#include "ffmpeg_audio_buffer.h"
extern "C" {
#ifdef __cplusplus
#define __STDC_CONSTANT_MACROS
#ifdef _STDINT_H
#undef _STDINT_H
#endif
#include <stdint.h>
#endif
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <libavutil/error.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
}
#define LOG_TAG "ffmpeg_jni"
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
// clang-format 18 and 21 disagree on escaped-newline alignment in these JNI macros.
// clang-format off
#define LIBRARY_FUNC(RETURN_TYPE, NAME, ...) \
extern "C" { \
JNIEXPORT RETURN_TYPE \
Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
} \
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
#define AUDIO_DECODER_FUNC(RETURN_TYPE, NAME, ...) \
extern "C" { \
JNIEXPORT RETURN_TYPE \
Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
} \
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
// clang-format on
#define ERROR_STRING_BUFFER_LENGTH 256
// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
// LINT.IfChange
static const int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
static const int AUDIO_DECODER_ERROR_OTHER = -2;
// LINT.ThenChange(../java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java)
namespace {
struct ResampleState {
SwrContext* context;
AVChannelLayout input_channel_layout;
AVSampleFormat input_sample_format;
AVSampleFormat output_sample_format;
int sample_rate;
};
} // namespace
static bool resampleConfigurationMatches(
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
static void releaseResampleState(ResampleState* state);
static jmethodID growOutputBufferMethod;
/**
* Returns the AVCodec with the specified name, or NULL if it is not available.
*/
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName);
/**
* Allocates and opens a new AVCodecContext for the specified codec, passing the
* provided extraData as initialization data for the decoder if it is non-NULL.
* Returns the created context.
*/
static AVCodecContext* createContext(
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount);
namespace {
struct GrowOutputBufferCallback {
uint8_t* operator()(int requiredSize) const;
JNIEnv* env;
jobject thiz;
jobject decoderOutputBuffer;
};
} // namespace
/**
* Decodes the packet into the output buffer, returning the number of bytes
* written, or a negative AUDIO_DECODER_ERROR constant value in the case of an
* error.
*/
static int decodePacket(
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
GrowOutputBufferCallback growBuffer);
/**
* Transforms ffmpeg AVERROR into a negative AUDIO_DECODER_ERROR constant value.
*/
static int transformError(int errorNumber);
/**
* Outputs a log message describing the avcodec error number.
*/
static void logError(const char* functionName, int errorNumber);
/**
* Releases the specified context.
*/
static void releaseContext(AVCodecContext* context);
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
LOGE("JNI_OnLoad: GetEnv failed");
return -1;
}
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
if (!clazz) {
LOGE("JNI_OnLoad: FindClass failed");
return -1;
}
growOutputBufferMethod = env->GetMethodID(
clazz, "growOutputBuffer",
"(Landroidx/media3/decoder/"
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
if (!growOutputBufferMethod) {
LOGE("JNI_OnLoad: GetMethodID failed");
return -1;
}
return JNI_VERSION_1_6;
}
LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); }
LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { return (jint)AV_INPUT_BUFFER_PADDING_SIZE; }
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != nullptr; }
AUDIO_DECODER_FUNC(
jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount) {
const AVCodec* codec = getCodecByName(env, codecName);
if (!codec) {
LOGE("Codec not found.");
return 0L;
}
return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, rawChannelCount);
}
AUDIO_DECODER_FUNC(
jint, ffmpegDecode, jlong context, jobject inputData, jint inputSize, jobject decoderOutputBuffer,
jobject outputData, jint outputSize) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
if (!inputData || !decoderOutputBuffer || !outputData) {
LOGE("Input and output buffers must be non-NULL.");
return -1;
}
if (inputSize < 0) {
LOGE("Invalid input buffer size: %d.", inputSize);
return -1;
}
if (outputSize < 0) {
LOGE("Invalid output buffer length: %d", outputSize);
return -1;
}
const jlong inputCapacity = env->GetDirectBufferCapacity(inputData);
const jlong outputCapacity = env->GetDirectBufferCapacity(outputData);
if (inputCapacity < inputSize || outputCapacity < outputSize) {
LOGE("Buffer size exceeds direct buffer capacity.");
return -1;
}
uint8_t* inputBuffer = (uint8_t*)env->GetDirectBufferAddress(inputData);
uint8_t* outputBuffer = (uint8_t*)env->GetDirectBufferAddress(outputData);
AVPacket* packet = av_packet_alloc();
if (!packet) {
LOGE("Failed to allocate packet.");
return -1;
}
packet->data = inputBuffer;
packet->size = inputSize;
const int ret = decodePacket(
(AVCodecContext*)context, packet, outputBuffer, outputSize,
GrowOutputBufferCallback{env, thiz, decoderOutputBuffer});
av_packet_free(&packet);
return ret;
}
uint8_t* GrowOutputBufferCallback::operator()(int requiredSize) const {
jobject newOutputData = env->CallObjectMethod(thiz, growOutputBufferMethod, decoderOutputBuffer, requiredSize);
if (env->ExceptionCheck()) {
LOGE("growOutputBuffer() failed");
env->ExceptionDescribe();
return nullptr;
}
if (env->GetDirectBufferCapacity(newOutputData) < requiredSize) {
LOGE("growOutputBuffer() returned an undersized or non-direct buffer.");
return nullptr;
}
return static_cast<uint8_t*>(env->GetDirectBufferAddress(newOutputData));
}
AUDIO_DECODER_FUNC(jint, ffmpegGetChannelCount, jlong context) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
return ((AVCodecContext*)context)->ch_layout.nb_channels;
}
AUDIO_DECODER_FUNC(jint, ffmpegGetSampleRate, jlong context) {
if (!context) {
LOGE("Context must be non-NULL.");
return -1;
}
return ((AVCodecContext*)context)->sample_rate;
}
AUDIO_DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
AVCodecContext* context = (AVCodecContext*)jContext;
if (!context) {
LOGE("Tried to reset without a context.");
return 0L;
}
AVCodecID codecId = context->codec_id;
if (codecId == AV_CODEC_ID_TRUEHD) {
jboolean outputFloat = (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
// Release and recreate the context if the codec is TrueHD.
// TODO: Figure out why flushing doesn't work for this codec.
releaseContext(context);
const AVCodec* codec = avcodec_find_decoder(codecId);
if (!codec) {
LOGE("Unexpected error finding codec %d.", codecId);
return 0L;
}
return (jlong)createContext(
env, codec, extraData, outputFloat,
/* rawSampleRate= */ -1,
/* rawChannelCount= */ -1);
}
avcodec_flush_buffers(context);
return (jlong)context;
}
AUDIO_DECODER_FUNC(void, ffmpegRelease, jlong context) {
if (context) {
releaseContext((AVCodecContext*)context);
}
}
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName) {
if (!codecName) {
return nullptr;
}
const char* codecNameChars = env->GetStringUTFChars(codecName, nullptr);
const AVCodec* codec = avcodec_find_decoder_by_name(codecNameChars);
env->ReleaseStringUTFChars(codecName, codecNameChars);
return codec;
}
static AVCodecContext* createContext(
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
jint rawChannelCount) {
AVCodecContext* context = avcodec_alloc_context3(codec);
if (!context) {
LOGE("Failed to allocate context.");
return nullptr;
}
context->request_sample_fmt = outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
if (extraData) {
jsize size = env->GetArrayLength(extraData);
if (size > INT_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
LOGE("Extradata is too large.");
releaseContext(context);
return nullptr;
}
context->extradata_size = size;
context->extradata = (uint8_t*)av_mallocz(static_cast<size_t>(size) + AV_INPUT_BUFFER_PADDING_SIZE);
if (!context->extradata) {
LOGE("Failed to allocate extradata.");
releaseContext(context);
return nullptr;
}
env->GetByteArrayRegion(extraData, 0, size, (jbyte*)context->extradata);
}
if (context->codec_id == AV_CODEC_ID_PCM_MULAW || context->codec_id == AV_CODEC_ID_PCM_ALAW) {
context->sample_rate = rawSampleRate;
av_channel_layout_default(&context->ch_layout, rawChannelCount);
}
context->err_recognition = AV_EF_IGNORE_ERR;
int result = avcodec_open2(context, codec, nullptr);
if (result < 0) {
logError("avcodec_open2", result);
releaseContext(context);
return nullptr;
}
return context;
}
static bool resampleConfigurationMatches(
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
return state && state->context && state->input_sample_format == static_cast<AVSampleFormat>(frame->format) &&
state->output_sample_format == context->request_sample_fmt && state->sample_rate == frame->sample_rate &&
av_channel_layout_compare(&state->input_channel_layout, &frame->ch_layout) == 0;
}
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
SwrContext* nextContext = nullptr;
const AVSampleFormat inputSampleFormat = static_cast<AVSampleFormat>(frame->format);
int result = swr_alloc_set_opts2(
&nextContext, // ps
&frame->ch_layout, // out_ch_layout
context->request_sample_fmt, // out_sample_fmt
frame->sample_rate, // out_sample_rate
&frame->ch_layout, // in_ch_layout
inputSampleFormat, // in_sample_fmt
frame->sample_rate, // in_sample_rate
0, // log_offset
nullptr // log_ctx
);
if (result < 0) {
logError("swr_alloc_set_opts2", result);
return result;
}
result = swr_init(nextContext);
if (result < 0) {
logError("swr_init", result);
swr_free(&nextContext);
return result;
}
AVChannelLayout nextInputChannelLayout = {};
result = av_channel_layout_copy(&nextInputChannelLayout, &frame->ch_layout);
if (result < 0) {
logError("av_channel_layout_copy", result);
swr_free(&nextContext);
return result;
}
swr_free(&state->context);
av_channel_layout_uninit(&state->input_channel_layout);
state->context = nextContext;
state->input_channel_layout = nextInputChannelLayout;
state->input_sample_format = inputSampleFormat;
state->output_sample_format = context->request_sample_fmt;
state->sample_rate = frame->sample_rate;
return 0;
}
static void releaseResampleState(ResampleState* state) {
if (!state) {
return;
}
swr_free(&state->context);
av_channel_layout_uninit(&state->input_channel_layout);
av_free(state);
}
static int decodePacket(
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
GrowOutputBufferCallback growBuffer) {
int result = avcodec_send_packet(context, packet);
if (result) {
logError("avcodec_send_packet", result);
return transformError(result);
}
int outSize = 0;
while (true) {
AVFrame* frame = av_frame_alloc();
if (!frame) {
LOGE("Failed to allocate output frame.");
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
result = avcodec_receive_frame(context, frame);
if (result) {
av_frame_free(&frame);
if (result == AVERROR(EAGAIN)) {
break;
}
logError("avcodec_receive_frame", result);
return transformError(result);
}
const AVSampleFormat sampleFormat = static_cast<AVSampleFormat>(frame->format);
const int channelCount = frame->ch_layout.nb_channels;
const int sampleRate = frame->sample_rate;
const int sampleCount = frame->nb_samples;
if (sampleFormat == AV_SAMPLE_FMT_NONE || channelCount <= 0 || sampleRate <= 0 || sampleCount < 0 ||
!frame->extended_data || !av_channel_layout_check(&frame->ch_layout)) {
LOGE("Decoder returned an invalid audio frame.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
ResampleState* resampleState = static_cast<ResampleState*>(context->opaque);
if (!resampleState) {
resampleState = static_cast<ResampleState*>(av_mallocz(sizeof(ResampleState)));
if (!resampleState) {
LOGE("Failed to allocate resampler state.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_OTHER;
}
context->opaque = resampleState;
}
if (!resampleConfigurationMatches(resampleState, context, frame)) {
result = configureResampler(resampleState, context, frame);
if (result < 0) {
av_frame_free(&frame);
return transformError(result);
}
}
const int bytesPerSample = av_get_bytes_per_sample(context->request_sample_fmt);
const int outputSampleCapacity = swr_get_out_samples(resampleState->context, sampleCount);
int outputByteCapacity;
int requiredOutputSize;
if (!plezy::ffmpeg::CheckedAudioByteCount(
outputSampleCapacity, channelCount, bytesPerSample, &outputByteCapacity) ||
!plezy::ffmpeg::CheckedAddByteCount(outSize, outputByteCapacity, &requiredOutputSize)) {
LOGE("Decoded audio output size is invalid or too large.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
if (requiredOutputSize > outputSize) {
LOGD(
"Output buffer size (%d) too small for output data (%d), "
"reallocating buffer.",
outputSize, requiredOutputSize);
outputSize = requiredOutputSize;
outputBuffer = growBuffer(outputSize);
if (!outputBuffer) {
LOGE("Failed to reallocate output buffer.");
av_frame_free(&frame);
return AUDIO_DECODER_ERROR_OTHER;
}
}
uint8_t* frameOutput = outputBuffer + outSize;
uint8_t* outputPlanes[] = {frameOutput};
result = swr_convert(
resampleState->context, outputPlanes, outputSampleCapacity, (const uint8_t**)frame->extended_data, sampleCount);
av_frame_free(&frame);
if (result < 0) {
logError("swr_convert", result);
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
int writtenByteCount;
int nextOutSize;
if (!plezy::ffmpeg::CheckedAudioByteCount(result, channelCount, bytesPerSample, &writtenByteCount) ||
writtenByteCount > outputByteCapacity ||
!plezy::ffmpeg::CheckedAddByteCount(outSize, writtenByteCount, &nextOutSize)) {
LOGE("Resampler returned an invalid output sample count.");
return AUDIO_DECODER_ERROR_INVALID_DATA;
}
outSize = nextOutSize;
}
return outSize;
}
static int transformError(int errorNumber) {
return errorNumber == AVERROR_INVALIDDATA ? AUDIO_DECODER_ERROR_INVALID_DATA : AUDIO_DECODER_ERROR_OTHER;
}
static void logError(const char* functionName, int errorNumber) {
char buffer[ERROR_STRING_BUFFER_LENGTH];
av_strerror(errorNumber, buffer, sizeof(buffer));
LOGE("Error in %s: %s", functionName, buffer);
}
static void releaseContext(AVCodecContext* context) {
if (!context) {
return;
}
releaseResampleState(static_cast<ResampleState*>(context->opaque));
context->opaque = nullptr;
avcodec_free_context(&context);
}
@@ -0,0 +1,294 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.decoder.ffmpeg;
import static com.google.common.base.Preconditions.checkNotNull;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.decoder.SimpleDecoder;
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
import java.nio.ByteBuffer;
import java.util.List;
/** Media3 audio decoder backed by the FFmpeg libraries packaged by libmpv. */
final class FfmpegAudioDecoder
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, FfmpegDecoderException> {
private static final int INITIAL_OUTPUT_BUFFER_SIZE_16BIT = 65535;
private static final int INITIAL_OUTPUT_BUFFER_SIZE_32BIT = INITIAL_OUTPUT_BUFFER_SIZE_16BIT * 2;
private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
private static final int AUDIO_DECODER_ERROR_OTHER = -2;
private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'};
private static final int FLAC_METADATA_TYPE_STREAM_INFO = 0;
private static final int FLAC_METADATA_BLOCK_HEADER_SIZE = 4;
private static final int FLAC_STREAM_INFO_DATA_SIZE = 34;
private final String codecName;
@Nullable private final byte[] extraData;
private final @C.PcmEncoding int encoding;
private int outputBufferSize;
private long nativeContext;
private boolean hasOutputFormat;
private volatile int channelCount;
private volatile int sampleRate;
FfmpegAudioDecoder(
Format format,
int numInputBuffers,
int numOutputBuffers,
int initialInputBufferSize,
boolean outputFloat)
throws FfmpegDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleDecoderOutputBuffer[numOutputBuffers]);
if (!FfmpegLibrary.isAvailable()) {
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
}
String mimeType = checkNotNull(format.sampleMimeType);
codecName = checkNotNull(FfmpegLibrary.getCodecName(mimeType));
extraData = getExtraData(mimeType, format.initializationData);
encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
outputBufferSize =
outputFloat ? INITIAL_OUTPUT_BUFFER_SIZE_32BIT : INITIAL_OUTPUT_BUFFER_SIZE_16BIT;
nativeContext =
ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
if (nativeContext == 0) {
throw new FfmpegDecoderException("Initialization failed.");
}
setInitialInputBufferSize(initialInputBufferSize);
}
@Override
public String getName() {
return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
}
@Override
protected DecoderInputBuffer createInputBuffer() {
return new DecoderInputBuffer(
DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
FfmpegLibrary.getInputBufferPaddingSize());
}
@Override
protected SimpleDecoderOutputBuffer createOutputBuffer() {
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
}
@Override
protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
return new FfmpegDecoderException("Unexpected decode error", error);
}
@Override
@Nullable
protected FfmpegDecoderException decode(
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
if (reset) {
nativeContext = ffmpegReset(nativeContext, extraData);
if (nativeContext == 0) {
return new FfmpegDecoderException("Error resetting (see logcat).");
}
}
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
int inputSize = inputData.limit();
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
int result =
ffmpegDecode(
nativeContext, inputData, inputSize, outputBuffer, outputData, outputBufferSize);
if (result == AUDIO_DECODER_ERROR_OTHER) {
return new FfmpegDecoderException("Error decoding (see logcat).");
} else if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
outputBuffer.shouldBeSkipped = true;
return null;
} else if (result == 0) {
outputBuffer.shouldBeSkipped = true;
return null;
}
if (!hasOutputFormat) {
channelCount = ffmpegGetChannelCount(nativeContext);
sampleRate = ffmpegGetSampleRate(nativeContext);
if (sampleRate == 0 && "alac".equals(codecName)) {
checkNotNull(extraData);
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
parsableExtraData.setPosition(extraData.length - 4);
sampleRate = parsableExtraData.readUnsignedIntToInt();
}
hasOutputFormat = true;
}
outputData = checkNotNull(outputBuffer.data);
outputData.position(0);
outputData.limit(result);
return null;
}
@SuppressWarnings("unused")
private ByteBuffer growOutputBuffer(SimpleDecoderOutputBuffer outputBuffer, int requiredSize) {
outputBufferSize = requiredSize;
return outputBuffer.grow(requiredSize);
}
@Override
public void release() {
super.release();
ffmpegRelease(nativeContext);
nativeContext = 0;
}
int getChannelCount() {
return channelCount;
}
int getSampleRate() {
return sampleRate;
}
@C.PcmEncoding
int getEncoding() {
return encoding;
}
@Nullable
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
case MimeTypes.AUDIO_OPUS:
return initializationData.get(0);
case MimeTypes.AUDIO_ALAC:
return getAlacExtraData(initializationData);
case MimeTypes.AUDIO_VORBIS:
return getVorbisExtraData(initializationData);
case MimeTypes.AUDIO_FLAC:
return getFlacExtraData(initializationData);
default:
return null;
}
}
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
byte[] magicCookie = initializationData.get(0);
int alacAtomLength = 12 + magicCookie.length;
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
alacAtom.putInt(alacAtomLength);
alacAtom.putInt(0x616c6163);
alacAtom.putInt(0);
alacAtom.put(magicCookie, 0, magicCookie.length);
return alacAtom.array();
}
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
byte[] header0 = initializationData.get(0);
byte[] header1 = initializationData.get(1);
byte[] extraData = new byte[header0.length + header1.length + 6];
extraData[0] = (byte) (header0.length >> 8);
extraData[1] = (byte) (header0.length & 0xFF);
System.arraycopy(header0, 0, extraData, 2, header0.length);
extraData[header0.length + 2] = 0;
extraData[header0.length + 3] = 0;
extraData[header0.length + 4] = (byte) (header1.length >> 8);
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
return extraData;
}
@Nullable
private static byte[] getFlacExtraData(List<byte[]> initializationData) {
for (int i = 0; i < initializationData.size(); i++) {
@Nullable byte[] streamInfo = extractFlacStreamInfo(initializationData.get(i));
if (streamInfo != null) {
return streamInfo;
}
}
return null;
}
@Nullable
private static byte[] extractFlacStreamInfo(byte[] data) {
int offset = 0;
if (arrayStartsWith(data, FLAC_STREAM_MARKER)) {
offset = FLAC_STREAM_MARKER.length;
}
if (data.length - offset == FLAC_STREAM_INFO_DATA_SIZE) {
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
System.arraycopy(data, offset, streamInfo, 0, FLAC_STREAM_INFO_DATA_SIZE);
return streamInfo;
}
if (data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE) {
int type = data[offset] & 0x7F;
int length =
((data[offset + 1] & 0xFF) << 16)
| ((data[offset + 2] & 0xFF) << 8)
| (data[offset + 3] & 0xFF);
if (type == FLAC_METADATA_TYPE_STREAM_INFO
&& length == FLAC_STREAM_INFO_DATA_SIZE
&& data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_DATA_SIZE) {
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
System.arraycopy(
data,
offset + FLAC_METADATA_BLOCK_HEADER_SIZE,
streamInfo,
0,
FLAC_STREAM_INFO_DATA_SIZE);
return streamInfo;
}
}
return null;
}
private static boolean arrayStartsWith(byte[] data, byte[] prefix) {
if (data.length < prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (data[i] != prefix[i]) {
return false;
}
}
return true;
}
private native long ffmpegInitialize(
String codecName,
@Nullable byte[] extraData,
boolean outputFloat,
int rawSampleRate,
int rawChannelCount);
private native int ffmpegDecode(
long context,
ByteBuffer inputData,
int inputSize,
SimpleDecoderOutputBuffer decoderOutputBuffer,
ByteBuffer outputData,
int outputSize);
private native int ffmpegGetChannelCount(long context);
private native int ffmpegGetSampleRate(long context);
private native long ffmpegReset(long context, @Nullable byte[] extraData);
private native void ffmpegRelease(long context);
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.decoder.ffmpeg;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
import static com.google.common.base.Preconditions.checkNotNull;
import android.os.Handler;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.TraceUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.CryptoConfig;
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
/** Decodes and renders audio using the FFmpeg libraries shared with libmpv. */
@UnstableApi
public final class FfmpegAudioRenderer extends DecoderAudioRenderer<FfmpegAudioDecoder> {
private static final String TAG = "FfmpegAudioRenderer";
private static final int NUM_BUFFERS = 16;
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
public FfmpegAudioRenderer(
@Nullable Handler eventHandler,
@Nullable AudioRendererEventListener eventListener,
AudioSink audioSink) {
super(eventHandler, eventListener, audioSink);
}
@Override
public String getName() {
return TAG;
}
@Override
protected @C.FormatSupport int supportsFormatInternal(Format format) {
String mimeType = checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
return C.FORMAT_UNSUPPORTED_TYPE;
} else if (!FfmpegLibrary.supportsFormat(mimeType)
|| (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
&& !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
return C.FORMAT_UNSUPPORTED_SUBTYPE;
} else if (format.cryptoType != C.CRYPTO_TYPE_NONE) {
return C.FORMAT_UNSUPPORTED_DRM;
} else {
return C.FORMAT_HANDLED;
}
}
@Override
public @AdaptiveSupport int supportsMixedMimeTypeAdaptation() {
return ADAPTIVE_NOT_SEAMLESS;
}
@Override
protected FfmpegAudioDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
throws FfmpegDecoderException {
TraceUtil.beginSection("createFfmpegAudioDecoder");
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
FfmpegAudioDecoder decoder =
new FfmpegAudioDecoder(
format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
TraceUtil.endSection();
return decoder;
}
@Override
protected Format getOutputFormat(FfmpegAudioDecoder decoder) {
checkNotNull(decoder);
return new Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setChannelCount(decoder.getChannelCount())
.setSampleRate(decoder.getSampleRate())
.setPcmEncoding(decoder.getEncoding())
.build();
}
private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
return sinkSupportsFormat(
Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
}
private boolean shouldOutputFloat(Format inputFormat) {
if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
return true;
}
@SinkFormatSupport
int formatSupport =
getSinkFormatSupport(
Util.getPcmFormat(
C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
switch (formatSupport) {
case SINK_FORMAT_SUPPORTED_DIRECTLY:
return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
case SINK_FORMAT_UNSUPPORTED:
case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
default:
return false;
}
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.decoder.ffmpeg;
import androidx.media3.decoder.DecoderException;
/** Thrown when an FFmpeg decoder error occurs. */
final class FfmpegDecoderException extends DecoderException {
FfmpegDecoderException(String message) {
super(message);
}
FfmpegDecoderException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.decoder.ffmpeg;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.LibraryLoader;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
/** Configures and queries the FFmpeg libraries shared with libmpv. */
@UnstableApi
final class FfmpegLibrary {
private static final String TAG = "FfmpegLibrary";
private static final LibraryLoader LOADER =
new LibraryLoader("ffmpegJNI") {
@Override
protected void loadLibrary(String name) {
System.loadLibrary(name);
}
};
@Nullable private static String version;
private static int inputBufferPaddingSize = C.LENGTH_UNSET;
private FfmpegLibrary() {}
/** Returns whether the JNI adapter and libmpv's FFmpeg libraries can be loaded. */
static boolean isAvailable() {
return LOADER.isAvailable();
}
/** Returns the linked FFmpeg version. The native libraries must be available. */
static String getVersion() {
String cachedVersion = version;
if (cachedVersion == null) {
cachedVersion = ffmpegGetVersion();
version = cachedVersion;
}
return cachedVersion;
}
/** Returns the required FFmpeg input-buffer padding. The native libraries must be available. */
static int getInputBufferPaddingSize() {
if (inputBufferPaddingSize == C.LENGTH_UNSET) {
inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
}
return inputBufferPaddingSize;
}
/** Returns whether the linked FFmpeg build supports the MIME type. */
static boolean supportsFormat(String mimeType) {
@Nullable String codecName = getCodecName(mimeType);
if (codecName == null) {
return false;
}
if (!ffmpegHasDecoder(codecName)) {
Log.w(TAG, "No " + codecName + " decoder available in libmpv's FFmpeg build.");
return false;
}
return true;
}
/** Returns the FFmpeg decoder name for a supported MIME type. */
@Nullable
static String getCodecName(String mimeType) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
return "aac";
case MimeTypes.AUDIO_MPEG:
case MimeTypes.AUDIO_MPEG_L1:
case MimeTypes.AUDIO_MPEG_L2:
return "mp3";
case MimeTypes.AUDIO_AC3:
return "ac3";
case MimeTypes.AUDIO_E_AC3:
case MimeTypes.AUDIO_E_AC3_JOC:
return "eac3";
case MimeTypes.AUDIO_TRUEHD:
return "truehd";
case MimeTypes.AUDIO_DTS:
case MimeTypes.AUDIO_DTS_HD:
return "dca";
case MimeTypes.AUDIO_VORBIS:
return "vorbis";
case MimeTypes.AUDIO_OPUS:
return "opus";
case MimeTypes.AUDIO_AMR_NB:
return "amrnb";
case MimeTypes.AUDIO_AMR_WB:
return "amrwb";
case MimeTypes.AUDIO_FLAC:
return "flac";
case MimeTypes.AUDIO_ALAC:
return "alac";
case MimeTypes.AUDIO_MLAW:
return "pcm_mulaw";
case MimeTypes.AUDIO_ALAW:
return "pcm_alaw";
default:
return null;
}
}
private static native String ffmpegGetVersion();
private static native int ffmpegGetInputBufferPaddingSize();
private static native boolean ffmpegHasDecoder(String codecName);
}
@@ -0,0 +1,338 @@
package com.edde746.plezy
import android.content.Context
import android.content.SharedPreferences
import java.security.MessageDigest
import java.util.concurrent.Executors
internal data class HistoricalExitRecord(
val reason: Int,
val status: Int,
val importance: Int,
val timestamp: Long
)
internal object AndroidStartupPhases {
const val NATIVE_ON_CREATE = "native_on_create"
private val allowed = setOf(
NATIVE_ON_CREATE,
"dart_main",
"runApp",
"first_frame",
"database_open_started",
"database_ready",
"credentials_loaded",
"binding_started",
"binding_settled",
"main_screen"
)
fun sanitize(raw: String?): String? = raw?.takeIf(allowed::contains)
}
internal class StartupPhaseStore(
readPhase: () -> String?,
private val persistPhase: (String) -> Boolean
) {
val previousPhase: String? = AndroidStartupPhases.sanitize(readPhase())
@Synchronized
fun mark(raw: String?): Boolean {
val phase = AndroidStartupPhases.sanitize(raw) ?: return false
return persistPhase(phase)
}
}
internal data class RuntimeDiagnosticSnapshot(
val codecContext: String? = null,
val channelCount: Int? = null,
val sampleRate: Int? = null,
val selectedDecoder: String? = null,
val passthroughEnabled: Boolean? = null,
val downmixEnabled: Boolean? = null,
val normalizationEnabled: Boolean? = null,
val uiState: String? = null
)
internal object AndroidRuntimeDiagnostics {
const val UI_STARTUP = "startup"
const val UI_AUTHENTICATION = "authentication"
const val UI_MAIN_SCREEN = "main_screen"
const val UI_PLAYER = "player"
const val UI_PLAYER_DISPOSED = "player_disposed"
private const val PREFERENCES_NAME = "plezy_runtime_diagnostics"
private const val KEY_CODEC_CONTEXT = "codec_context"
private const val KEY_CHANNEL_COUNT = "channel_count"
private const val KEY_SAMPLE_RATE = "sample_rate"
private const val KEY_SELECTED_DECODER = "selected_decoder"
private const val KEY_PASSTHROUGH_ENABLED = "passthrough_enabled"
private const val KEY_DOWNMIX_ENABLED = "downmix_enabled"
private const val KEY_NORMALIZATION_ENABLED = "normalization_enabled"
private const val KEY_UI_STATE = "ui_state"
private val allowedCodecContexts = setOf(
"audio:aac",
"audio:ac3",
"audio:eac3",
"audio:dts",
"audio:truehd",
"audio:flac",
"audio:pcm",
"audio:other",
"video:dolby_vision",
"video:hevc",
"video:avc",
"video:other"
)
private val allowedUiStates = setOf(
UI_STARTUP,
UI_AUTHENTICATION,
UI_MAIN_SCREEN,
UI_PLAYER,
UI_PLAYER_DISPOSED
)
private val decoderNamePattern = Regex("[A-Za-z0-9_.:-]{1,96}")
private val executor by lazy {
Executors.newSingleThreadExecutor { task ->
Thread(task, "plezy-runtime-diagnostics").apply { isDaemon = true }
}
}
fun codecContextForMime(mimeType: String?): String? {
val normalized = mimeType?.lowercase() ?: return null
return when (normalized) {
"audio/mp4a-latm" -> "audio:aac"
"audio/ac3" -> "audio:ac3"
"audio/eac3", "audio/eac3-joc" -> "audio:eac3"
"audio/vnd.dts", "audio/vnd.dts.hd" -> "audio:dts"
"audio/true-hd" -> "audio:truehd"
"audio/flac" -> "audio:flac"
"audio/raw" -> "audio:pcm"
"video/dolby-vision" -> "video:dolby_vision"
"video/hevc" -> "video:hevc"
"video/avc" -> "video:avc"
else -> when {
normalized.startsWith("audio/") -> "audio:other"
normalized.startsWith("video/") -> "video:other"
else -> null
}
}
}
fun sanitizeDecoderName(raw: String?): String? {
if (raw == null) return null
return raw.takeIf(decoderNamePattern::matches) ?: "unknown"
}
fun sanitizeUiState(raw: String?): String? = raw?.takeIf(allowedUiStates::contains)
fun sanitize(snapshot: RuntimeDiagnosticSnapshot): RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot(
codecContext = snapshot.codecContext?.takeIf(allowedCodecContexts::contains),
channelCount = snapshot.channelCount?.takeIf { it in 1..32 },
sampleRate = snapshot.sampleRate?.takeIf { it in 1..768_000 },
selectedDecoder = sanitizeDecoderName(snapshot.selectedDecoder),
passthroughEnabled = snapshot.passthroughEnabled,
downmixEnabled = snapshot.downmixEnabled,
normalizationEnabled = snapshot.normalizationEnabled,
uiState = sanitizeUiState(snapshot.uiState)
)
fun update(
context: Context,
codecContext: String? = null,
channelCount: Int? = null,
sampleRate: Int? = null,
selectedDecoder: String? = null,
passthroughEnabled: Boolean? = null,
downmixEnabled: Boolean? = null,
normalizationEnabled: Boolean? = null,
uiState: String? = null
) {
val safeCodecContext = codecContext?.takeIf(allowedCodecContexts::contains)
val safeChannelCount = channelCount?.takeIf { it in 1..32 }
val safeSampleRate = sampleRate?.takeIf { it in 1..768_000 }
val safeDecoder = sanitizeDecoderName(selectedDecoder)
val safeUiState = sanitizeUiState(uiState)
val applicationContext = context.applicationContext
executor.execute {
runCatching {
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply {
safeCodecContext?.let { putString(KEY_CODEC_CONTEXT, it) }
safeChannelCount?.let { putInt(KEY_CHANNEL_COUNT, it) }
safeSampleRate?.let { putInt(KEY_SAMPLE_RATE, it) }
safeDecoder?.let { putString(KEY_SELECTED_DECODER, it) }
passthroughEnabled?.let { putBoolean(KEY_PASSTHROUGH_ENABLED, it) }
downmixEnabled?.let { putBoolean(KEY_DOWNMIX_ENABLED, it) }
normalizationEnabled?.let { putBoolean(KEY_NORMALIZATION_ENABLED, it) }
safeUiState?.let { putString(KEY_UI_STATE, it) }
}.commit()
}
}
}
fun clearPlayback(context: Context) {
val applicationContext = context.applicationContext
executor.execute {
runCatching {
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit()
.remove(KEY_CODEC_CONTEXT)
.remove(KEY_CHANNEL_COUNT)
.remove(KEY_SAMPLE_RATE)
.remove(KEY_SELECTED_DECODER)
.remove(KEY_PASSTHROUGH_ENABLED)
.remove(KEY_DOWNMIX_ENABLED)
.remove(KEY_NORMALIZATION_ENABLED)
.putString(KEY_UI_STATE, UI_PLAYER_DISPOSED)
.commit()
}
}
}
fun read(context: Context): RuntimeDiagnosticSnapshot {
val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
return RuntimeDiagnosticSnapshot(
codecContext = runCatching { preferences.getString(KEY_CODEC_CONTEXT, null) }
.getOrNull()
?.takeIf(allowedCodecContexts::contains),
channelCount = runCatching { preferences.getInt(KEY_CHANNEL_COUNT, -1) }.getOrNull()?.takeIf { it in 1..32 },
sampleRate = runCatching { preferences.getInt(KEY_SAMPLE_RATE, -1) }.getOrNull()?.takeIf { it in 1..768_000 },
selectedDecoder = sanitizeDecoderName(runCatching { preferences.getString(KEY_SELECTED_DECODER, null) }.getOrNull()),
passthroughEnabled = readBoolean(preferences, KEY_PASSTHROUGH_ENABLED),
downmixEnabled = readBoolean(preferences, KEY_DOWNMIX_ENABLED),
normalizationEnabled = readBoolean(preferences, KEY_NORMALIZATION_ENABLED),
uiState = sanitizeUiState(runCatching { preferences.getString(KEY_UI_STATE, null) }.getOrNull())
)
}
private fun readBoolean(preferences: SharedPreferences, key: String): Boolean? {
if (!preferences.contains(key)) return null
return runCatching { preferences.getBoolean(key, false) }.getOrNull()
}
}
internal data class PreviousExitReport(
val reason: String,
val status: Int,
val importance: Int,
val timestamp: Long,
val deviceModel: String,
val apiLevel: Int,
val abi: String,
val lowRam: Boolean,
val startupPhase: String?,
val runtime: RuntimeDiagnosticSnapshot,
val dedupeKey: String
) {
fun toMap(): Map<String, Any> = buildMap {
put("reason", reason)
put("status", status)
put("importance", importance)
put("timestamp", timestamp)
put("deviceModel", deviceModel)
put("apiLevel", apiLevel)
put("abi", abi)
put("lowRam", lowRam)
startupPhase?.let { put("startupPhase", it) }
runtime.codecContext?.let { put("codecContext", it) }
runtime.channelCount?.let { put("channelCount", it) }
runtime.sampleRate?.let { put("sampleRate", it) }
runtime.selectedDecoder?.let { put("selectedDecoder", it) }
runtime.passthroughEnabled?.let { put("passthroughEnabled", it) }
runtime.downmixEnabled?.let { put("downmixEnabled", it) }
runtime.normalizationEnabled?.let { put("normalizationEnabled", it) }
runtime.uiState?.let { put("uiState", it) }
}
}
internal object AndroidExitReportMapper {
private const val REASON_LOW_MEMORY = 3
private const val REASON_CRASH = 4
private const val REASON_CRASH_NATIVE = 5
private const val REASON_ANR = 6
private const val REASON_USER_REQUESTED = 10
private const val REASON_USER_STOPPED = 11
private const val MAX_DEVICE_MODEL_LENGTH = 80
private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
fun map(
record: HistoricalExitRecord,
deviceModel: String,
apiLevel: Int,
abi: String,
lowRam: Boolean,
startupPhase: String? = null,
runtime: RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot()
): PreviousExitReport {
val dedupeKey = sha256(
listOf(
record.timestamp.toString(),
record.reason.toString(),
record.status.toString(),
record.importance.toString()
)
)
return PreviousExitReport(
reason = mapReason(record.reason),
status = record.status,
importance = record.importance,
timestamp = record.timestamp,
deviceModel = sanitizeDeviceModel(deviceModel),
apiLevel = apiLevel,
abi = abi.takeIf(supportedAbis::contains) ?: "unknown",
lowRam = lowRam,
startupPhase = AndroidStartupPhases.sanitize(startupPhase),
runtime = AndroidRuntimeDiagnostics.sanitize(runtime),
dedupeKey = dedupeKey
)
}
fun mapReason(reason: Int): String = when (reason) {
REASON_CRASH -> "crash"
REASON_CRASH_NATIVE -> "native_crash"
REASON_ANR -> "anr"
REASON_LOW_MEMORY -> "low_memory"
REASON_USER_REQUESTED, REASON_USER_STOPPED -> "user_requested"
else -> "other"
}
fun sanitizeDeviceModel(raw: String): String {
val sanitized = buildString(raw.length.coerceAtMost(MAX_DEVICE_MODEL_LENGTH)) {
var pendingSpace = false
raw.forEach { character ->
if (length >= MAX_DEVICE_MODEL_LENGTH) return@forEach
if (character.isWhitespace() || Character.isISOControl(character)) {
pendingSpace = isNotEmpty()
} else {
if (pendingSpace && length < MAX_DEVICE_MODEL_LENGTH) append(' ')
if (length < MAX_DEVICE_MODEL_LENGTH) append(character)
pendingSpace = false
}
}
}.trim()
return sanitized.ifEmpty { "unknown" }
}
private fun sha256(fields: List<String>): String {
val digest = MessageDigest.getInstance("SHA-256")
fields.forEach { field ->
digest.update(field.length.toString().toByteArray(Charsets.UTF_8))
digest.update(':'.code.toByte())
digest.update(field.toByteArray(Charsets.UTF_8))
digest.update(';'.code.toByte())
}
return digest.digest().joinToString("") { byte ->
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
}
}
}
internal class PreviousExitReportStore(
private val readDedupeKey: () -> String?,
private val persistDedupeKey: (String) -> Boolean
) {
@Synchronized
fun takeIfNew(report: PreviousExitReport): Map<String, Any>? {
if (readDedupeKey() == report.dedupeKey) return null
if (!persistDedupeKey(report.dedupeKey)) return null
return report.toMap()
}
}
@@ -0,0 +1,256 @@
package com.edde746.plezy
import android.app.ActivityManager
import android.app.NotificationManager
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
/**
* Raw per-signal snapshot of everything the OS is willing to tell us about
* whether our own background work will be allowed to run.
*
* Every field is nullable: a null means "this API level cannot answer", not
* "unrestricted". [BackgroundWorkClassifier] treats unknown as OK, because a
* warning we cannot substantiate is worse than no warning at all.
*/
internal data class BackgroundWorkSignals(
val sdkInt: Int? = null,
val backgroundRestricted: Boolean? = null,
val standbyBucket: Int? = null,
val notificationsEnabled: Boolean? = null,
val downloadChannelBlocked: Boolean? = null,
val dataSaverRestricted: Boolean? = null,
val ignoringBatteryOptimizations: Boolean? = null
)
/**
* Turns [BackgroundWorkSignals] into a verdict plus ordered reasons.
*
* Deliberately pure and free of `Build.VERSION` reads so the whole decision
* table is exercisable from plain JVM unit tests.
*/
internal object BackgroundWorkClassifier {
const val VERDICT_OK = "ok"
const val VERDICT_DEGRADED = "degraded"
const val VERDICT_BLOCKED = "blocked"
const val REASON_BACKGROUND_RESTRICTED = "background_restricted"
const val REASON_STANDBY_RESTRICTED = "standby_restricted"
const val REASON_DOWNLOAD_CHANNEL_BLOCKED = "download_channel_blocked"
const val REASON_NOTIFICATIONS_DISABLED = "notifications_disabled"
const val REASON_DATA_SAVER = "data_saver"
/**
* `STANDBY_BUCKET_RESTRICTED` (API 30) and `STANDBY_BUCKET_NEVER`. Declared
* literally so the classifier stays compilable and testable below those API
* levels. `RARE` (40) is intentionally absent — it is reached by ordinary
* disuse and says nothing about a user-imposed restriction, so warning on it
* would fire on healthy devices.
*/
const val BUCKET_RESTRICTED = 45
const val BUCKET_NEVER = 50
private val blockingReasons = setOf(
REASON_BACKGROUND_RESTRICTED,
REASON_STANDBY_RESTRICTED
)
/** Ordered most- to least-actionable; the UI leads with the first entry. */
fun reasons(signals: BackgroundWorkSignals): List<String> = buildList {
if (signals.backgroundRestricted == true) add(REASON_BACKGROUND_RESTRICTED)
if (signals.standbyBucket != null && isRestrictedBucket(signals.standbyBucket)) add(REASON_STANDBY_RESTRICTED)
// App-wide denial is more fundamental than a muted channel and routes to
// a screen that also exists before the channel has been created.
if (signals.notificationsEnabled == false) {
add(REASON_NOTIFICATIONS_DISABLED)
} else if (signals.downloadChannelBlocked == true) {
add(REASON_DOWNLOAD_CHANNEL_BLOCKED)
}
if (signals.dataSaverRestricted == true) add(REASON_DATA_SAVER)
}
fun verdict(signals: BackgroundWorkSignals, reasons: List<String> = reasons(signals)): String = when {
reasons.any(blockingReasons::contains) -> VERDICT_BLOCKED
signals.sdkInt != null &&
signals.sdkInt >= Build.VERSION_CODES.TIRAMISU &&
signals.notificationsEnabled == false -> VERDICT_BLOCKED
reasons.isNotEmpty() -> VERDICT_DEGRADED
else -> VERDICT_OK
}
fun isRestrictedBucket(bucket: Int): Boolean = bucket == BUCKET_RESTRICTED || bucket == BUCKET_NEVER
fun toMap(signals: BackgroundWorkSignals): Map<String, Any?> {
val reasons = reasons(signals)
return mapOf(
"verdict" to verdict(signals, reasons),
"reasons" to reasons,
"sdkInt" to signals.sdkInt,
"backgroundRestricted" to signals.backgroundRestricted,
"standbyBucket" to signals.standbyBucket,
"notificationsEnabled" to signals.notificationsEnabled,
"downloadChannelBlocked" to signals.downloadChannelBlocked,
"dataSaverRestricted" to signals.dataSaverRestricted,
"ignoringBatteryOptimizations" to signals.ignoringBatteryOptimizations
)
}
}
/** Settings screen a remedy button can send the user to. */
internal enum class BackgroundSettingsTarget(val id: String) {
APP_DETAILS("app_details"),
APP_NOTIFICATIONS("app_notifications"),
NOTIFICATION_CHANNEL("notification_channel");
companion object {
fun fromId(raw: String?): BackgroundSettingsTarget? = entries.firstOrNull { it.id == raw }
}
}
/** Declarative intent description, kept Intent-free so it unit-tests on the JVM. */
internal data class SettingsIntentSpec(
val action: String,
val data: String? = null,
val stringExtras: Map<String, String> = emptyMap()
)
/**
* Ordered candidate intents per target, each falling back to the app details
* page — the one Settings screen that exists on every Android build and that
* reaches Battery → Unrestricted on One UI, Pixel, and AOSP alike.
*
* Samsung's own "Background usage limits" screen lives in an unexported
* `com.samsung.android.lool` activity that moves between One UI releases, so
* it is deliberately not targeted; the user gets written directions instead.
*/
internal object BackgroundSettingsIntents {
fun specsFor(
target: BackgroundSettingsTarget,
packageName: String,
sdkInt: Int
): List<SettingsIntentSpec> {
val appDetails = SettingsIntentSpec(
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
data = "package:$packageName"
)
if (sdkInt < Build.VERSION_CODES.O) return listOf(appDetails)
return when (target) {
BackgroundSettingsTarget.APP_DETAILS -> listOf(appDetails)
BackgroundSettingsTarget.APP_NOTIFICATIONS -> listOf(
SettingsIntentSpec(
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
),
appDetails
)
BackgroundSettingsTarget.NOTIFICATION_CHANNEL -> listOf(
SettingsIntentSpec(
action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS,
stringExtras = mapOf(
Settings.EXTRA_APP_PACKAGE to packageName,
Settings.EXTRA_CHANNEL_ID to BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID
)
),
SettingsIntentSpec(
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
),
appDetails
)
}
}
}
/**
* Reads the OS-visible reasons background downloads may be blocked.
*
* Every read is for our own package and needs no permission. Notably absent is
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: it is a Play-policy-restricted
* permission whose qualifying use cases do not include media downloading.
* Whitelist state is reported for support diagnostics only.
*/
internal object BackgroundWorkDiagnostics {
/** `background_downloader`'s channel id (`Notifications.kt`, `nId`). */
const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "background_downloader"
fun read(context: Context): BackgroundWorkSignals = BackgroundWorkSignals(
sdkInt = Build.VERSION.SDK_INT,
backgroundRestricted = readBackgroundRestricted(context),
standbyBucket = readStandbyBucket(context),
notificationsEnabled = readNotificationsEnabled(context),
downloadChannelBlocked = readDownloadChannelBlocked(context),
dataSaverRestricted = readDataSaverRestricted(context),
ignoringBatteryOptimizations = readIgnoringBatteryOptimizations(context)
)
private fun readBackgroundRestricted(context: Context): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
return runCatching {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).isBackgroundRestricted
}.getOrNull()
}
private fun readStandbyBucket(context: Context): Int? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
return runCatching {
(context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
}.getOrNull()
}
private fun readNotificationsEnabled(context: Context): Boolean? = runCatching {
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
}.getOrNull()
/**
* Null until the plugin has created its channel (first download), which is
* correct — there is nothing to warn about before then.
*/
private fun readDownloadChannelBlocked(context: Context): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
return runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = manager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) ?: return null
channel.importance == NotificationManager.IMPORTANCE_NONE
}.getOrNull()
}
private fun readDataSaverRestricted(context: Context): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null
return runCatching {
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivity.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}.getOrNull()
}
private fun readIgnoringBatteryOptimizations(context: Context): Boolean? = runCatching {
(context.getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(context.packageName)
}.getOrNull()
/**
* Launches the first candidate Settings screen that resolves. Returns false
* when the device has none of them — some TV and Fire OS builds ship without
* a battery settings activity entirely.
*/
fun openSettings(context: Context, target: BackgroundSettingsTarget): Boolean {
for (spec in BackgroundSettingsIntents.specsFor(target, context.packageName, Build.VERSION.SDK_INT)) {
val launched = runCatching {
val intent = Intent(spec.action).apply {
spec.data?.let { data = Uri.parse(it) }
spec.stringExtras.forEach { (key, value) -> putExtra(key, value) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
true
}.getOrDefault(false)
if (launched) return true
}
return false
}
}
@@ -0,0 +1,46 @@
package com.edde746.plezy
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.view.TextureView
internal class DeferredSurfaceTextureListener(
private val delegate: TextureView.SurfaceTextureListener,
private val handler: Handler = Handler(Looper.getMainLooper()),
private val onSurfaceAvailable: () -> Unit = {},
private val isCurrentSurface: (SurfaceTexture) -> Boolean
) : TextureView.SurfaceTextureListener {
private var pendingResize: Runnable? = null
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
delegate.onSurfaceTextureAvailable(surface, width, height)
onSurfaceAvailable()
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
pendingResize?.let(handler::removeCallbacks)
pendingResize = Runnable {
pendingResize = null
if (isCurrentSurface(surface)) {
delegate.onSurfaceTextureSizeChanged(surface, width, height)
}
}
handler.postDelayed(pendingResize!!, RESIZE_DELAY_MILLIS)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
delegate.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let(handler::removeCallbacks)
pendingResize = null
return delegate.onSurfaceTextureDestroyed(surface)
}
private companion object {
const val RESIZE_DELAY_MILLIS = 100L
}
}
@@ -0,0 +1,194 @@
package com.edde746.plezy
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.content.FileProvider
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
internal class ExternalPlayerChannel(private val activity: Activity) {
companion object {
private const val CHANNEL = "com.plezy/external_player"
const val REQUEST_CODE = 7461
private const val API_MX_RETURN_RESULT = "return_result"
private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW"
private const val API_MX_RESULT_POSITION = "position"
private const val API_MX_RESULT_DURATION = "duration"
private const val API_MX_RESULT_END_BY = "end_by"
private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion"
private const val API_MX_TITLE = "title"
private const val API_MX_FILENAME = "filename"
private const val API_MX_SECURE_URI = "secure_uri"
private const val API_VLC_RESULT_POSITION = "extra_position"
private const val API_VLC_RESULT_DURATION = "extra_duration"
private const val API_VIMU_TITLE = "forcename"
private const val API_VIMU_SEEK_POSITION = "startfrom"
private const val API_VIMU_RESUME = "forceresume"
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
private const val API_VIMU_RESULT_ERROR = 4
private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1
private val positionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
private val durationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION)
}
private var pendingResult: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, CHANNEL).setMethodCallHandler(::onMethodCall)
}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST_CODE) return false
val result = pendingResult
pendingResult = null
if (result == null) {
android.util.Log.w("ExternalPlayerChannel", "Result received without a pending channel call")
} else {
result.success(buildResult(resultCode, data))
}
return true
}
fun dispose() {
pendingResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
pendingResult = null
}
internal fun buildResult(resultCode: Int, data: Intent?): Map<String, Any?> {
val extras = data?.extras
val endPosition = firstNumberExtra(extras, positionExtras)
val duration = firstNumberExtra(extras, durationExtras)
val action = data?.action
val playbackCompleted = when (action) {
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
else -> false
}
val playbackError = action == API_VIMU_RESULT_ID && resultCode == API_VIMU_RESULT_ERROR
return mapOf(
"launched" to true,
"resultCode" to resultCode,
"resultOk" to (resultCode == Activity.RESULT_OK),
"action" to action,
"positionMs" to endPosition,
"durationMs" to duration,
"playbackCompleted" to playbackCompleted,
"playbackError" to playbackError
)
}
private fun firstNumberExtra(extras: Bundle?, keys: Array<String>): Long? {
if (extras == null) return null
for (key in keys) {
@Suppress("DEPRECATION")
val value = extras.get(key)
when (value) {
is Number -> return value.toLong()
is String -> value.toLongOrNull()?.let { return it }
}
}
return null
}
@Suppress("UNCHECKED_CAST")
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "openVideo") {
result.notImplemented()
return
}
val filePath = call.argument<String>("filePath")
val packageNames = call.argument<List<Any?>>("packages")
?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) }
?: emptyList()
val title = call.argument<String>("title")?.trim()?.takeIf(String::isNotEmpty)
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return
}
if (pendingResult != null) {
result.error("ALREADY_ACTIVE", "An external player is already active", null)
return
}
try {
val source = resolveSource(filePath)
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
for (packageName in targetPackages) {
try {
pendingResult = result
activity.startActivityForResult(
buildIntent(source, packageName, startPositionMs, title),
REQUEST_CODE
)
return
} catch (_: ActivityNotFoundException) {
pendingResult = null
}
}
val message = if (packageNames.isEmpty()) {
"No app found for video"
} else {
"No app found for packages: ${packageNames.joinToString(", ")}"
}
result.error("APP_NOT_FOUND", message, null)
} catch (error: Exception) {
pendingResult = null
result.error("LAUNCH_FAILED", error.message ?: error.javaClass.simpleName, null)
}
}
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
private fun resolveSource(filePath: String): Source {
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
val uri = Uri.parse(filePath)
return Source(uri, grantRead = false, fileName = uri.lastPathSegment)
}
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
return Source(uri, grantRead = true, fileName = uri.lastPathSegment)
}
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
val file = File(path)
val uri = FileProvider.getUriForFile(activity, "com.edde746.plezy.fileprovider", file)
return Source(uri, grantRead = true, fileName = file.name)
}
private fun buildIntent(
source: Source,
packageName: String?,
startPositionMs: Long,
title: String?
): Intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(source.uri, "video/*")
if (source.grantRead) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
packageName?.let(::setPackage)
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
if (startPosition > 0) {
putExtra(API_MX_RESULT_POSITION, startPosition)
putExtra(API_VIMU_SEEK_POSITION, startPosition)
}
putExtra(API_MX_RETURN_RESULT, true)
putExtra(API_MX_SECURE_URI, true)
putExtra(API_VIMU_RESUME, false)
title?.let {
putExtra(API_MX_TITLE, it)
putExtra(API_VIMU_TITLE, it)
}
source.fileName?.let { putExtra(API_MX_FILENAME, it) }
}
}
@@ -0,0 +1,41 @@
package com.edde746.plezy
internal enum class FlutterRenderer(
val diagnosticName: String,
val shellArgument: String?
) {
SKIA("Skia", "--enable-impeller=false"),
IMPELLER("Impeller", null)
}
/** Selects the Flutter UI renderer before the engine starts. */
internal object FlutterRendererPolicy {
private const val ANDROID_12_API = 31
fun select(
isEWaste: Boolean,
manufacturer: String,
isAndroidTv: Boolean,
sdkInt: Int,
supportsVulkan11: Boolean,
is64Bit: Boolean
): FlutterRenderer {
if (isEWaste) return FlutterRenderer.SKIA
if (manufacturer.equals("NVIDIA", ignoreCase = true)) return FlutterRenderer.SKIA
if (manufacturer.equals("Huawei", ignoreCase = true) ||
manufacturer.equals("HONOR", ignoreCase = true)
) {
return FlutterRenderer.SKIA
}
if (!isAndroidTv) return FlutterRenderer.IMPELLER
if (sdkInt < ANDROID_12_API || manufacturer.equals("Amazon", ignoreCase = true) || !supportsVulkan11) {
return FlutterRenderer.SKIA
}
// 32-bit Android TV SoCs are the low-memory / low-throughput class. Skia avoids
// Impeller/Vulkan's substantially higher raster cost on these devices.
if (!is64Bit) return FlutterRenderer.SKIA
return FlutterRenderer.IMPELLER
}
}
@@ -7,23 +7,23 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.SurfaceTexture
import android.net.Uri
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.provider.Settings
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import androidx.annotation.RequiresApi
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.ThemeHelper
@@ -35,25 +35,53 @@ import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
var usingSkia = false
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"
private val startupPhaseLock = Any()
@Volatile private var startupPhaseInitializationAttempted = false
@Volatile private var startupPhaseStore: StartupPhaseStore? = null
@Volatile private var previousRuntimeDiagnostics = RuntimeDiagnosticSnapshot()
private val exitDiagnosticsExecutor by lazy {
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "plezy-exit-diagnostics").apply { isDaemon = true }
}
}
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
private const val LOW_MEM_THRESHOLD_BYTES = 2252L shl 20
private var selectedFlutterRenderer = FlutterRenderer.IMPELLER
}
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private val DEVICE_CHANNEL = "com.plezy/device"
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 APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
private var watchNextPlugin: WatchNextPlugin? = null
private var nativeTextInputFocused = false
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
private var activityStarted = false
private val externalPlayerChannel = ExternalPlayerChannel(this)
private val exitDiagnosticsRequested = AtomicBoolean(false)
private inline fun logTextInputDiag(message: () -> String) {
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
@@ -68,6 +96,8 @@ class MainActivity : FlutterActivity() {
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
private fun isPipSupportedDevice(): Boolean = !isAndroidTvDevice() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
private fun isImeVisible(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
@@ -140,6 +170,7 @@ class MainActivity : FlutterActivity() {
val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv")
val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH)
val isAutomotive = pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
val reasons = mutableListOf<String>()
if (isTelevisionUiMode) reasons.add("ui_mode_television")
@@ -149,7 +180,11 @@ class MainActivity : FlutterActivity() {
if (!hasTouchscreen) reasons.add("no_touchscreen")
return mapOf(
"isTv" to reasons.isNotEmpty(),
// A car is never a TV: rotary-only head units report no touchscreen, and
// an OEM image can carry a stray leanback flag. Keep the raw reasons for
// diagnostics, but never let them promote a vehicle to the TV experience.
"isTv" to (!isAutomotive && reasons.isNotEmpty()),
"isAutomotive" to isAutomotive,
"reasons" to reasons,
"isTelevisionUiMode" to isTelevisionUiMode,
"hasTelevisionFeature" to hasTelevisionFeature,
@@ -162,7 +197,174 @@ class MainActivity : FlutterActivity() {
)
}
/** Hardware capability signals used by Dart to pick the visual-effects tier. */
private fun getPerformanceSignals(): Map<String, Any> {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
return mapOf(
// Actual process bitness: low-end TV boxes often run 32-bit userspace.
"is64Bit" to Process.is64Bit(),
"isLowRamDevice" to activityManager.isLowRamDevice,
"totalMemBytes" to memoryInfo.totalMem
)
}
private fun initializeStartupPhaseStore() {
var shouldMarkNativeOnCreate = false
synchronized(startupPhaseLock) {
if (startupPhaseInitializationAttempted) return
startupPhaseInitializationAttempted = true
try {
previousRuntimeDiagnostics = AndroidRuntimeDiagnostics.read(this)
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
startupPhaseStore = StartupPhaseStore(
readPhase = { preferences.getString(LAST_STARTUP_PHASE_KEY, null) },
persistPhase = { phase ->
preferences.edit().putString(LAST_STARTUP_PHASE_KEY, phase).commit()
}
)
shouldMarkNativeOnCreate = true
} catch (_: Throwable) {
Log.w(TAG, "Startup phase persistence unavailable")
}
}
if (shouldMarkNativeOnCreate) {
queueStartupPhase(AndroidStartupPhases.NATIVE_ON_CREATE)
}
}
private fun queueStartupPhase(raw: String?, result: MethodChannel.Result? = null) {
val phase = AndroidStartupPhases.sanitize(raw)
if (phase == null) {
result?.let { completeStartupPhase(it, false) }
return
}
AndroidRuntimeDiagnostics.update(this, uiState = uiStateForStartupPhase(phase))
try {
exitDiagnosticsExecutor.execute {
val persisted = try {
startupPhaseStore?.mark(phase) == true
} catch (_: Throwable) {
Log.w(TAG, "Startup phase update failed")
false
}
result?.let { reply ->
runOnUiThread { completeStartupPhase(reply, persisted) }
}
}
} catch (_: Throwable) {
Log.w(TAG, "Startup phase update could not start")
result?.let { completeStartupPhase(it, false) }
}
}
private fun uiStateForStartupPhase(phase: String): String = when (phase) {
"credentials_loaded", "binding_started", "binding_settled" -> AndroidRuntimeDiagnostics.UI_AUTHENTICATION
"main_screen" -> AndroidRuntimeDiagnostics.UI_MAIN_SCREEN
else -> AndroidRuntimeDiagnostics.UI_STARTUP
}
private fun completeStartupPhase(result: MethodChannel.Result, persisted: Boolean) {
try {
result.success(persisted)
} catch (_: Throwable) {
Log.w(TAG, "Startup phase reply failed")
}
}
private fun handlePreviousExit(result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
completePreviousExit(result, null)
return
}
if (!exitDiagnosticsRequested.compareAndSet(false, true)) {
completePreviousExit(result, null)
return
}
try {
exitDiagnosticsExecutor.execute {
val report = try {
readPreviousExit()
} catch (_: Throwable) {
Log.w(TAG, "Previous exit diagnostics failed")
null
}
runOnUiThread { completePreviousExit(result, report) }
}
} catch (_: RejectedExecutionException) {
completePreviousExit(result, null)
} catch (_: Throwable) {
Log.w(TAG, "Previous exit diagnostics could not start")
completePreviousExit(result, null)
}
}
private fun completePreviousExit(result: MethodChannel.Result, report: Map<String, Any>?) {
try {
result.success(report)
} catch (_: Throwable) {
Log.w(TAG, "Previous exit diagnostics reply failed")
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun readPreviousExit(): Map<String, Any>? {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val exitInfo = activityManager
.getHistoricalProcessExitReasons(packageName, 0, 1)
.firstOrNull()
?: return null
val report = AndroidExitReportMapper.map(
record = HistoricalExitRecord(
reason = exitInfo.reason,
status = exitInfo.status,
importance = exitInfo.importance,
timestamp = exitInfo.timestamp
),
deviceModel = Build.MODEL,
apiLevel = Build.VERSION.SDK_INT,
abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
lowRam = activityManager.isLowRamDevice,
startupPhase = startupPhaseStore?.previousPhase,
runtime = previousRuntimeDiagnostics
)
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
return PreviousExitReportStore(
readDedupeKey = { preferences.getString(LAST_EXIT_DEDUPE_KEY, null) },
persistDedupeKey = { key ->
preferences.edit().putString(LAST_EXIT_DEDUPE_KEY, key).commit()
}
).takeIfNew(report)
}
/**
* Same triple DevicePerformance uses for the reduced tier on the Dart
* side — keep the two in sync. Evaluated here too because engine shell
* args must be decided before Dart runs.
*/
private fun isLowRamClass(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
return !Process.is64Bit() || activityManager.isLowRamDevice || memoryInfo.totalMem <= LOW_MEM_THRESHOLD_BYTES
}
/** User-assigned device name (Settings > About > Device name), or null. */
private fun getDeviceName(): String? {
// The name the user gave the device; also used by Cast/Nearby.
val name = Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME)
if (!name.isNullOrBlank()) return name
// Fallback: the Bluetooth name usually mirrors the device name. Reading the
// settings string needs no BLUETOOTH permission (unlike BluetoothAdapter).
val bt = Settings.Secure.getString(contentResolver, "bluetooth_name")
return if (!bt.isNullOrBlank()) bt else null
}
override fun onCreate(savedInstanceState: Bundle?) {
// Snapshot the previous process phase before this launch can overwrite it.
initializeStartupPhaseStore()
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
// screen and Flutter's first frame for non-default themes (e.g. OLED).
@@ -250,6 +452,20 @@ class MainActivity : FlutterActivity() {
return handled
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!externalPlayerChannel.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onDestroy() {
externalPlayerChannel.dispose()
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
super.onDestroy()
}
private fun handleWatchNextIntent(intent: Intent?) {
val contentId = WatchNextPlugin.handleIntent(intent)
if (contentId != null) {
@@ -260,24 +476,38 @@ class MainActivity : FlutterActivity() {
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
usingSkia = shouldDisableImpeller()
if (usingSkia) args.add("--enable-impeller=false")
selectedFlutterRenderer = selectFlutterRenderer()
selectedFlutterRenderer.shellArgument?.let { args.add(it) }
if (isLowRamClass()) {
// Bound the memory pools Dart can't reach: Skia's GPU resource cache
// is sized from the surface area (hundreds of MB on a 4K-composited
// TV) and the Dart old gen defaults to a large fraction of physical
// RAM. Both drive LMK kills on 2GB boxes (#1349).
if (selectedFlutterRenderer == FlutterRenderer.SKIA) {
args.add("--resource-cache-max-bytes-threshold=50331648")
}
args.add("--old-gen-heap-size=256")
Log.i(
TAG,
"Low-RAM device: capped engine caches " +
"(renderer=${selectedFlutterRenderer.diagnosticName}, oldGen=256MB)"
)
}
return args
}
private fun shouldDisableImpeller(): Boolean {
// Android TV devices — weaker GPUs, less Impeller testing
if (isAndroidTvDevice()) return true
if (DeviceQuirks.isEWaste) return true
// NVIDIA Tegra (Shield TV)
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
// Huawei/HONOR Kirin SoCs use Mali GPUs
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
) {
return true
}
return false
private fun selectFlutterRenderer(): FlutterRenderer {
val isAndroidTv = isAndroidTvDevice()
val vulkan11 = 0x401000 // FEATURE_VULKAN_HARDWARE_VERSION encodes 1.1.0 as 0x401000
return FlutterRendererPolicy.select(
isEWaste = DeviceQuirks.isEWaste,
manufacturer = Build.MANUFACTURER,
isAndroidTv = isAndroidTv,
sdkInt = Build.VERSION.SDK_INT,
supportsVulkan11 = isAndroidTv &&
packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11),
is64Bit = Process.is64Bit()
)
}
override fun getRenderMode(): RenderMode {
@@ -293,41 +523,49 @@ class MainActivity : FlutterActivity() {
}
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
this.flutterTextureView = flutterTextureView
val original = flutterTextureView.surfaceTextureListener ?: return
val handler = Handler(Looper.getMainLooper())
var pendingResize: Runnable? = null
var lastWidth = 0
var lastHeight = 0
flutterTextureView.surfaceTextureListener =
DeferredSurfaceTextureListener(
delegate = original,
onSurfaceAvailable = ::tryReconnectFlutterSurface
) { surface ->
flutterTextureView.isAvailable && flutterTextureView.surfaceTexture === surface
}
}
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
original.onSurfaceTextureAvailable(surface, width, height)
override fun onStop() {
activityStarted = false
if (isAndroidTvDevice()) flutterSurfaceReconnectPending = true
super.onStop()
}
override fun onStart() {
super.onStart()
activityStarted = true
tryReconnectFlutterSurface()
}
private fun tryReconnectFlutterSurface() {
if (!activityStarted || !flutterSurfaceReconnectPending) return
val textureView = flutterTextureView ?: return
textureView.post {
if (!activityStarted ||
!flutterSurfaceReconnectPending ||
textureView !== flutterTextureView ||
!textureView.isAttachedToWindow ||
!textureView.isAvailable
) {
return@post
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
if (width == lastWidth && height == lastHeight) return
lastWidth = width
lastHeight = height
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, width, height)
}
}
handler.postDelayed(pendingResize!!, 100)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
original.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = null
lastWidth = 0
lastHeight = 0
return original.onSurfaceTextureDestroyed(surface)
}
// Some TV firmware retains an available TextureView while invalidating
// its compositor surface during standby. Force Flutter's supported
// surface-swap path so rendering resumes without restarting the engine.
Log.i(TAG, "Reconnecting Flutter texture surface after Android TV standby")
textureView.pause()
textureView.resume()
flutterSurfaceReconnectPending = false
}
}
@@ -335,14 +573,39 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
flutterEngine.plugins.add(MpvAudioPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getTvDetection" -> result.success(getAndroidTvDetection())
"getDeviceName" -> result.success(getDeviceName())
"getPerformanceSignals" -> result.success(getPerformanceSignals())
"getBackgroundWorkSignals" -> result.success(
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
)
"openBackgroundSettings" -> {
val target = BackgroundSettingsTarget.fromId(call.arguments as? String)
result.success(target != null && BackgroundWorkDiagnostics.openSettings(this, target))
}
"getPreviousExit" -> handlePreviousExit(result)
"setStartupPhase" -> queueStartupPhase(call.arguments as? String, result)
"setRuntimeUiState" -> {
val uiState = AndroidRuntimeDiagnostics.sanitizeUiState(call.arguments as? String)
if (uiState == null) {
result.success(false)
} else {
AndroidRuntimeDiagnostics.update(this, uiState = uiState)
result.success(true)
}
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
handleDeviceAdjustmentCall(call.method, call.arguments, result)
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setNativeTextInputFocused" -> {
@@ -369,67 +632,12 @@ class MainActivity : FlutterActivity() {
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"requestForeground" -> result.success(requestForeground())
else -> result.notImplemented()
}
}
// External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"openVideo" -> {
val filePath = call.argument<String>("filePath")
val packageName = call.argument<String>("package")
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (packageName != null) {
setPackage(packageName)
}
}
startActivity(intent)
result.success(true)
} catch (e: android.content.ActivityNotFoundException) {
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
} catch (e: Exception) {
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
}
}
externalPlayerChannel.attach(flutterEngine.dartExecutor.binaryMessenger)
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
"getRenderer" -> result.success(selectedFlutterRenderer.diagnosticName)
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
@@ -461,7 +669,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isPipSupportedDevice())
}
"enter" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -469,7 +677,7 @@ class MainActivity : FlutterActivity() {
return@setMethodCallHandler
}
if (isAndroidTvDevice()) {
if (!isPipSupportedDevice()) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
return@setMethodCallHandler
}
@@ -496,7 +704,7 @@ class MainActivity : FlutterActivity() {
}
}
"setAutoPipReady" -> {
if (isAndroidTvDevice()) {
if (!isPipSupportedDevice()) {
autoPipReady = false
result.success(true)
return@setMethodCallHandler
@@ -521,31 +729,92 @@ class MainActivity : FlutterActivity() {
}
}
private fun requestForeground(): Boolean = try {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.moveTaskToFront(taskId, 0)
true
} catch (e: Exception) {
Log.w(TAG, "Failed to move task to foreground", e)
private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) {
try {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
when (method) {
"getBrightness" -> result.success(getScreenBrightnessFraction())
"setBrightness" -> {
setScreenBrightnessFraction(argumentAsDouble(arguments))
result.success(null)
}
"restoreBrightness" -> {
restoreScreenBrightness()
result.success(null)
}
"getMediaVolume" -> result.success(getMediaVolumeFraction())
"setMediaVolume" -> {
setMediaVolumeFraction(argumentAsDouble(arguments))
result.success(null)
}
else -> result.notImplemented()
}
if (launchIntent != null) {
startActivity(launchIntent)
true
} else {
false
}
} catch (launchError: Exception) {
Log.w(TAG, "Failed to start foreground activity", launchError)
false
} catch (e: IllegalArgumentException) {
result.error("INVALID_ARGUMENT", e.message ?: e.javaClass.simpleName, null)
} catch (e: Exception) {
result.error("DEVICE_ADJUSTMENT_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
private fun argumentAsDouble(arguments: Any?): Double {
val value = (arguments as? Number)?.toDouble()
?: throw IllegalArgumentException("Expected a numeric value")
if (value.isNaN() || value.isInfinite()) {
throw IllegalArgumentException("Expected a finite numeric value")
}
return value.coerceIn(0.0, 1.0)
}
private fun getScreenBrightnessFraction(): Double {
val windowBrightness = window.attributes.screenBrightness
if (windowBrightness >= 0f) return windowBrightness.coerceIn(0f, 1f).toDouble()
return try {
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS).coerceIn(0, 255) / 255.0
} catch (e: Settings.SettingNotFoundException) {
0.5
}
}
private fun setScreenBrightnessFraction(value: Double) {
if (originalWindowBrightness == null) originalWindowBrightness = window.attributes.screenBrightness
val attributes = window.attributes
attributes.screenBrightness = value.coerceIn(0.0, 1.0).toFloat()
window.attributes = attributes
}
private fun restoreScreenBrightness() {
val attributes = window.attributes
attributes.screenBrightness = originalWindowBrightness ?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
window.attributes = attributes
originalWindowBrightness = null
}
private fun getMediaVolumeFraction(): Double {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val minVolume = streamMinVolume(audioManager)
if (maxVolume <= minVolume) return 0.0
val volume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).coerceIn(minVolume, maxVolume)
return (volume - minVolume).toDouble() / (maxVolume - minVolume).toDouble()
}
private fun setMediaVolumeFraction(value: Double) {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val minVolume = streamMinVolume(audioManager)
val target = (minVolume + value.coerceIn(0.0, 1.0) * (maxVolume - minVolume))
.roundToInt()
.coerceIn(minVolume, maxVolume)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
}
private fun streamMinVolume(audioManager: AudioManager): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
} else {
0
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
flutterEngine?.let { engine ->
@@ -559,7 +828,7 @@ class MainActivity : FlutterActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
if (!isAndroidTvDevice() &&
if (isPipSupportedDevice() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
autoPipReady &&
@@ -578,6 +847,7 @@ class MainActivity : FlutterActivity() {
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun isPipPermissionGranted(): Boolean {
val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOpsManager.checkOpNoThrow(
@@ -587,6 +857,7 @@ class MainActivity : FlutterActivity() {
) == AppOpsManager.MODE_ALLOWED
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams {
val (w, h) = if (width <= 0 || height <= 0) {
Pair(16, 9)
@@ -0,0 +1,28 @@
package com.edde746.plezy
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
/**
* Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts,
* except that FEATURE_AUTOMOTIVE vetoes the verdict outright. Kept in sync with
* the Dart-facing detection so native gating matches PlatformDetector.isTV().
*/
object TvDetection {
fun isTv(context: Context): Boolean {
val pm = context.packageManager
val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
// A car is never a TV: rotary-only head units report no touchscreen, and an
// OEM image can carry a stray leanback flag.
if (pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
@Suppress("DEPRECATION")
return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION ||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
pm.hasSystemFeature("amazon.hardware.fire_tv") ||
!pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
}
}
@@ -0,0 +1,204 @@
package com.edde746.plezy.exoplayer
import android.os.Build
import android.view.SurfaceControl
import android.view.SurfaceView
import androidx.annotation.RequiresApi
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Auto-calibrates [ExoPlayerCore]'s subtitle/video layer offset by measuring, per device, how much
* later the codec VIDEO plane reaches the display than the GL subtitle OVERLAY plane — the
* inter-plane latency (a deep video VPP on TV boxes) that otherwise makes subtitles a frame ahead.
*
* Both planes are pinned to the same video-frame target (releaseTimeNs). Measuring BOTH with the
* same mechanism — [SurfaceTxProbe] + the API-34 previous-release fence — the per-SoC buffer-recycle
* bias cancels in the difference, leaving the pure offset:
*
* offsetFrames = round( (median(videoReleaseVsTarget) − median(overlayReleaseVsTarget)) / frameMs )
*
* This is a MEDIAN-of-each-plane model, NOT a per-frame paired difference: every sample is
* self-paired to its own frame's target, and the per-plane release-vs-target is a steady hardware
* constant, so the two medians need not come from the same frames — only from the same (stable)
* device. Both medians use a recent-sample window so they stay roughly contemporaneous, and a
* confidence gate refuses to converge on an ambiguous (~half-frame) result.
*
* Video samples arrive every few frames from the metadata listener (codec thread); overlay samples
* from the libass GL thread's pre-swap hook (sparse — only when subtitles swap). Once both planes
* have enough samples and the result is confident it converges ONCE, applies + persists, and stops
* all probing (zero steady-state overhead). Re-runs each play to re-confirm.
*
* Threads: probeVideo (codec), probeOverlay (libass GL), onResult (probe reader). The lock guards
* only counter/sample state and is NEVER held across the binder/JNI `applyTransactionToFrame` call
* (so the GL swap path is never stalled by it). The native slot ring is allocated atomically.
*/
internal class AssLatencyCalibrator(
private val videoSurface: SurfaceView,
private val overlaySurface: SurfaceView,
private val onCalibrated: (Int) -> Unit,
private val onDone: () -> Unit,
private val log: (String) -> Unit
) {
private val lock = Any()
private val finished = AtomicBoolean(false)
private val applyFail = AtomicInteger(0)
@Volatile private var frameIntervalNs: Long = 0L
@Volatile private var converged = false
@Volatile private var stopped = false
private var videoFrames = 0
private var videoAttempts = 0
private var overlaySwaps = 0
private var overlayAttempts = 0
private val videoRel = ArrayDeque<Double>()
private val overlayRel = ArrayDeque<Double>()
fun start() {
SurfaceTxProbe.sink = { tag, latch, release, count, state, source, cb ->
onResult(tag, latch, release, count, state, source, cb)
}
log("calibration started (API ${Build.VERSION.SDK_INT})")
}
fun probeVideo(releaseTimeNs: Long, fps: Float) {
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
if (fps > 1f) frameIntervalNs = (1_000_000_000.0 / fps).toLong()
var doAttach = false
var giveUp = false
synchronized(lock) {
videoFrames++
if (videoFrames > GIVEUP_FRAMES) {
giveUp = true
} else if (videoAttempts < VIDEO_ATTEMPT_CAP && videoFrames % VIDEO_EVERY == 0) {
videoAttempts++
doAttach = true
}
}
if (giveUp) {
finishIncomplete()
} else if (doAttach) {
attach(videoSurface, releaseTimeNs, SurfaceTxProbe.SOURCE_VIDEO)
}
}
fun probeOverlay(releaseTimeNs: Long) {
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
var doAttach = false
synchronized(lock) {
overlaySwaps++
if (overlayAttempts < OVERLAY_ATTEMPT_CAP && overlaySwaps % OVERLAY_EVERY == 0) {
overlayAttempts++
doAttach = true
}
}
if (doAttach) attach(overlaySurface, releaseTimeNs, SurfaceTxProbe.SOURCE_OVERLAY)
}
/** Never called while holding [lock]: applyTransactionToFrame is a binder call and runs on the
* GL swap path. The native slot allocation is atomic, so concurrent callers are safe. */
@RequiresApi(34)
private fun attach(surface: SurfaceView, releaseTimeNs: Long, source: Int) {
try {
val tx = SurfaceControl.Transaction()
SurfaceTxProbe.nativeAttach(tx, releaseTimeNs, source)
surface.applyTransactionToFrame(tx)
} catch (t: Throwable) {
if (applyFail.incrementAndGet() == 1) {
log("applyTransactionToFrame failed: ${t.javaClass.simpleName}: ${t.message}")
}
}
}
private fun onResult(
tag: Long,
latchNs: Long,
releaseNs: Long,
surfaceCount: Int,
fenceState: Int,
source: Int,
callbackNs: Long
) {
if (surfaceCount <= 0 || fenceState != FENCE_OK || releaseNs <= 0) return
val relMs = (releaseNs - tag) / 1_000_000.0
var result: Int? = null
synchronized(lock) {
if (converged || stopped) return
val list = if (source == SurfaceTxProbe.SOURCE_OVERLAY) overlayRel else videoRel
val window = if (source == SurfaceTxProbe.SOURCE_OVERLAY) OVERLAY_WINDOW else VIDEO_WINDOW
list.addLast(relMs)
while (list.size > window) list.removeFirst() // keep a recent window so medians stay fresh
result = tryComputeOffset()
}
result?.let {
onCalibrated(it)
finish()
}
}
private fun tryComputeOffset(): Int? {
val frameMs = frameIntervalNs / 1_000_000.0
if (frameMs <= 0.5) return null
if (videoRel.size < VIDEO_MIN || overlayRel.size < OVERLAY_MIN) return null
val medV = median(videoRel)
val medO = median(overlayRel)
val offsetMs = medV - medO
val raw = offsetMs / frameMs
val frames = raw.roundToInt()
// Confidence gate: only trust a measurement that points clearly at an integer frame count.
// An ambiguous ~half-frame result (corruption / drift / wrong fps) keeps collecting instead.
if (abs(raw - frames) > CONFIDENCE_TOL) return null
val clamped = frames.coerceIn(-2, 2)
converged = true
log(
"CALIBRATED offsetFrames=$clamped raw=${"%.2f".format(raw)} " +
"(video=${"%.1f".format(medV)}ms − overlay=${"%.1f".format(medO)}ms = ${"%.1f".format(offsetMs)}ms " +
"/ frameMs=${"%.2f".format(frameMs)}) samples video=${videoRel.size} overlay=${overlayRel.size} " +
"applyFail=${applyFail.get()}"
)
return clamped
}
private fun finishIncomplete() {
if (converged || stopped) return
log(
"calibration incomplete after $videoFrames frames " +
"(video=${videoRel.size} overlay=${overlayRel.size}); keeping seed"
)
finish()
}
fun stop() = finish()
private fun finish() {
if (!finished.compareAndSet(false, true)) return
stopped = true
SurfaceTxProbe.sink = null
onDone()
}
private fun median(xs: Collection<Double>): Double {
val s = xs.sorted()
val n = s.size
return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
}
companion object {
private const val FENCE_OK = 3
private const val VIDEO_EVERY = 2
private const val OVERLAY_EVERY = 1 // overlay swaps are already sparse
private const val VIDEO_MIN = 40
private const val OVERLAY_MIN = 12
private const val VIDEO_WINDOW = 60 // recent-sample window for the median
private const val OVERLAY_WINDOW = 40
private const val VIDEO_ATTEMPT_CAP = 200 // ~16s @24fps/2 of video probing, then the window freezes
private const val OVERLAY_ATTEMPT_CAP = 60
private const val CONFIDENCE_TOL = 0.33 // |raw − round(raw)| must be within ⅓ frame to converge
private const val GIVEUP_FRAMES = 1800 // ~75s @24fps without enough confident overlay samples
}
}
@@ -0,0 +1,206 @@
package com.edde746.plezy.exoplayer
import android.media.audiofx.DynamicsProcessing
import android.media.audiofx.LoudnessEnhancer
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Session-bound loudness normalization approximating mpv's
* `loudnorm=I=-14:TP=-3:LRA=4` filter (#1289).
*
* API 28+: DynamicsProcessing with a single full-range compressor band as a
* slow AGC plus a limiter for the true-peak ceiling. API 25-27, or when the
* device lacks the DynamicsProcessing effect HAL: LoudnessEnhancer (fixed
* makeup gain with built-in limiting).
*
* Effects bind to the audio session, so they survive AudioTrack re-creation
* (seeks, format changes) while the session id is stable. They only process
* PCM mixer streams — the caller must force decoded PCM output and disable
* tunneling while enabled. DynamicsProcessing parameters are per-channel, so
* the effect is re-created when the output channel count changes (dialogue
* lives in the center channel of 5.1 PCM).
*/
class AudioNormalizationEffect(private val log: (String, String, String) -> Unit) {
private companion object {
const val EFFECT_PRIORITY = 0
// DynamicsProcessing — tuned for movie mixes whose dialogue sits around
// -27 LUFS: compress above threshold, make up toward I=-14, limit at TP=-3.
const val FRAME_DURATION_MS = 10f
const val MBC_CUTOFF_HZ = 20_000f
const val MBC_ATTACK_MS = 30f
const val MBC_RELEASE_MS = 300f
const val MBC_RATIO = 4f
const val MBC_THRESHOLD_DB = -34f
const val MBC_KNEE_DB = 6f
const val MBC_NOISE_GATE_DB = -90f // effectively disabled
const val MBC_EXPANDER_RATIO = 1f // no downward expansion
const val MBC_POST_GAIN_DB = 15f
const val LIMITER_ATTACK_MS = 1f
const val LIMITER_RELEASE_MS = 60f
const val LIMITER_RATIO = 10f
const val LIMITER_THRESHOLD_DB = -3f
const val DEFAULT_CHANNEL_COUNT = 2
const val MAX_CHANNEL_COUNT = 8
// LoudnessEnhancer fallback: fixed boost, internally limited.
const val LOUDNESS_ENHANCER_GAIN_MB = 900 // +9 dB
}
private var dynamicsProcessing: DynamicsProcessing? = null
private var loudnessEnhancer: LoudnessEnhancer? = null
private var attachedSessionId = 0
private var attachedChannelCount = 0
val isActive: Boolean get() = dynamicsProcessing != null || loudnessEnhancer != null
/** For stats/QA: which engine is processing. */
val describe: String
get() = when {
dynamicsProcessing != null -> "DynamicsProcessing"
loudnessEnhancer != null -> "LoudnessEnhancer"
else -> "off"
}
/** Attach to [sessionId]; idempotent for an unchanged (session, channels) pair. */
fun attach(sessionId: Int, channelCount: Int?) {
val channels = (channelCount ?: DEFAULT_CHANNEL_COUNT).coerceIn(1, MAX_CHANNEL_COUNT)
if (sessionId == attachedSessionId && channels == attachedChannelCount && isActive) return
release()
if (sessionId == 0) return // AUDIO_SESSION_ID_UNSET — retry on onAudioSessionIdChanged
attachedSessionId = sessionId
attachedChannelCount = channels
if (Build.VERSION.SDK_INT >= 28 && tryDynamicsProcessing(sessionId, channels)) return
tryLoudnessEnhancer(sessionId)
}
fun release() {
dynamicsProcessing?.let { effect ->
runCatching { effect.setEnabled(false) }
runCatching { effect.release() }
}
dynamicsProcessing = null
loudnessEnhancer?.let { effect ->
runCatching { effect.setEnabled(false) }
runCatching { effect.release() }
}
loudnessEnhancer = null
attachedSessionId = 0
attachedChannelCount = 0
}
@RequiresApi(28)
private fun tryDynamicsProcessing(sessionId: Int, channelCount: Int): Boolean = try {
val band = DynamicsProcessing.MbcBand(
// inUse
true,
MBC_CUTOFF_HZ,
MBC_ATTACK_MS,
MBC_RELEASE_MS,
MBC_RATIO,
MBC_THRESHOLD_DB,
MBC_KNEE_DB,
MBC_NOISE_GATE_DB,
MBC_EXPANDER_RATIO,
// preGain
0f,
MBC_POST_GAIN_DB
)
val mbc = DynamicsProcessing.Mbc(
// inUse
true,
// enabled
true,
// bandCount
1
).apply { setBand(0, band) }
val limiter = DynamicsProcessing.Limiter(
// inUse
true,
// enabled
true,
// linkGroup
0,
LIMITER_ATTACK_MS,
LIMITER_RELEASE_MS,
LIMITER_RATIO,
LIMITER_THRESHOLD_DB,
// postGain
0f
)
val channel = DynamicsProcessing.Channel(
// inputGain
0f,
// preEqInUse
false,
// preEqBandCount
0,
// mbcInUse
true,
// mbcBandCount
1,
// postEqInUse
false,
// postEqBandCount
0,
// limiterInUse
true
).apply {
setMbc(mbc)
setLimiter(limiter)
}
val config = DynamicsProcessing.Config.Builder(
DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
channelCount,
// preEqInUse
false,
// preEqBandCount
0,
// mbcInUse
true,
// mbcBandCount
1,
// postEqInUse
false,
// postEqBandCount
0,
// limiterInUse
true
)
.setPreferredFrameDuration(FRAME_DURATION_MS)
.setAllChannelsTo(channel)
.build()
dynamicsProcessing = DynamicsProcessing(EFFECT_PRIORITY, sessionId, config).apply { setEnabled(true) }
log("info", "audio-normalization", "DynamicsProcessing attached (session=$sessionId, channels=$channelCount)")
true
} catch (e: Exception) {
log(
"warn",
"audio-normalization",
"DynamicsProcessing unavailable (${e.javaClass.simpleName}: ${e.message}); trying LoudnessEnhancer"
)
dynamicsProcessing = null
false
}
private fun tryLoudnessEnhancer(sessionId: Int) {
try {
loudnessEnhancer = LoudnessEnhancer(sessionId).apply {
setTargetGain(LOUDNESS_ENHANCER_GAIN_MB)
setEnabled(true)
}
log("info", "audio-normalization", "LoudnessEnhancer attached (session=$sessionId, gain=${LOUDNESS_ENHANCER_GAIN_MB}mB)")
} catch (e: Exception) {
loudnessEnhancer = null
attachedSessionId = 0
attachedChannelCount = 0
log(
"warn",
"audio-normalization",
"No loudness effect available on this device (${e.javaClass.simpleName}: ${e.message})"
)
}
}
}
@@ -0,0 +1,74 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioCapabilities
private const val TAG = "AudioOutputPolicy"
internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeType) {
"audio/ac3",
"audio/eac3",
"audio/eac3-joc",
"audio/ac4",
"audio/vnd.dts",
"audio/vnd.dts.hd",
"audio/vnd.dts.uhd",
MimeTypes.AUDIO_TRUEHD -> true
else -> false
}
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
*/
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
)
/**
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
*
* 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.
*/
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. */
// 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 {
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val capabilities = try {
AudioCapabilities.getCapabilities(context, audioAttributes, null)
} catch (error: Exception) {
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
return ""
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
}
@@ -0,0 +1,151 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.EOFException
/**
* Buffers transformed [TrackOutput] data until metadata identifies the exact sample range.
*
* Media3 may append multiple samples before reporting metadata, and [TrackOutput.sampleMetadata]
* identifies a sample relative to all appended bytes using `size` and `offset`. Before calling
* [transformSample], this class compacts only that sample to `inputBuffer[0 until inputLength]`.
* Bytes after the sample remain buffered for later metadata; unreferenced bytes before it are
* discarded. Extractor decorators must call [resetBufferedData] when a seek abandons pending data.
*/
abstract class BufferedTransformingTrackOutput(
protected val delegate: TrackOutput,
initialBufferSize: Int,
initialReadBufferSize: Int = initialBufferSize,
private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
) : TrackOutput {
protected var inputBuffer = ByteArray(initialBufferSize)
private set
private var inputLength = 0
private var buffering = false
private var readBuffer = ByteArray(initialReadBufferSize)
private val outputParsable = ParsableByteArray()
protected abstract val transformEnabled: Boolean
protected abstract val transformedBuffer: ByteArray
/**
* Transforms the exact sample in `inputBuffer[0 until inputLength]`.
*
* Implementations must treat [inputBuffer] as read-only. Returns the number of bytes available
* from [transformedBuffer], or a negative value to drop the sample.
*/
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
init {
require(initialBufferSize > 0)
require(initialReadBufferSize > 0)
require(maxBufferedSampleBytes >= initialBufferSize)
}
open override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!transformEnabled) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
buffering = true
val remainingCapacity = maxBufferedSampleBytes - inputLength
if (length < 0 || remainingCapacity <= 0) throw sampleTooLarge()
val requested = minOf(length, remainingCapacity, readBuffer.size)
val bytesRead = input.read(readBuffer, 0, requested)
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!transformEnabled) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureInputCapacity(length)
data.readBytes(inputBuffer, inputLength, length)
inputLength += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!transformEnabled || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
// Offset can describe data for one or more later samples already appended by the extractor.
if (size < 0 || offset < 0 || size > inputLength - offset) {
resetBufferedData()
return
}
val sampleEnd = inputLength - offset
val sampleStart = sampleEnd - size
if (sampleStart > 0) {
System.arraycopy(inputBuffer, sampleStart, inputBuffer, 0, size)
}
try {
val transformedLength = transformSample(size, flags)
if (transformedLength < 0) return
outputParsable.reset(transformedBuffer, transformedLength)
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
} finally {
if (offset > 0) {
System.arraycopy(inputBuffer, sampleEnd, inputBuffer, 0, offset)
}
inputLength = offset
buffering = offset > 0
}
}
/** Drops sample bytes buffered before an extractor seek while retaining allocated storage. */
internal fun resetBufferedData() {
inputLength = 0
buffering = false
}
private fun appendInput(source: ByteArray, length: Int) {
ensureInputCapacity(length)
System.arraycopy(source, 0, inputBuffer, inputLength, length)
inputLength += length
}
private fun ensureInputCapacity(additionalBytes: Int) {
if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
throw sampleTooLarge()
}
val needed = inputLength + additionalBytes
if (inputBuffer.size < needed) {
val doubledSize = minOf(maxBufferedSampleBytes.toLong(), inputBuffer.size.toLong() * 2).toInt()
inputBuffer = inputBuffer.copyOf(maxOf(needed, doubledSize))
}
}
private fun sampleTooLarge(): ParserException = ParserException.createForMalformedContainer(
"Buffered sample exceeds the maximum size of $maxBufferedSampleBytes bytes",
null
)
}
@@ -1,18 +1,63 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.util.Log
import android.view.Display
import android.view.WindowManager
enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP }
object DoviBridge {
private const val TAG = "DoviBridge"
data class DvAutoDecision(
val mode: DvConversionMode,
val reason: String,
val bridgeReady: Boolean,
val displayDv: Boolean,
val nativeDecoder: Boolean,
val advertisedP7: Boolean,
val advertisedP8: Boolean,
val decoders: String,
val displayHdr: String
) {
fun logMessage(): String = "AUTO P7 DV decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
"displayDV=$displayDv, nativeDecoder=$nativeDecoder, advertisedP7=$advertisedP7, " +
"advertisedP8=$advertisedP8, decoders=$decoders, displayHdr=$displayHdr"
}
data class Dv7FallbackDecision(
val mode: DvConversionMode,
val reason: String,
val bridgeReady: Boolean,
val displayDv: Boolean,
val advertisedP8: Boolean,
val displayHdr: String
) {
fun logMessage(): String = "DV7 fallback decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
"displayDV=$displayDv, advertisedP8=$advertisedP8, displayHdr=$displayHdr"
}
private val DOLBY_VISION_MIME_TYPES = setOf(
"video/dolby-vision",
"video/hevcdv",
"video/dv_hevc"
)
const val CONVERT_FAILED = -1
const val DESTINATION_TOO_SMALL = -2
private data class DvProfileLevel(val profile: Int, val level: Int)
private data class DvDecoderCapability(
val name: String,
val mimeType: String,
val profileLevels: List<DvProfileLevel>
)
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
@@ -29,46 +74,269 @@ object DoviBridge {
fun isAvailable(): Boolean = conversionPathReady
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
private val dolbyVisionDecoders: List<DvDecoderCapability> by lazy {
try {
if (Build.VERSION.SDK_INT < minApi) return false
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
return codecList.codecInfos.any { info ->
!info.isEncoder &&
info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile }
codecList.codecInfos.flatMap { info ->
if (info.isEncoder) {
emptyList()
} else {
info.supportedTypes.mapNotNull { type ->
if (!DOLBY_VISION_MIME_TYPES.contains(type.lowercase())) return@mapNotNull null
val capabilities = runCatching { info.getCapabilitiesForType(type) }
.onFailure { Log.w(TAG, "Failed to query ${info.name} capabilities for $type", it) }
.getOrNull()
?: return@mapNotNull null
DvDecoderCapability(
name = info.name,
mimeType = type,
profileLevels = capabilities.profileLevels.map { DvProfileLevel(it.profile, it.level) }
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV profile $profile support", e)
return false
Log.w(TAG, "Failed to query native Dolby Vision decoder support", e)
emptyList()
}
}
val hasNativeDolbyVisionDecoder: Boolean by lazy {
dolbyVisionDecoders.isNotEmpty().also {
Log.i(TAG, "Native Dolby Vision decoder available: $it; decoders=${describeDolbyVisionDecoders()}")
}
}
private fun deviceAdvertisesDvProfile(profile: Int, minApi: Int = 0): Boolean {
if (Build.VERSION.SDK_INT < minApi) return false
return dolbyVisionDecoders.any { decoder ->
decoder.profileLevels.any { it.profile == profile }
}
}
val deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb)
.also { Log.i(TAG, "Device advertises exact DV Profile 7 (DvheDtb): $it") }
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
}
fun getConversionMode(): DvConversionMode = when {
!isAvailable() -> DvConversionMode.DISABLED
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
fun displaySupportsDolbyVision(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
return false
}
val display = getCurrentDisplay(context)
if (display == null) {
Log.i(TAG, "Display Dolby Vision support: false (no active display)")
return false
}
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
Log.w(TAG, "Display Dolby Vision support: false (failed to query HDR types)", error)
return false
}
val supported = hdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
Log.i(TAG, "Display Dolby Vision support: $supported; ${describeDisplayHdrCapabilities(display)}")
return supported
}
fun describeDisplayHdrCapabilities(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
return describeDisplayHdrCapabilities(display)
}
fun logSupportSummary(context: Context) {
Log.i(
TAG,
"DV support summary: bridgeReady=${isAvailable()}, displayDV=${displaySupportsDolbyVision(context)}, " +
"nativeDecoder=$hasNativeDolbyVisionDecoder, advertisedP7=$deviceSupportsDvProfile7, " +
"advertisedP8=$deviceSupportsDvProfile8, decoders=${describeDolbyVisionDecoders()}, " +
"displayHdr=${describeDisplayHdrCapabilities(context)}"
)
}
fun getConversionDecision(context: Context): DvAutoDecision {
val bridgeReady = isAvailable()
val displayDv = displaySupportsDolbyVision(context)
val nativeDecoder = hasNativeDolbyVisionDecoder
val advertisedP7 = deviceSupportsDvProfile7
val advertisedP8 = deviceSupportsDvProfile8
val mode = when {
!displayDv -> DvConversionMode.HEVC_STRIP
advertisedP7 -> DvConversionMode.DISABLED
advertisedP8 && bridgeReady -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
val reason = when {
!displayDv -> "active display does not support Dolby Vision; stripping DV metadata for HEVC fallback"
advertisedP7 -> "active display supports Dolby Vision and decoder advertises exact Profile 7; trying native DV first"
advertisedP8 && bridgeReady -> "active display supports Dolby Vision and decoder advertises Profile 8; converting to Profile 8.1"
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
else -> "Dolby Vision output path is unavailable; stripping DV metadata for HEVC fallback"
}
val decision = DvAutoDecision(
mode = mode,
reason = reason,
bridgeReady = bridgeReady,
displayDv = displayDv,
nativeDecoder = nativeDecoder,
advertisedP7 = advertisedP7,
advertisedP8 = advertisedP8,
decoders = describeDolbyVisionDecoders(),
displayHdr = describeDisplayHdrCapabilities(context)
)
Log.i(TAG, decision.logMessage())
return decision
}
fun getConversionMode(context: Context): DvConversionMode = getConversionDecision(context).mode
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
fun getDv7FallbackDecision(context: Context): Dv7FallbackDecision {
val bridgeReady = isAvailable()
val displayDv = displaySupportsDolbyVision(context)
val advertisedP8 = deviceSupportsDvProfile8
val mode = if (displayDv && advertisedP8 && bridgeReady) DvConversionMode.DV81 else DvConversionMode.HEVC_STRIP
val reason = when {
mode == DvConversionMode.DV81 -> "display supports Dolby Vision and decoder advertises Profile 8"
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
else -> "Dolby Vision output or Profile 8 support is unavailable"
}
val decision = Dv7FallbackDecision(
mode = mode,
reason = reason,
bridgeReady = bridgeReady,
displayDv = displayDv,
advertisedP8 = advertisedP8,
displayHdr = describeDisplayHdrCapabilities(context)
)
Log.i(TAG, decision.logMessage())
return decision
}
fun getDv7FallbackMode(context: Context): DvConversionMode = getDv7FallbackDecision(context).mode
private fun getCurrentDisplay(context: Context): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay
}
private fun describeDolbyVisionDecoders(): String {
if (dolbyVisionDecoders.isEmpty()) return "none"
return dolbyVisionDecoders.joinToString { decoder ->
val profiles = if (decoder.profileLevels.isEmpty()) {
"none"
} else {
decoder.profileLevels.joinToString(prefix = "[", postfix = "]") {
"${describeDvProfile(it.profile)}/${describeDvLevel(it.level)}"
}
}
"${decoder.name}(${decoder.mimeType}, profiles=$profiles)"
}
}
private fun describeHdrTypes(hdrTypes: IntArray): String {
if (hdrTypes.isEmpty()) return "none"
return hdrTypes.joinToString(prefix = "[", postfix = "]") { type ->
when (type) {
Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DOLBY_VISION"
Display.HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
Display.HdrCapabilities.HDR_TYPE_HLG -> "HLG"
Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10_PLUS"
else -> "unknown(${hex(type)})"
}
}
}
private fun describeDisplayHdrCapabilities(display: Display): String {
val hdrCapabilities = getDisplayHdrCapabilities(display)
val parts = mutableListOf(
"display=${display.displayId}:${display.name}",
"activeMode=${describeDisplayMode(display.mode)}"
)
if (hdrCapabilities == null) {
parts += "hdrCapabilities=unavailable"
} else {
@Suppress("DEPRECATION")
parts += "hdrCapabilities=${describeHdrTypes(hdrCapabilities.supportedHdrTypes)}"
parts += "desiredLuminance=max=${formatLuminance(hdrCapabilities.desiredMaxLuminance)}, " +
"maxAvg=${formatLuminance(hdrCapabilities.desiredMaxAverageLuminance)}, " +
"min=${formatLuminance(hdrCapabilities.desiredMinLuminance)}"
}
parts += "supportedModes=${display.supportedModes.joinToString(prefix = "[", postfix = "]") { describeDisplayMode(it) }}"
return parts.joinToString(prefix = "{", postfix = "}")
}
private fun getDisplayHdrCapabilities(display: Display): Display.HdrCapabilities? = runCatching {
display.hdrCapabilities
}.onFailure { Log.w(TAG, "Failed to query display HDR capabilities", it) }.getOrNull()
private fun getDisplayHdrTypes(display: Display): IntArray {
val hdrCapabilities = getDisplayHdrCapabilities(display) ?: return IntArray(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return runCatching { display.mode.supportedHdrTypes }.getOrElse { error ->
Log.w(TAG, "Failed to query mode HDR types; falling back to display HDR capabilities", error)
@Suppress("DEPRECATION")
hdrCapabilities.supportedHdrTypes
}
}
@Suppress("DEPRECATION")
return hdrCapabilities.supportedHdrTypes
}
private fun describeDisplayMode(mode: Display.Mode): String {
val hdr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
", hdr=${describeHdrTypes(mode.supportedHdrTypes)}"
} else {
""
}
return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz$hdr"
}
private fun formatLuminance(value: Float): String = if (value.isNaN() || value <= 0f) "unknown" else "${value}nits"
private fun describeDvProfile(profile: Int): String = when (profile) {
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPer -> "P0/DvavPer"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPen -> "P1/DvavPen"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDer -> "P2/DvheDer"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDen -> "P3/DvheDen"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr -> "P4/DvheDtr"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn -> "P5/DvheStn"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDth -> "P6/DvheDth"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb -> "P7/DvheDtb"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt -> "P8/DvheSt"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavSe -> "P9/DvavSe"
else -> "unknown(${hex(profile)})"
}
private fun describeDvLevel(level: Int): String = when (level) {
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd24 -> "L1/Hd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd30 -> "L2/Hd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd24 -> "L3/Fhd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd30 -> "L4/Fhd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd60 -> "L5/Fhd60"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd24 -> "L6/Uhd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd30 -> "L7/Uhd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd48 -> "L8/Uhd48"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd60 -> "L9/Uhd60"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd120 -> "L10/Uhd120"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k30 -> "L11/8k30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k60 -> "L12/8k60"
else -> "unknown(${hex(level)})"
}
private fun hex(value: Int): String = "0x${value.toString(16)}"
fun convertRpuNalu(
payload: ByteArray,
payloadOffset: Int,
@@ -2,18 +2,17 @@ package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
/**
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
*
* - DV81: Convert RPU NALs via libdovi to Profile 8.1, present as video/dolby-vision
* with dvhe.08.XX codec string. Preserves dynamic tone mapping metadata.
* - HEVC_STRIP: Strip all DV enhancement layers, present as plain video/hevc.
* - DV81: Convert RPU NALs via libdovi mode 2 to Profile 8.1, present as
* video/dolby-vision with dvhe.08.XX codec string. Conversion failures drop
* that RPU instead of forwarding Profile 7 metadata into a Profile 8.1 stream.
* - HEVC_STRIP: Strip DV RPU/EL NALs, present as plain video/hevc.
*
* Two modes of NAL framing (auto-detected):
* - Annex B (MKV path): MatroskaExtractor outputs 00 00 00 01 start codes
@@ -22,16 +21,15 @@ import androidx.media3.extractor.TrackOutput
* NAL processing:
* - Type 62 (UNSPEC62): DV RPU → convert (DV81) or strip (HEVC_STRIP)
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
* - nuh_layer_id > 0: Enhancement layer NAL → strip
* - All retained NALs: normalize nuh_layer_id to 0
* - All other NALs: pass through unchanged, matching Kodi's compatibility path
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : TrackOutput {
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE) {
companion object {
private const val TAG = "DoviConvertTrack"
@@ -47,6 +45,10 @@ class DoviConvertingTrackOutput(
private set
var strippedNalCount = 0L
private set
var strippedRpuNalCount = 0L
private set
var strippedElNalCount = 0L
private set
var strippedInitNalCount = 0L
private set
var convertedRpuCount = 0L
@@ -61,12 +63,8 @@ class DoviConvertingTrackOutput(
get() = if (sampleCount > 0) totalSampleProcessingTimeUs / sampleCount else 0L
// Reusable buffers — grown as needed, never shrunk
private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var sampleLen = 0
private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var outputLen = 0
private val outputParsable = ParsableByteArray()
private var buffering = false
// Sample counter for periodic logging
private var sampleCount = 0L
@@ -75,15 +73,22 @@ class DoviConvertingTrackOutput(
private var totalSampleProcessingTimeUs = 0L
private var loggedSupplementalWrapper = false
private var loggedEncryptedSupplementalPassthrough = false
private var outputIsProcessed = false
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) {
if (isDvProfile7Codec(codecs)) {
val codecString = codecs ?: ""
conversionActive = true
logInfo("DV Profile 7 detected ($codecs), mode=$dvMode")
logInfo("DV Profile 7 detected ($codecString), mode=$dvMode")
when (dvMode) {
DvConversionMode.DV81 -> logInfo("DV81: Kodi-style libdovi conversion (convert RPU type 62, drop EL type 63)")
DvConversionMode.HEVC_STRIP -> logInfo("HEVC_STRIP: Kodi-compatible strip (drop RPU type 62 and EL type 63 only)")
else -> Unit
}
logInfo(
"Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"Original format: mime=${format.sampleMimeType}, codecs=$codecString, " +
"initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})"
)
@@ -92,7 +97,7 @@ class DoviConvertingTrackOutput(
val newFormat = when (dvMode) {
DvConversionMode.DV81 -> {
// Parse DV level from codec string: "dvhe.07.06" → 6
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val level = codecString.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level)
logInfo("DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
@@ -125,81 +130,37 @@ class DoviConvertingTrackOutput(
delegate.format(format)
}
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
override val transformEnabled: Boolean
get() = conversionActive
buffering = true
ensureSampleCapacity(sampleLen + length)
val bytesRead = input.read(sampleBuf, sampleLen, length)
if (bytesRead > 0) {
sampleLen += bytesRead
}
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureSampleCapacity(sampleLen + length)
data.readBytes(sampleBuf, sampleLen, length)
sampleLen += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val srcLen = sampleLen
sampleLen = 0
override val transformedBuffer: ByteArray
get() = if (outputIsProcessed) outputBuf else inputBuffer
override fun transformSample(inputLength: Int, flags: Int): Int {
val processStartNs = System.nanoTime()
val success = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
outputIsProcessed = false
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
loggedEncryptedSupplementalPassthrough = true
logWarn("Encrypted supplemental sample encountered, passing raw sample")
}
false
} else {
try {
processSampleData(flags, srcLen)
true
} catch (e: Exception) {
logError("NAL processing failed, passing raw sample", e)
false
return if (inputLength == 0) -1 else inputLength
}
return try {
if (!processSampleData(flags, inputLength)) {
outputIsProcessed = true
-1
} else {
outputIsProcessed = true
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
if (outputLen == 0) -1 else outputLen
}
} catch (e: Exception) {
outputIsProcessed = false
logError("NAL processing failed, passing raw sample", e)
if (inputLength == 0) -1 else inputLength
}
val outLen = if (success) outputLen else srcLen
val outBuf = if (success) outputBuf else sampleBuf
if (success) {
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (outLen == 0) return
outputParsable.reset(outBuf, outLen)
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
}
/**
@@ -209,23 +170,21 @@ class DoviConvertingTrackOutput(
* [4-byte big-endian main sample size][main sample][supplemental data].
* Only the main sample contains video NALs and should be rewritten.
*/
private fun processSampleData(flags: Int, dataLen: Int) {
private fun processSampleData(flags: Int, dataLen: Int): Boolean {
if ((flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) == 0) {
processNalUnits(0, dataLen)
return
return true
}
if (dataLen < 4) {
logWarn("Bad supplemental sample: ${dataLen}B is too small")
copyRawSample(dataLen)
return
logWarn("Bad supplemental sample: ${dataLen}B is too small; dropping sample")
return false
}
val mainSampleLen = readInt32BE(sampleBuf, 0)
val mainSampleLen = readInt32BE(inputBuffer, 0)
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
copyRawSample(dataLen)
return
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen; dropping sample")
return false
}
val supplementalLen = dataLen - 4 - mainSampleLen
@@ -233,23 +192,20 @@ class DoviConvertingTrackOutput(
loggedSupplementalWrapper = true
logDebug(
"Supplemental wrapper detected: total=${dataLen}B, main=${mainSampleLen}B, " +
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(sampleBuf, 4, 8)}"
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(inputBuffer, 4, 8)}"
)
}
processNalUnits(4, mainSampleLen)
val processedMainLen = outputLen
if (processedMainLen == 0) {
outputLen = 0
return
}
if (processedMainLen == 0) return true
ensureOutputCapacity(4 + processedMainLen + supplementalLen)
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
writeInt32BE(outputBuf, 0, processedMainLen)
if (supplementalLen > 0) {
System.arraycopy(sampleBuf, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
System.arraycopy(inputBuffer, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
}
outputLen = 4 + processedMainLen + supplementalLen
@@ -259,6 +215,7 @@ class DoviConvertingTrackOutput(
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
)
}
return true
}
/**
@@ -272,7 +229,7 @@ class DoviConvertingTrackOutput(
outputLen = 0
if (dataLen < 4) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
return
}
@@ -280,22 +237,22 @@ class DoviConvertingTrackOutput(
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (
dataLen >= 4 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 0.toByte() &&
sampleBuf[dataOffset + 3] == 1.toByte()
inputBuffer[dataOffset] == 0.toByte() &&
inputBuffer[dataOffset + 1] == 0.toByte() &&
inputBuffer[dataOffset + 2] == 0.toByte() &&
inputBuffer[dataOffset + 3] == 1.toByte()
) ||
(
dataLen >= 3 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 1.toByte()
inputBuffer[dataOffset] == 0.toByte() &&
inputBuffer[dataOffset + 1] == 0.toByte() &&
inputBuffer[dataOffset + 2] == 1.toByte()
)
if (sampleCount == 0L) {
logDebug(
"NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${formatBytes(sampleBuf, dataOffset, 8)}"
"first bytes: ${formatBytes(inputBuffer, dataOffset, 8)}"
)
}
@@ -313,11 +270,11 @@ class DoviConvertingTrackOutput(
var scEnd = -1
var i = dataOffset
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
scEnd = i + 4
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
} else if (inputBuffer[i + 2] == 1.toByte()) {
scEnd = i + 3
break
}
@@ -327,7 +284,7 @@ class DoviConvertingTrackOutput(
if (scEnd < 0) {
// No start codes found — pass through
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
sampleCount++
return
@@ -340,11 +297,11 @@ class DoviConvertingTrackOutput(
var nalEnd = dataEnd
i = nalStart
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
nalEnd = i
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
} else if (inputBuffer[i + 2] == 1.toByte()) {
nalEnd = i
break
}
@@ -359,8 +316,7 @@ class DoviConvertingTrackOutput(
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
@@ -368,23 +324,22 @@ class DoviConvertingTrackOutput(
if (convertedLen >= 0) {
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
normalizeLayerId(outputBuf, outputLen)
outputLen += convertedLen
convertedRpuCount++
kept++
} else {
strippedNalCount++
recordStrippedNal(action)
stripped++
}
} else {
strippedNalCount++
recordStrippedNal(action)
stripped++
}
}
// Advance past the next start code
if (nalEnd >= dataEnd) break
nalStart = if (nalEnd + 3 < dataEnd && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
nalStart = if (nalEnd + 3 < dataEnd && inputBuffer[nalEnd + 2] == 0.toByte() && inputBuffer[nalEnd + 3] == 1.toByte()) {
nalEnd + 4
} else {
nalEnd + 3
@@ -409,10 +364,10 @@ class DoviConvertingTrackOutput(
var stripped = 0
while (pos + 4 <= dataEnd) {
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
(sampleBuf[pos + 3].toInt() and 0xFF)
val nalLen = ((inputBuffer[pos].toInt() and 0xFF) shl 24) or
((inputBuffer[pos + 1].toInt() and 0xFF) shl 16) or
((inputBuffer[pos + 2].toInt() and 0xFF) shl 8) or
(inputBuffer[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > dataEnd) {
if (sampleCount < 5) {
@@ -427,8 +382,7 @@ class DoviConvertingTrackOutput(
ensureOutputCapacity(outputLen + 4 + nalLen)
writeInt32BE(outputBuf, outputLen, nalLen)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
@@ -436,16 +390,15 @@ class DoviConvertingTrackOutput(
if (convertedLen >= 0) {
writeInt32BE(outputBuf, outputLen, convertedLen)
outputLen += 4
normalizeLayerId(outputBuf, outputLen)
outputLen += convertedLen
convertedRpuCount++
kept++
} else {
strippedNalCount++
recordStrippedNal(action)
stripped++
}
} else {
strippedNalCount++
recordStrippedNal(action)
stripped++
}
@@ -461,7 +414,17 @@ class DoviConvertingTrackOutput(
}
}
private enum class NalAction { KEEP, STRIP, CONVERT }
private enum class NalAction { KEEP, STRIP_RPU, STRIP_EL, CONVERT }
private fun recordStrippedNal(action: NalAction) {
strippedNalCount++
// CONVERT reaches here only after conversion fails; raw P7 RPUs are not safe in P8.1 output.
when (action) {
NalAction.STRIP_RPU, NalAction.CONVERT -> strippedRpuNalCount++
NalAction.STRIP_EL -> strippedElNalCount++
NalAction.KEEP -> Unit
}
}
private fun convertRpuIntoOutput(nalStart: Int, nalLen: Int, outputOffset: Int): Int {
ensureOutputCapacity(outputOffset + MAX_CONVERTED_RPU_SIZE)
@@ -470,7 +433,7 @@ class DoviConvertingTrackOutput(
while (true) {
val startNs = System.nanoTime()
val written = DoviBridge.convertRpuNalu(
payload = sampleBuf,
payload = inputBuffer,
payloadOffset = nalStart,
payloadLength = nalLen,
output = outputBuf,
@@ -565,7 +528,6 @@ class DoviConvertingTrackOutput(
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(data, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
}
@@ -615,27 +577,24 @@ class DoviConvertingTrackOutput(
}
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(inputBuffer, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun isDvProfile7Codec(codecs: String?): Boolean {
val normalized = codecs?.lowercase() ?: return false
return normalized.startsWith("dvhe.07") || normalized.startsWith("dvh1.07")
}
private fun classifyNal(data: ByteArray, offset: Int, len: Int, convertRpu: Boolean): NalAction {
if (len < 2) return NalAction.KEEP
val nalType = (data[offset].toInt() ushr 1) and 0x3F
val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or
((data[offset + 1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && convertRpu -> NalAction.CONVERT
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
nalType == NAL_TYPE_UNSPEC62 -> NalAction.STRIP_RPU
nalType == NAL_TYPE_UNSPEC63 -> NalAction.STRIP_EL
else -> NalAction.KEEP
}
}
private fun normalizeLayerId(data: ByteArray, offset: Int) {
if (data.size - offset >= 2) {
data[offset] = (data[offset].toInt() and 0xFE).toByte()
data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte()
}
}
private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) {
buf[offset] = ((value ushr 24) and 0xFF).toByte()
buf[offset + 1] = ((value ushr 16) and 0xFF).toByte()
@@ -648,24 +607,12 @@ class DoviConvertingTrackOutput(
((buf[offset + 2].toInt() and 0xFF) shl 8) or
(buf[offset + 3].toInt() and 0xFF)
private fun copyRawSample(dataLen: Int) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
}
private fun formatBytes(data: ByteArray, offset: Int, length: Int): String {
val end = minOf(data.size, offset + length)
if (offset >= end) return ""
return (offset until end).joinToString(" ") { "%02X".format(data[it]) }
}
private fun ensureSampleCapacity(needed: Int) {
if (sampleBuf.size < needed) {
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
}
}
private fun ensureOutputCapacity(needed: Int) {
if (outputBuf.size < needed) {
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
@@ -19,12 +19,19 @@ class DoviExtractorOutputWrapper(
private val emitLog: ((String, String, String) -> Unit)?,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
) : ExtractorOutput {
private val trackOutputs = mutableListOf<DoviConvertingTrackOutput>()
fun resetTracks() {
trackOutputs.forEach { it.resetBufferedData() }
}
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
onVideoTrackWrapped(wrapper)
return wrapper
return DoviConvertingTrackOutput(original, dvMode, emitLog).also {
trackOutputs.add(it)
onVideoTrackWrapped(it)
}
}
return original
}
@@ -46,16 +53,22 @@ class DoviExtractorWrapper(
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
private var outputWrapper: DoviExtractorOutputWrapper? = null
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
val wrapper = DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it }
outputWrapper = wrapper
delegate.init(wrapper)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun seek(position: Long, timeUs: Long) {
outputWrapper?.resetTracks()
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
}
@@ -0,0 +1,84 @@
package com.edde746.plezy.exoplayer
import kotlin.math.pow
/**
* Stereo downmix coefficient builder (Kodi-style center boost).
*
* Channel-order assumptions per input count follow the Android channel-mask
* bit order that MediaCodec / the FFmpeg extension emit:
* 3 = FL FR FC, 4 = FL FR BL BR, 5 = FL FR FC BL BR, 6 = FL FR FC LFE BL BR,
* 7 = 6.1 (5.1 + BC), 8 = 7.1 (5.1 + SL SR). LFE is dropped, matching
* ffmpeg's default lfe_mix_level=0 (and what the mpv backends produce).
*
* All coefficients must stay >= 0: ChannelMixingMatrix throws on negatives.
*/
object DownmixMatrices {
const val MIN_DOWNMIX_INPUT_CHANNELS = 3
const val MAX_DOWNMIX_INPUT_CHANNELS = 8
const val MAX_CENTER_BOOST_DB = 12
const val MAX_MIXING_CHANNELS = 12
const val SURROUND_GAIN = 0.70710678f // -3 dB
private const val BACK_CENTER_GAIN = 0.5f // SURROUND_GAIN split across both outputs
/** Kodi's mechanism: center coefficient = 10^((-3 + boostDb) / 20); boost 0 is the standard -3 dB. */
fun centerGain(centerBoostDb: Int): Float = 10f.pow((-3f + centerBoostDb.coerceIn(0, MAX_CENTER_BOOST_DB)) / 20f)
/**
* Row-major stereo coefficients ([inputChannel * 2 + outputChannel]), or null
* when [inputChannels] is not downmixed (mono/stereo pass through; >8ch
* unsupported, the caller keeps an identity matrix).
*
* [normalize] scales the matrix so the loudest output sum is <= 1 (cannot
* clip); disabled keeps the original level like Kodi's "maintain original
* volume" (the 16-bit mix saturates on clip).
*/
fun stereoCoefficients(inputChannels: Int, centerBoostDb: Int, normalize: Boolean): FloatArray? {
val c = centerGain(centerBoostDb)
val s = SURROUND_GAIN
val rows: List<FloatArray> = when (inputChannels) {
3 -> listOf(fl(), fr(), both(c))
4 -> listOf(fl(), fr(), left(s), right(s))
5 -> listOf(fl(), fr(), both(c), left(s), right(s))
6 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s))
7 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), both(BACK_CENTER_GAIN))
8 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), left(s), right(s))
else -> return null
}
val flat = FloatArray(rows.size * 2)
rows.forEachIndexed { i, row ->
flat[i * 2] = row[0]
flat[i * 2 + 1] = row[1]
}
if (normalize) {
var sumL = 0f
var sumR = 0f
for (i in rows.indices) {
sumL += flat[i * 2]
sumR += flat[i * 2 + 1]
}
val peak = maxOf(sumL, sumR, 1f)
if (peak > 1f) {
for (i in flat.indices) flat[i] /= peak
}
}
return flat
}
fun identityCoefficients(channelCount: Int): FloatArray {
require(channelCount in 1..MAX_MIXING_CHANNELS)
return FloatArray(channelCount * channelCount).apply {
for (channel in 0 until channelCount) {
this[channel * channelCount + channel] = 1f
}
}
}
private fun fl() = floatArrayOf(1f, 0f)
private fun fr() = floatArrayOf(0f, 1f)
private fun left(gain: Float) = floatArrayOf(gain, 0f)
private fun right(gain: Float) = floatArrayOf(0f, gain)
private fun both(gain: Float) = floatArrayOf(gain, gain)
private fun lfe() = floatArrayOf(0f, 0f)
}
@@ -0,0 +1,172 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* In-place sanitizer for HEVC Annex B buffers carrying both Dolby Vision and HDR10+
* dynamic metadata. Buggy chipsets (Fire TV 4K Max, MediaTek-based Google TV, ...)
* crash or black-screen when a native DV codec also receives in-band HDR10+ SEI, so
* only the metadata the active decode path consumes may be kept:
*
* - Native DV codec: strip HDR10+ SEI NALs (types 39/40 with ST 2094-40 payload),
* the decoder follows the DV RPU. Port of androidx/media#3085 / Kodi xbmc#24584.
* - HEVC fallback for a DV format: strip DV RPU/EL NALs (types 62/63) instead,
* leaving HDR10+ for the display.
*
* Runs on the playback thread against MediaCodec's direct input buffers, where
* per-byte ByteBuffer accessor calls are far too slow for high-bitrate UHD remuxes
* (#1313). Each sample is therefore staged through a reusable heap array with a
* single bulk get, scanned and compacted with array ops, and only the modified tail
* is written back. Instances are not thread-safe: use one per renderer.
*
* Pure JVM (no android/media3 imports) so it stays unit-testable on the host.
*/
class DvBitstreamSanitizer {
private companion object {
const val NAL_TYPE_PREFIX_SEI = 39
const val NAL_TYPE_SUFFIX_SEI = 40
const val NAL_TYPE_UNSPEC62 = 62 // DV RPU
const val NAL_TYPE_UNSPEC63 = 63 // DV Enhancement Layer
const val SEI_PAYLOAD_TYPE_ITU_T_T35 = 4
const val INITIAL_SCRATCH_SIZE = 256 * 1024
}
/** Reusable staging buffer — grown as needed, never shrunk. */
private var scratch = ByteArray(INITIAL_SCRATCH_SIZE)
/**
* Scans `[position, limit)` of [data] for Annex B NAL units and removes the selected
* metadata NALs by compacting the buffer in place and reducing its limit. The position
* is left unchanged. Returns the number of NAL units stripped.
*
* A sample with no start codes at all is left untouched: it cannot contain the
* targeted NALs, and emptying it would only confuse the decoder.
*/
fun sanitize(data: ByteBuffer, stripHdr10PlusSei: Boolean, stripDvRpu: Boolean): Int {
val startPos = data.position()
val len = data.limit() - startPos
if (len == 0) return 0
if (scratch.size < len) scratch = ByteArray(maxOf(len, scratch.size * 2))
val buf = scratch
data.get(buf, 0, len)
data.position(startPos)
var writeLen = 0
var firstModified = -1
var strippedCount = 0
var nalStartIndex = -1
var startCodeLen = 0
var i = 0
while (i <= len) {
// Find next start code or end of buffer.
val atEnd = i == len
var foundStartCode = false
var nextStartCodeLen = 0
if (!atEnd && i + 2 < len && buf[i].toInt() == 0 && buf[i + 1].toInt() == 0) {
if (buf[i + 2].toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 3
} else if (buf[i + 2].toInt() == 0 && i + 3 < len && buf[i + 3].toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 4
}
}
if (foundStartCode || atEnd) {
if (nalStartIndex >= 0) {
// Complete NAL unit (including its start code) from nalStartIndex to i.
val nalDataStart = nalStartIndex + startCodeLen
val nalEnd = i
var strip = false
if (nalEnd - nalDataStart >= 2) {
// HEVC NAL header: forbidden_zero_bit(1) + nal_unit_type(6) + nuh_layer_id MSB(1).
val nalUnitType = (buf[nalDataStart].toInt() and 0x7E) shr 1
strip = when (nalUnitType) {
NAL_TYPE_UNSPEC62, NAL_TYPE_UNSPEC63 -> stripDvRpu
NAL_TYPE_PREFIX_SEI, NAL_TYPE_SUFFIX_SEI ->
stripHdr10PlusSei && isHdr10PlusSeiNalUnit(buf, nalDataStart + 2, nalEnd)
else -> false
}
}
if (strip) {
strippedCount++
if (firstModified < 0) firstModified = writeLen
} else {
if (writeLen != nalStartIndex) {
// Also reached with zero strips when bytes precede the first start code.
if (firstModified < 0) firstModified = writeLen
System.arraycopy(buf, nalStartIndex, buf, writeLen, nalEnd - nalStartIndex)
}
writeLen += nalEnd - nalStartIndex
}
}
nalStartIndex = i
startCodeLen = nextStartCodeLen
i += if (nextStartCodeLen > 0) nextStartCodeLen else 1
} else {
i++
}
}
if (writeLen == len || firstModified < 0) return 0
// Bytes before firstModified are byte-identical to the buffer; write back only the tail.
data.position(startPos + firstModified)
data.put(buf, firstModified, writeLen - firstModified)
data.limit(startPos + writeLen)
data.position(startPos)
return strippedCount
}
/**
* Returns whether the SEI RBSP (starting after the 2-byte HEVC NAL header) begins with an
* HDR10+ message: user_data_registered_itu_t_t35 with country code 0xB5 (United States),
* provider code 0x003C (Samsung), provider oriented code 0x0001, application identifier 4
* (ST 2094-40), application version 0 or 1. Malformed/truncated data returns false so the
* NAL is kept.
*/
private fun isHdr10PlusSeiNalUnit(buf: ByteArray, rbspStart: Int, nalEnd: Int): Boolean {
var pos = rbspStart
if (pos >= nalEnd) return false
// SEI payload type: accumulated 0xFF bytes plus the final byte.
var payloadType = 0
while (pos < nalEnd) {
val b = buf[pos++].toInt() and 0xFF
payloadType += b
if (b != 0xFF) break
}
// SEI payload size, same encoding.
var payloadSize = 0
while (pos < nalEnd) {
val b = buf[pos++].toInt() and 0xFF
payloadSize += b
if (b != 0xFF) break
}
if (payloadType != SEI_PAYLOAD_TYPE_ITU_T_T35 || payloadSize < 7 || pos + 7 > nalEnd) {
return false
}
// The identifier bytes (B5 00 3C 00 01 04 00/01) cannot contain the 0x000003 emulation
// prevention pattern, so they can be read without RBSP unescaping.
val countryCode = buf[pos].toInt() and 0xFF
val providerCode = ((buf[pos + 1].toInt() and 0xFF) shl 8) or (buf[pos + 2].toInt() and 0xFF)
val orientedCode = ((buf[pos + 3].toInt() and 0xFF) shl 8) or (buf[pos + 4].toInt() and 0xFF)
val appIdentifier = buf[pos + 5].toInt() and 0xFF
val appVersion = buf[pos + 6].toInt() and 0xFF
return countryCode == 0xB5 &&
providerCode == 0x003C &&
orientedCode == 0x0001 &&
appIdentifier == 4 &&
(appVersion == 0 || appVersion == 1)
}
}
@@ -0,0 +1,77 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
/**
* Decision logic for media the player refuses to end (#1673).
*
* media3 reports `StuckPlayerException.STUCK_PLAYING_NOT_ENDING` when the player
* sits in STATE_READY past the declared duration while no renderer ever reports
* itself ended. Observed on a tunneled MTK decoder (Fire TV 4K Max): the clock
* ran a full minute past the last frame behind a black screen, so nothing ever
* completed the item — no Play Next, no auto-play, and a "playing" timeline the
* server kept extrapolating past the item duration. The leading hypothesis is
* that the tunneled end-of-stream buffer never arrives; what the evidence
* establishes is only that the renderers never end.
*
* The stuck report alone cannot mean "finished": the same condition holds for a
* container that under-declares its duration and is legitimately still playing.
* The rendered-frame counter separates the two — a finished file has stopped
* painting, an under-declared one has not.
*
* Inputs are deliberately the core's own bookkeeping rather than live player
* state: media3 has already stopped the player by the time the report arrives,
* so its timeline and track selection may be gone.
*/
internal object EndOfStreamPolicy {
/**
* Window media3 waits before reporting a player that is past its duration and
* still not ending. media3's own not-ending default is a full minute; this
* matches its stuck-*playing* default instead, which [isFinishedFile] can
* afford because it also requires the picture to be gone.
*/
const val STALL_TIMEOUT_MS = 10_000
/**
* How long the rendered-frame counter must sit still before a player stuck
* past its duration counts as a finished file rather than a long tail.
*/
const val FRAME_STALL_MS = 5_000L
/**
* Distance from the end a fallback backend may resume at. MPV opened at or
* past the last frame seeks straight into EOF and parks there without ever
* reporting it, which is how a failed hand-off turned into a second stall.
*/
const val FALLBACK_END_GUARD_MS = 1_000L
/**
* Whether a player stuck past its duration has actually reached the end of the
* file.
*
* [hasPlaybackOutput] keeps start-up failures — which the fallback ladder owns
* — out of this path. [hasVideoOutput] says whether the rendered-frame counter
* means anything for this media: audio-only playback never moves it, so there
* the stuck timeout stands alone. [frameStallMs] is the age of the last
* counter change.
*/
fun isFinishedFile(
hasPlaybackOutput: Boolean,
hasVideoOutput: Boolean,
isLive: Boolean,
durationMs: Long,
positionMs: Long,
frameStallMs: Long
): Boolean = hasPlaybackOutput &&
!isLive &&
durationMs != C.TIME_UNSET &&
durationMs > 0L &&
positionMs >= durationMs &&
(!hasVideoOutput || frameStallMs >= FRAME_STALL_MS)
/** Keep a backend hand-off strictly inside the media, mirroring the seek clamp. */
fun fallbackStartPositionMs(positionMs: Long, durationMs: Long, isLive: Boolean): Long {
if (isLive || durationMs == C.TIME_UNSET || durationMs <= 0L) return positionMs.coerceAtLeast(0L)
return positionMs.coerceIn(0L, (durationMs - FALLBACK_END_GUARD_MS).coerceAtLeast(0L))
}
}

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