Compare commits

...
164 Commits
Author SHA1 Message Date
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
1158 changed files with 189641 additions and 44346 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
@@ -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
+195 -162
View File
@@ -25,10 +25,11 @@ on:
type: boolean
env:
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
# Shared by every release build command; SENTRY_DIST stays per-platform.
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
LINUX_APT_PACKAGES: >
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev
@@ -39,7 +40,21 @@ env:
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
jobs:
validate-trusted-ref:
name: Validate trusted build ref
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require the protected main branch
shell: bash
run: |
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "Release builds may only run from refs/heads/main." >&2
exit 1
fi
build-android:
needs: validate-trusted-ref
if: ${{ inputs.build_android }}
runs-on: ubuntu-latest
permissions:
@@ -47,38 +62,39 @@ jobs:
attestations: write
contents: read
steps:
- 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"
java-version: "21"
- 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
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache Gradle
uses: actions/cache@v4
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-
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Install dependencies
run: flutter pub get --enforce-lockfile --no-example
@@ -94,7 +110,7 @@ jobs:
EOF
- name: Build APKs
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
@@ -114,7 +130,7 @@ jobs:
tar -czf plezy-android-x86_64.tar.gz -C build/app/outputs/flutter-apk app-x86_64-release.apk --transform 's/app-x86_64-release.apk/plezy.apk/'
- name: Attest APKs
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
plezy-android-arm64-v8a.tar.gz
@@ -122,7 +138,7 @@ jobs:
plezy-android-x86_64.tar.gz
- name: Upload APKs
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: android-apk
path: |
@@ -131,6 +147,7 @@ jobs:
plezy-android-x86_64.tar.gz
build-ios:
needs: validate-trusted-ref
if: ${{ inputs.build_ios }}
runs-on: macos-26
permissions:
@@ -138,39 +155,40 @@ jobs:
attestations: write
contents: read
steps:
- 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
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache CocoaPods
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-ios-pods-${{ hashFiles('**/Podfile.lock') }}
- name: Install dependencies
run: flutter pub get --enforce-lockfile --no-example
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios
run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
@@ -186,17 +204,18 @@ jobs:
zip -r plezy-ios.ipa Payload
- name: Attest IPA
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: plezy-ios.ipa
- name: Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-ipa
path: plezy-ios.ipa
build-macos:
needs: validate-trusted-ref
if: ${{ inputs.build_macos }}
runs-on: macos-26
permissions:
@@ -204,39 +223,40 @@ jobs:
attestations: write
contents: read
steps:
- 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
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache CocoaPods
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
macos/Pods
~/Library/Caches/CocoaPods
~/.cocoapods
key: ${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-macos-pods-
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }}
- name: Install dependencies
run: flutter pub get --enforce-lockfile --no-example
- name: Build macOS
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos
run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
@@ -355,7 +375,7 @@ jobs:
echo "MACOS_DMG_SIZE=$(stat -f%z plezy-macos.dmg)" >> $GITHUB_ENV
- name: Attest macOS DMG
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: plezy-macos.dmg
@@ -365,7 +385,7 @@ jobs:
echo "${{ env.MACOS_DMG_SIZE }}" > macos-dmg-size.txt
- name: Upload macOS DMG
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macos-dmg
path: |
@@ -374,6 +394,7 @@ jobs:
macos-dmg-size.txt
build-windows:
needs: validate-trusted-ref
name: Build Windows (${{ matrix.arch }})
if: ${{ inputs.build_windows }}
runs-on: ${{ matrix.runner }}
@@ -394,14 +415,16 @@ jobs:
build/windows/arm64/_deps
build/windows/arm64/mpv-dev-arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Cache Windows native dependencies
id: windows-native-cache
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ matrix.native_cache_path }}
key: windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
- name: Install 7-Zip
if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'
@@ -410,33 +433,24 @@ jobs:
- name: Setup Flutter
if: matrix.flutter_setup == 'action'
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
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Set up Flutter 3.44.0 (git tag)
- name: Set up Flutter from its pinned commit
if: matrix.flutter_setup == 'git'
# Flutter publishes no windows-arm64 SDK archive, so subosito can't
# resolve 3.44.0 for arm64: the stable manifest has no arm64 entry, and
# `channel: master` would git-clone master HEAD (whose engine != our
# patched 3.44.0). Clone the 3.44.0 tag directly to get engine rev
# 4c525dac, which install-patched-engine.ps1 asserts before swapping.
shell: pwsh
run: |
$root = "$env:RUNNER_TEMP\flutter"
git clone --depth 1 --branch 3.44.0 https://github.com/flutter/flutter.git $root
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
& "$root\bin\flutter.bat" --version
uses: ./.github/actions/setup-flutter-git
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~\AppData\Local\Pub\Cache
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Install dependencies
shell: pwsh
@@ -450,7 +464,7 @@ jobs:
- name: Build Windows ${{ matrix.arch }}
shell: pwsh
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
@@ -461,7 +475,7 @@ jobs:
run: .\scripts\upload-symbols.ps1 windows-${{ matrix.arch }}
- name: Upload ${{ matrix.arch }} build
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-${{ matrix.arch }}-build
path: build/windows/${{ matrix.arch }}/runner/Release/
@@ -475,19 +489,31 @@ jobs:
attestations: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Dart
uses: dart-lang/setup-dart@v1
- name: Setup Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Install dependencies
shell: pwsh
run: flutter pub get --enforce-lockfile --no-example
- name: Download x64 build
uses: actions/download-artifact@v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-x64-build
path: build-x64
- name: Download arm64 build
uses: actions/download-artifact@v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-arm64-build
path: build-arm64
@@ -508,21 +534,25 @@ jobs:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
shell: pwsh
run: |
mkdir _signer | Out-Null
@{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="^2.7.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/main/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart
Push-Location _signer
dart pub get
Set-Content -Path ed25519_key.pem -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
$output = dart run sign.dart ../plezy-windows-installer.exe ed25519_key.pem
Pop-Location
Remove-Item _signer -Recurse -Force
$sig = [regex]::Match($output, 'edSignature="([^"]*)"').Groups[1].Value
Set-Content -Path win-ed-signature.txt -Value $sig -Encoding ascii -NoNewline
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
$keyPath = Join-Path $env:RUNNER_TEMP "plezy-winsparkle-ed25519.pem"
try {
Set-Content -Path $keyPath -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
$output = & dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath
if ($LASTEXITCODE -ne 0) {
throw "WinSparkle signer failed with exit code $LASTEXITCODE"
}
$match = [regex]::Match($output, 'edSignature="([^"]*)"')
if (-not $match.Success) {
throw "WinSparkle signer returned no EdDSA signature"
}
Set-Content -Path win-ed-signature.txt -Value $match.Groups[1].Value -Encoding ascii -NoNewline
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
} finally {
Remove-Item -Path $keyPath -Force -ErrorAction SilentlyContinue
}
- name: Attest Windows artifacts
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
plezy-windows-x64-portable.7z
@@ -530,19 +560,19 @@ jobs:
plezy-windows-installer.exe
- name: Upload x64 portable
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-x64-portable
path: plezy-windows-x64-portable.7z
- name: Upload arm64 portable
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-arm64-portable
path: plezy-windows-arm64-portable.7z
- name: Upload installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-installer
path: |
@@ -551,6 +581,7 @@ jobs:
win-installer-size.txt
build-linux:
needs: validate-trusted-ref
name: Build Linux (${{ matrix.arch }})
if: ${{ inputs.build_linux }}
runs-on: ${{ matrix.runner }}
@@ -571,28 +602,31 @@ jobs:
flutter_channel: master
pkg_config_arch: aarch64-linux-gnu
steps:
- 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: ${{ matrix.flutter_channel }}
flutter-version: "3.44.0"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # latest
with:
packages: ${{ env.LINUX_APT_PACKAGES }}
version: 1.1
version: trusted-build-v1.1
- name: Install Linux dependencies
shell: bash
@@ -613,10 +647,10 @@ jobs:
- name: Cache libmpv build
id: libmpv-cache
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
- name: Build libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
@@ -625,7 +659,7 @@ jobs:
- name: Install fpm
shell: bash
run: sudo gem install fpm
run: sudo gem install fpm --version 1.17.0 --no-document
- name: Install dependencies
shell: bash
@@ -633,7 +667,7 @@ jobs:
- name: Build Linux ${{ matrix.arch }}
shell: bash
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
@@ -702,7 +736,7 @@ jobs:
tar -czf "$GITHUB_WORKSPACE/plezy-linux-${{ matrix.arch }}.tar.gz" *
- name: Attest Linux ${{ matrix.arch }} artifacts
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
plezy-linux-${{ matrix.arch }}.tar.gz
@@ -711,7 +745,7 @@ jobs:
plezy-linux-${{ matrix.arch }}.pkg.tar.zst
- name: Upload Linux ${{ matrix.arch }} artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-${{ matrix.arch }}
path: |
@@ -721,75 +755,17 @@ jobs:
plezy-linux-${{ matrix.arch }}.pkg.tar.zst
create-release:
needs: [build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && (inputs.build_android || inputs.build_ios || inputs.build_macos || inputs.build_windows || inputs.build_linux) }}
needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]
if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
sparse-checkout: pubspec.yaml
sparse-checkout-cone-mode: false
- name: Download Android artifacts
if: ${{ inputs.build_android }}
uses: actions/download-artifact@v4
with:
name: android-apk
path: artifacts/android-apk
- name: Download iOS artifact
if: ${{ inputs.build_ios }}
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: artifacts/ios-ipa
- name: Download macOS artifact
if: ${{ inputs.build_macos }}
uses: actions/download-artifact@v4
with:
name: macos-dmg
path: artifacts/macos-dmg
- name: Download Windows x64 artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@v4
with:
name: windows-x64-portable
path: artifacts/windows-x64-portable
- name: Download Windows arm64 artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@v4
with:
name: windows-arm64-portable
path: artifacts/windows-arm64-portable
- name: Download Windows installer artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@v4
with:
name: windows-installer
path: artifacts/windows-installer
- name: Download Linux x64 artifacts
if: ${{ inputs.build_linux }}
uses: actions/download-artifact@v4
with:
name: linux-x64
path: artifacts/linux-x64
- name: Download Linux arm64 artifacts
if: ${{ inputs.build_linux }}
uses: actions/download-artifact@v4
with:
name: linux-arm64
path: artifacts/linux-arm64
- name: Display structure of downloaded files
run: ls -R artifacts
persist-credentials: false
- name: Read version from pubspec.yaml
id: version
@@ -799,6 +775,65 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
- name: Download Android artifacts
if: ${{ inputs.build_android }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: android-apk
path: artifacts/android-apk
- name: Download iOS artifact
if: ${{ inputs.build_ios }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ios-ipa
path: artifacts/ios-ipa
- name: Download macOS artifact
if: ${{ inputs.build_macos }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: macos-dmg
path: artifacts/macos-dmg
- name: Download Windows x64 artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-x64-portable
path: artifacts/windows-x64-portable
- name: Download Windows arm64 artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-arm64-portable
path: artifacts/windows-arm64-portable
- name: Download Windows installer artifact
if: ${{ inputs.build_windows }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-installer
path: artifacts/windows-installer
- name: Download Linux x64 artifacts
if: ${{ inputs.build_linux }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: linux-x64
path: artifacts/linux-x64
- name: Download Linux arm64 artifacts
if: ${{ inputs.build_linux }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: linux-arm64
path: artifacts/linux-arm64
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Generate appcast.xml
run: |
VERSION="${{ steps.version.outputs.version }}"
@@ -899,12 +934,10 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with:
tag_name: ${{ steps.version.outputs.version }}
files: ${{ steps.release-files.outputs.files }}
draft: true
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+379 -31
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,9 +46,9 @@ jobs:
flutter pub get
- name: Install saf_util development dependencies
working-directory: packages/saf_util
run: 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 --check
@@ -50,11 +57,7 @@ jobs:
run: python3 scripts/clean_translations.py --check --strict
- name: Verify workflow and script guards
run: |
python3 scripts/check_build_workflow.py
python3 scripts/check_update_packages_workflow.py
python3 scripts/test_pubspec_version.py
python3 scripts/test_clean_translations.py
run: bash scripts/ci_guard_checks.sh
- name: Verify formatting
run: |
@@ -62,6 +65,10 @@ jobs:
[ ! -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
- name: Verify icon consistency
run: dart run scripts/check_icon_consistency.dart
- name: Analyze code
run: dart run scripts/check_analyzer.dart
@@ -96,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
@@ -121,43 +130,57 @@ 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
android-test:
name: Android JVM Unit Tests
name: Android JVM and Native Tests
runs-on: ubuntu-latest
permissions:
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"
java-version: "21"
- 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
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.gradle/caches
@@ -172,6 +195,19 @@ jobs:
- 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
@@ -187,10 +223,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"
@@ -198,6 +236,274 @@ 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
- 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
@@ -205,18 +511,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
@@ -227,3 +535,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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
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
+307
View File
@@ -0,0 +1,307 @@
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 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.outcome != 'success' || steps.api28.outcome != 'success') }}
run: |
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
exit 1
+9 -1
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
+13 -3
View File
@@ -18,6 +18,14 @@ jobs:
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:
@@ -60,7 +68,7 @@ jobs:
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
@@ -95,7 +103,7 @@ jobs:
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$'
@@ -110,7 +118,9 @@ jobs:
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: |
+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,50 @@
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
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Play"
timeout: 10000
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Pause"
timeout: 10000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "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
+72 -2
View File
@@ -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
@@ -34,10 +34,80 @@ The project includes automated CI checks that run on all pull requests:
- Run locally: `scripts/codegen.sh --check`
4. **Tests**: Runs unit and widget tests (when available)
- Run locally: `flutter test`
- 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.9.0"
sha256 "d7f939bdab4421d53f49b5d8b840e5f9d005e1c48269ccc1fa53a350e7d1e65c"
version "2.9.1"
sha256 "8cdc0cba43d82d358193575a6a71edc58749a59081e48fe188abc5c78b4be2c6"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+2 -2
View File
@@ -4,8 +4,6 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
plugins:
- dart_code_linter
linter:
rules:
@@ -17,6 +15,8 @@ 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/**"
+327 -48
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,23 +59,49 @@ 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()
}
}
}
// 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 by tasks.registering {
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val outDir = File(mpvDir, "libcxx")
@@ -34,7 +110,7 @@ val extractMpvLibcxx by tasks.registering {
doLast {
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
outDir.mkdirs()
exec {
providers.exec {
commandLine(
"unzip",
"-q",
@@ -44,49 +120,213 @@ val extractMpvLibcxx by tasks.registering {
"-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()
}
}
}
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 {
@@ -97,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}"
)
}
}
@@ -118,6 +360,7 @@ android {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
@@ -174,16 +417,52 @@ android {
}
}
// 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 = "../.."
}
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv, extractMpvLibcxx)
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
@@ -193,24 +472,22 @@ tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders"
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"))
@@ -218,5 +495,7 @@ dependencies {
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.15.1")
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,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,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
}
}
+20
View File
@@ -28,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" />
@@ -41,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"
@@ -87,6 +89,19 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="true"
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
@@ -117,5 +132,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<!-- Required to identify only the active HOME launcher as a shelf artwork consumer. -->
<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)
+17 -31
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;
}
// 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, static_cast<uint8_t>(mode));
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,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
}
}
@@ -35,6 +35,9 @@ 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.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
class MainActivity : FlutterActivity() {
@@ -42,12 +45,27 @@ class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = 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
var usingSkia = false
private var selectedFlutterRenderer = FlutterRenderer.IMPELLER
}
private val PIP_CHANNEL = "com.plezy/pip"
@@ -63,6 +81,7 @@ class MainActivity : FlutterActivity() {
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) {
@@ -184,6 +203,135 @@ class MainActivity : FlutterActivity() {
)
}
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
@@ -208,6 +356,8 @@ class MainActivity : FlutterActivity() {
}
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).
@@ -319,43 +469,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 (usingSkia) args.add("--resource-cache-max-bytes-threshold=50331648")
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 (skia=$usingSkia, oldGen=256MB)")
Log.i(
TAG,
"Low-RAM device: capped engine caches " +
"(renderer=${selectedFlutterRenderer.diagnosticName}, oldGen=256MB)"
)
}
return args
}
private fun shouldDisableImpeller(): Boolean {
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
}
if (isAndroidTvDevice()) return !tvSupportsImpeller()
return false
}
// Impeller froze API 30 Fire TV hardware (#749) and Flutter's Vulkan → GLES
// fallback still miscompiles gradients/SVGs, so only TV devices on Android 12+
// with a Vulkan 1.1 driver leave the Skia path.
private fun tvSupportsImpeller(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
// Fire OS reports modern API levels on GPUs whose drivers can't back it up
if (Build.MANUFACTURER.equals("Amazon", ignoreCase = true)) return false
private fun selectFlutterRenderer(): FlutterRenderer {
val isAndroidTv = isAndroidTvDevice()
val vulkan11 = 0x401000 // FEATURE_VULKAN_HARDWARE_VERSION encodes 1.1.0 as 0x401000
return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11)
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 {
@@ -428,6 +573,24 @@ class MainActivity : FlutterActivity() {
"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()
}
}
@@ -467,7 +630,7 @@ class MainActivity : FlutterActivity() {
// 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")
@@ -3,15 +3,25 @@ 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
/** Shared whole-sample buffering protocol for TrackOutput transforms. */
/**
* 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
initialReadBufferSize: Int = initialBufferSize,
private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
) : TrackOutput {
protected var inputBuffer = ByteArray(initialBufferSize)
private set
@@ -24,8 +34,19 @@ abstract class BufferedTransformingTrackOutput(
protected abstract val transformEnabled: Boolean
protected abstract val transformedBuffer: ByteArray
/** Returns transformed length, or a negative value to drop the sample. */
/**
* 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(
@@ -39,8 +60,10 @@ abstract class BufferedTransformingTrackOutput(
}
buffering = true
if (readBuffer.size < length) readBuffer = ByteArray(length)
val bytesRead = input.read(readBuffer, 0, length)
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
@@ -53,7 +76,7 @@ abstract class BufferedTransformingTrackOutput(
}
buffering = true
ensureInputCapacity(inputLength + length)
ensureInputCapacity(length)
data.readBytes(inputBuffer, inputLength, length)
inputLength += length
}
@@ -70,26 +93,59 @@ abstract class BufferedTransformingTrackOutput(
return
}
buffering = false
val sourceLength = inputLength
inputLength = 0
val transformedLength = transformSample(sourceLength, flags)
if (transformedLength < 0) 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
}
outputParsable.reset(transformedBuffer, transformedLength)
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
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(inputLength + length)
ensureInputCapacity(length)
System.arraycopy(source, 0, inputBuffer, inputLength, length)
inputLength += length
}
private fun ensureInputCapacity(needed: Int) {
private fun ensureInputCapacity(additionalBytes: Int) {
if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
throw sampleTooLarge()
}
val needed = inputLength + additionalBytes
if (inputBuffer.size < needed) {
inputBuffer = inputBuffer.copyOf(maxOf(needed, inputBuffer.size * 2))
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
)
}
@@ -138,28 +138,29 @@ class DoviConvertingTrackOutput(
override fun transformSample(inputLength: Int, flags: Int): Int {
val processStartNs = System.nanoTime()
outputIsProcessed = 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, inputLength)
true
} catch (e: Exception) {
logError("NAL processing failed, passing raw sample", e)
false
}
}
val transformedLength = if (outputIsProcessed) outputLen else inputLength
if (outputIsProcessed) {
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
return if (inputLength == 0) -1 else inputLength
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder.
return if (transformedLength == 0) -1 else transformedLength
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
}
}
/**
@@ -169,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(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
@@ -200,10 +199,7 @@ class DoviConvertingTrackOutput(
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)
@@ -219,6 +215,7 @@ class DoviConvertingTrackOutput(
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
)
}
return true
}
/**
@@ -610,12 +607,6 @@ 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(inputBuffer, 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 ""
@@ -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()
}
@@ -17,6 +17,7 @@ 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
@@ -65,6 +66,15 @@ object DownmixMatrices {
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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
@@ -24,10 +23,9 @@ internal val matroskaExtractorOutputField by lazy {
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
/**
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM ffmpeg's (and
* therefore Plex's) fallback mapping for aac_latm, which Matroska has no native
* codec ID for. The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of
* CodecPrivate.
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM ffmpeg's
* fallback mapping for aac_latm, which Matroska has no native codec ID for.
* The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
*/
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
codecPrivate != null &&
@@ -65,48 +63,3 @@ class LatmExtractorOutputWrapper(
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* MatroskaExtractor with LOAS/LATM AAC support, used for Plex Live TV MKV
* streams (which bypass the ASS/DV extractor chain). VOD playback gets the
* same LATM handling via ZlibMatroskaExtractor.
*/
class LatmMatroskaExtractor(flags: Int) : MatroskaExtractor(flags) {
companion object {
private const val TAG = "LatmMkvExtractor"
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
}
private var latmWrapper: LatmExtractorOutputWrapper? = null
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// init() is final, so install the wrapping output when the Segment starts —
// before any TrackEntry can create a track through it.
if (id == ID_SEGMENT && latmWrapper == null) {
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
val wrapper = LatmExtractorOutputWrapper(currentOutput)
latmWrapper = wrapper
matroskaExtractorOutputField.set(this, wrapper)
}
}
override fun endMasterElement(id: Int) {
if (id == ID_TRACK_ENTRY) {
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmWrapper?.markNextTrackLatm()
}
}
super.endMasterElement(id)
}
override fun seek(position: Long, timeUs: Long) {
latmWrapper?.resetTracks()
super.seek(position, timeUs)
}
}
@@ -51,10 +51,21 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
* "No mixing matrix for input channel count". ExoPlayerCore swaps in
* downmix matrices via [DownmixMatrices] when the setting is enabled.
*/
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
for (count in 1..12) putChannelMixingMatrix(ChannelMixingMatrix.create(count, count))
private val identityChannelMixingMatrices = Array(DownmixMatrices.MAX_MIXING_CHANNELS) { index ->
val channelCount = index + 1
ChannelMixingMatrix(
channelCount,
channelCount,
DownmixMatrices.identityCoefficients(channelCount)
)
}
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
for (matrix in identityChannelMixingMatrices) putChannelMixingMatrix(matrix)
}
fun identityChannelMixingMatrix(channelCount: Int): ChannelMixingMatrix = identityChannelMixingMatrices[channelCount - 1]
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
@@ -75,9 +86,9 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>
) {
// Let super build the full list (it also appends extension renderers reflectively,
// e.g. the jellyfin ffmpeg artifact's video renderer), then swap the stock
// MediaCodecVideoRenderer for the DV-sanitizing variant at the same index.
// Let super build the full list (including optional extension renderers),
// then swap the stock MediaCodecVideoRenderer for the DV-sanitizing variant
// at the same index.
super.buildVideoRenderers(
context,
extensionRendererMode,
@@ -0,0 +1,40 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.PlaybackException
/**
* Keeps runtime decoder recovery separate from genuine format incompatibility.
*
* A video format that has already rendered a frame is known to be supported. A
* later decoder failure can therefore be retried in ExoPlayer before the
* session falls back to mpv. Cold-start failures still use the normal fallback,
* while consecutive and per-media caps prevent reload loops.
*/
internal object VideoDecoderRecoveryPolicy {
const val MAX_CONSECUTIVE_ATTEMPTS = 1
const val MAX_ATTEMPTS_PER_MEDIA = 3
const val HEALTHY_PLAYBACK_PROGRESS_MS = 3000L
fun isTransientVideoDecoderError(errorCode: Int, isVideoRenderer: Boolean): Boolean = isVideoRenderer &&
(
errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ||
errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED
)
fun canRetryRuntimeFailure(
hasRenderedVideoFrame: Boolean,
consecutiveAttempts: Int,
totalAttempts: Int
): Boolean = hasRenderedVideoFrame &&
consecutiveAttempts < MAX_CONSECUTIVE_ATTEMPTS &&
totalAttempts < MAX_ATTEMPTS_PER_MEDIA
fun hasSustainedPlayback(
hasRenderedFrame: Boolean,
isPlaying: Boolean,
recoveryPositionMs: Long,
currentPositionMs: Long
): Boolean = hasRenderedFrame &&
isPlaying &&
currentPositionMs - recoveryPositionMs >= HEALTHY_PLAYBACK_PROGRESS_MS
}
@@ -1,6 +1,6 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.ParserException
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
@@ -14,18 +14,27 @@ import java.util.zip.Inflater
*/
class ZlibInflatingTrackOutput(
delegate: TrackOutput
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE, INFLATE_CHUNK) {
) : BufferedTransformingTrackOutput(
delegate,
INITIAL_BUFFER_SIZE,
INFLATE_CHUNK,
MAX_COMPRESSED_SAMPLE_SIZE
) {
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
private const val MAX_COMPRESSION_RATIO = 1024L
private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
}
var active = false
private val inflater = Inflater()
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private val overflowProbe = ByteArray(1)
override val transformEnabled: Boolean
get() = active
@@ -33,31 +42,50 @@ class ZlibInflatingTrackOutput(
override val transformedBuffer: ByteArray
get() = inflateBuf
override fun transformSample(inputLength: Int, flags: Int): Int = try {
override fun transformSample(inputLength: Int, flags: Int): Int {
inflater.reset()
inflater.setInput(inputBuffer, 0, inputLength)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${inputLength}B), passing raw", e)
ensureInflateCapacity(inputLength)
System.arraycopy(inputBuffer, 0, inflateBuf, 0, inputLength)
inputLength
}
val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
try {
while (true) {
if (written == inflateBuf.size) {
if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
inflateBuf = inflateBuf.copyOf(nextSize)
} else {
val overflow = inflater.inflate(overflowProbe, 0, 1)
if (overflow > 0) {
throw malformed("Inflated sample exceeds the maximum size")
}
if (inflater.finished()) return written
throw stalledInflate()
}
}
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
written += count
if (written.toLong() > ratioBound) {
throw malformed("Inflated sample exceeds the maximum compression ratio")
}
if (inflater.finished()) return written
if (count == 0) throw stalledInflate()
}
} catch (_: DataFormatException) {
// Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
// by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
return inputLength
}
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
private fun stalledInflate(): ParserException = when {
inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
else -> malformed("Zlib decompressor made no progress")
}
private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
}
@@ -21,8 +21,7 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
*
* LOAS/LATM AAC as A_MS/ACM media3 sets audio/x-unknown for non-PCM ACM
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
* which unwraps LOAS frames to raw AAC (see LatmMatroskaExtractor for the
* Live TV counterpart).
* which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
@@ -103,6 +102,7 @@ class ZlibMatroskaExtractor(
override fun seek(position: Long, timeUs: Long) {
latmOutput?.resetTracks()
zlibOutput?.resetTracks()
super.seek(position, timeUs)
}
@@ -116,18 +116,24 @@ class ZlibMatroskaExtractor(
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
private val trackOutputs = mutableListOf<ZlibInflatingTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
return ZlibInflatingTrackOutput(original).also {
trackOutputs.add(it)
lastCreatedWrapper = it
}
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
fun resetTracks() {
trackOutputs.forEach { it.resetBufferedData() }
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
@@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import dev.jdtech.mpv.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@@ -35,10 +36,20 @@ import kotlinx.coroutines.sync.withLock
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
class MpvPlayerCore(
class MpvPlayerCore private constructor(
private val context: Context,
private val audioOnly: Boolean = false
) : SurfaceHolder.Callback {
private val audioOnly: Boolean,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
initializedForTesting: Boolean
) : SurfaceHolder.Callback,
SurfacePlayerCore {
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : this(context, audioOnly, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
@@ -71,6 +82,10 @@ class MpvPlayerCore(
var isInitialized: Boolean = false
private set
init {
if (initializedForTesting) isInitialized = true
}
@Volatile private var player: MpvPlayer? = null
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val endFileDiagnostics = MpvEndFileDiagnostics()
@@ -100,8 +115,12 @@ class MpvPlayerCore(
@Volatile private var cachedPaused: Boolean = true
@Volatile private var desiredPaused: Boolean = true
@Volatile private var pausedForSurfaceLoss: Boolean = false
@Volatile private var pausedForAudioFocusLoss: Boolean = false
@Volatile private var hasAttachedSurface: Boolean = false
@Volatile private var attachedToPlaceholder: Boolean = false
@@ -112,6 +131,16 @@ class MpvPlayerCore(
@Volatile private var resumeBlockedByPublicPause: Boolean = false
private data class PublicPauseIntent(
val generation: Long,
val previousBlocked: Boolean,
val previousDesiredPaused: Boolean
)
private val publicPauseIntentLock = Any()
private var publicPauseIntentGeneration = 0L
private val publicPauseWriteMutex = Mutex()
@Volatile private var videoOutputEpoch: Long = 0L
private val videoOutputMutex = Mutex()
private var pendingVideoOutputDisableJob: Job? = null
@@ -186,14 +215,19 @@ class MpvPlayerCore(
disposing = false
endFileDiagnostics.onStartFile()
cachedPaused = true
desiredPaused = true
pausedForSurfaceLoss = false
pausedForAudioFocusLoss = false
pendingSurface = null
attachedSurface = null
attachedToPlaceholder = false
hasAttachedSurface = false
videoOutputRestoring = false
deferredResumeRequested = false
resumeBlockedByPublicPause = false
synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration += 1L
resumeBlockedByPublicPause = false
}
videoOutputEpoch = 0L
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
@@ -210,18 +244,12 @@ class MpvPlayerCore(
handler = handler,
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
onPause = {
scope.launch {
try {
player?.setProperty("pause", true)
} catch (e: Exception) {
Log.w(TAG, "Failed to pause on focus loss", e)
}
}
pauseForAudioFocusLoss()
},
onResume = {
requestAutoResume("audio focus gain")
resumeAfterAudioFocusGain("audio focus gain")
},
isPaused = { cachedPaused }
isPaused = { desiredPaused }
)
if (!audioOnly) {
frameRateManager = FrameRateManager(
@@ -334,7 +362,10 @@ class MpvPlayerCore(
is MpvEvent.EndFile -> {
delegate?.onEvent("end-file", endFileDiagnostics.onEndFile(event))
}
is MpvEvent.StartFile -> endFileDiagnostics.onStartFile()
is MpvEvent.StartFile -> {
endFileDiagnostics.onStartFile()
delegate?.onEvent("start-file", null)
}
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
@@ -376,9 +407,15 @@ class MpvPlayerCore(
// Audio Focus
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
override fun requestAudioFocus(): Boolean {
val granted = audioFocusManager?.requestAudioFocus() ?: false
if (granted && pausedForAudioFocusLoss) {
resumeAfterAudioFocusGain("audio focus request granted")
}
return granted
}
fun abandonAudioFocus() {
override fun abandonAudioFocus() {
audioFocusManager?.abandonAudioFocus()
}
@@ -571,23 +608,25 @@ class MpvPlayerCore(
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
return@withLock
}
val wasPaused = try {
p.getFlag("pause") == true
} catch (e: Exception) {
cachedPaused
}
if (!wasPaused) {
try {
p.setProperty("pause", true)
cachedPaused = true
pausedForSurfaceLoss = true
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
publicPauseWriteMutex.withLock {
val wasPaused = try {
p.getFlag("pause") == true
} catch (e: Exception) {
pausedForSurfaceLoss = false
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
cachedPaused
}
if (!wasPaused) {
try {
p.setProperty("pause", true)
cachedPaused = true
pausedForSurfaceLoss = true
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
} catch (e: Exception) {
pausedForSurfaceLoss = false
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
}
} else {
pausedForSurfaceLoss = false
}
} else {
pausedForSurfaceLoss = false
}
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
@@ -638,29 +677,106 @@ class MpvPlayerCore(
else -> null
}
private fun pauseForAudioFocusLoss() {
val shouldPause = synchronized(publicPauseIntentLock) {
(!desiredPaused).also { pausedForAudioFocusLoss = it }
}
if (!shouldPause) {
Log.d(TAG, "Skipping audio-focus pause because playback is already desirably paused")
return
}
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
try {
publicPauseWriteMutex.withLock {
if (!pausedForAudioFocusLoss || disposing) return@withLock
writeProperty("pause", "yes")
cachedPaused = true
}
} catch (error: CancellationException) {
Log.d(TAG, "Canceled audio-focus pause")
} catch (error: Exception) {
Log.w(TAG, "Failed to pause on focus loss", error)
}
}
}
private fun resumeAfterAudioFocusGain(reason: String) {
val shouldResume = synchronized(publicPauseIntentLock) {
if (!pausedForAudioFocusLoss) {
false
} else {
pausedForAudioFocusLoss = false
true
}
}
if (shouldResume) requestAutoResume(reason)
}
private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration == intent.generation) {
resumeBlockedByPublicPause = intent.previousBlocked
desiredPaused = intent.previousDesiredPaused
}
}
}
private fun completePublicResumeNoOp(onComplete: ((Result<Unit>) -> Unit)?) {
runOnMain {
val completion: Result<Unit> = if (disposing || !isInitialized || !scope.isActive) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
Result.success(Unit)
}
onComplete?.invoke(completion)
}
}
private fun requestAutoResume(reason: String) {
val p = player ?: return
val p = player
if (p == null && propertyWriterOverride == null) return
if (disposing) return
if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
return
val intentGeneration = synchronized(publicPauseIntentLock) {
if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
return
}
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
return
}
publicPauseIntentGeneration
}
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
return
}
scope.launch {
scope.launch(mpvWriteDispatcher) {
try {
if (p.getFlag("pause") == true) {
Log.d(TAG, "Auto-resuming playback after $reason")
p.setProperty("pause", false)
} else {
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
publicPauseWriteMutex.withLock {
val shouldResume = synchronized(publicPauseIntentLock) {
!pausedForAudioFocusLoss &&
!resumeBlockedByPublicPause &&
publicPauseIntentGeneration == intentGeneration
}
if (!shouldResume) {
Log.d(TAG, "Skipping stale auto-resume after $reason")
return@withLock
}
val isPaused = p?.getFlag("pause") ?: cachedPaused
if (isPaused) {
Log.d(TAG, "Auto-resuming playback after $reason")
if (p != null) {
p.setProperty("pause", false)
} else {
writeProperty("pause", "no")
}
cachedPaused = false
} else {
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume after $reason", e)
@@ -669,64 +785,199 @@ class MpvPlayerCore(
}
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
if (!deferredResumeRequested) return
if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
return
publicPauseWriteMutex.withLock {
val shouldResume = synchronized(publicPauseIntentLock) {
if (!deferredResumeRequested) {
false
} else if (pausedForAudioFocusLoss) {
Log.d(TAG, "Keeping deferred auto-resume pending after $reason until audio focus returns")
false
} else if (resumeBlockedByPublicPause) {
deferredResumeRequested = false
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
false
} else {
deferredResumeRequested = false
true
}
}
if (!shouldResume) return@withLock
if (p.getFlag("pause") == true) {
Log.d(TAG, "Applying deferred auto-resume after $reason")
p.setProperty("pause", false)
cachedPaused = false
} else {
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
}
}
}
deferredResumeRequested = false
if (p.getFlag("pause") == true) {
Log.d(TAG, "Applying deferred auto-resume after $reason")
p.setProperty("pause", false)
private suspend fun writeProperty(name: String, value: String) {
val writer = propertyWriterOverride
if (writer != null) {
writer(name, value)
} else {
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
val currentPlayer = player ?: throw CancellationException("MPV player unavailable")
currentPlayer.setProperty(name, value)
}
}
// Public API
/**
* Atomically records the public pause intent applied by the next loadfile
* operation. The load owns the native state transition, so this deliberately
* does not enqueue a second pause property write.
*/
fun setPauseIntentForLoad(paused: Boolean) {
if (!isInitialized || disposing || !scope.isActive) return
fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(false)
return
}
if (name == "pause") {
val paused = normalizePauseValue(value)
if (paused == true) {
synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration += 1L
desiredPaused = paused
resumeBlockedByPublicPause = paused
if (paused) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
pausedForAudioFocusLoss = false
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
resumeBlockedByPublicPause = false
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
onComplete?.invoke(true)
return
}
} else if (!pausedForSurfaceLoss && !pausedForAudioFocusLoss && !deferredResumeRequested) {
cachedPaused = false
pausedForSurfaceLoss = false
Log.d(TAG, "Public pause state updated: paused=false")
}
}
scope.launch(mpvWriteDispatcher) {
var success = false
try {
player?.setProperty(name, value)
success = true
} catch (e: Exception) {
Log.w(TAG, "setProperty($name) failed", e)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(success)
Log.d(TAG, "Load pause intent updated: paused=$paused")
}
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return
}
val paused = if (name == "pause") normalizePauseValue(value) else null
val pauseIntent = paused?.let {
synchronized(publicPauseIntentLock) {
PublicPauseIntent(
generation = ++publicPauseIntentGeneration,
previousBlocked = resumeBlockedByPublicPause,
previousDesiredPaused = desiredPaused
).also {
resumeBlockedByPublicPause = paused
desiredPaused = paused
}
}
}
if (paused == false && pauseIntent != null) {
val shouldReclaimAudioFocus = synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss
}
if (shouldReclaimAudioFocus) {
val focusGranted = audioFocusManager?.requestAudioFocus() == true
if (!focusGranted) {
Log.w(TAG, "Audio focus request denied; keeping public resume pending")
completePublicResumeNoOp(onComplete)
return
}
synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss) {
pausedForAudioFocusLoss = false
}
}
}
}
if (paused == false && pauseIntent != null && !hasReadyVideoOutput()) {
runOnMain {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
var deferredForSurface = false
val interruptedAgain = synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration != pauseIntent.generation) {
false
} else if (pausedForAudioFocusLoss) {
true
} else {
deferredResumeRequested = true
deferredForSurface = true
false
}
}
if (interruptedAgain) {
Log.d(TAG, "Public resume deferred by a newer audio-focus loss")
onComplete?.invoke(Result.success(Unit))
} else {
if (deferredForSurface) {
Log.d(TAG, "Deferring public resume until video output is ready")
}
onComplete?.invoke(Result.success(Unit))
}
}
return
}
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
var interruptedBeforeWrite = false
val writeResult = try {
if (pauseIntent == null) {
writeProperty(name, value)
} else {
publicPauseWriteMutex.withLock {
val shouldWrite = synchronized(publicPauseIntentLock) {
val isCurrent = publicPauseIntentGeneration == pauseIntent.generation
if (isCurrent && paused == false && pausedForAudioFocusLoss) {
interruptedBeforeWrite = true
false
} else {
isCurrent
}
}
if (shouldWrite) writeProperty(name, value)
}
}
if (interruptedBeforeWrite) {
Log.d(TAG, "Public resume write skipped after a newer audio-focus loss")
}
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "MPV property write failed")
Result.failure(error)
}
if (writeResult.isFailure && pauseIntent != null) {
rollbackFailedPublicPauseIntent(pauseIntent)
}
withContext(NonCancellable + Dispatchers.Main) {
val completion = if (disposing || !isInitialized) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
writeResult
}
val isCurrent = pauseIntent == null ||
synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation
}
if (isCurrent && completion.isSuccess && !interruptedBeforeWrite) {
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
cachedPaused = false
pausedForSurfaceLoss = false
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=false")
}
}
onComplete?.invoke(completion)
}
}
}
fun getProperty(name: String): String? {
@@ -847,7 +1098,7 @@ class MpvPlayerCore(
}
}
fun setVisible(visible: Boolean) {
override fun setVisible(visible: Boolean) {
// Audio-only: no render layer to show or hide — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
@@ -872,11 +1123,11 @@ class MpvPlayerCore(
}
}
fun onPipModeChanged(isInPipMode: Boolean) {
override fun onPipModeChanged(isInPipMode: Boolean) {
// MPV handles aspect ratio internally via its own surface management
}
fun updateFrame() {
override fun updateFrame() {
// Audio-only: no surface to refresh — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
@@ -911,7 +1162,7 @@ class MpvPlayerCore(
// Frame Rate Matching
fun setVideoFrameRate(
override fun setVideoFrameRate(
fps: Float,
videoDurationMs: Long,
extraDelayMs: Long,
@@ -933,7 +1184,7 @@ class MpvPlayerCore(
}
}
fun clearVideoFrameRate() {
override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
}
@@ -1003,10 +1254,15 @@ class MpvPlayerCore(
placeholderImageReader?.close()
placeholderImageReader = null
pausedForSurfaceLoss = false
pausedForAudioFocusLoss = false
attachedToPlaceholder = false
videoOutputRestoring = false
deferredResumeRequested = false
resumeBlockedByPublicPause = false
synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration += 1L
resumeBlockedByPublicPause = false
desiredPaused = true
}
videoOutputEpoch = 0L
pendingVideoOutputDisableJob = null
isInitialized = false
@@ -12,6 +12,24 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
outcome: Result<Unit>,
successValue: Any? = null
) {
val failure = outcome.exceptionOrNull()
when {
failure == null -> result.success(successValue)
failure is CancellationException -> completeMpvPropertyNotInitialized(result)
else -> result.error("SET_PROPERTY_FAILED", "MPV property write was rejected", null)
}
}
internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
result.error("NOT_INITIALIZED", "Player not initialized", null)
}
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
@@ -53,6 +71,8 @@ open class MpvPlayerPlugin(
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
@Volatile private var isInitializing = false
private var initAttemptCounter = 0
private var activeInitAttempt: Int? = null
// FlutterPlugin
@@ -62,22 +82,26 @@ open class MpvPlayerPlugin(
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channels.detach()
if (audioOnly) {
// The audio core is not activity-bound; engine detach is its terminal
// native lifecycle event (mirrors the video core's activity detach).
disposeCoreForTeardown()
}
// Engine detach is terminal for both video and audio plugin instances.
// Dispose before detaching channels so no native work can publish into a
// dead messenger.
disposeCoreForTeardown()
activity = null
activityBinding = null
applicationContext = null
channels.detach()
}
private fun takeCoreForTeardown(): MpvPlayerCore? {
++sessionGeneration
val core = playerCore
playerCore = null
cancelPendingInits()
return core
}
private fun disposeCoreForTeardown() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
takeCoreForTeardown()?.dispose()
}
// ActivityAware
@@ -106,6 +130,12 @@ open class MpvPlayerPlugin(
}
override fun onDetachedFromActivityForConfigChanges() {
// The video core owns views and window services from this Activity. Plezy
// does not retain the engine across configuration recreation, so there is
// no Activity-transfer contract under which that core may survive.
if (!audioOnly) {
disposeCoreForTeardown()
}
activity = null
activityBinding = null
Log.d(tag, "Detached from activity for config changes")
@@ -169,16 +199,29 @@ open class MpvPlayerPlugin(
// call's outcome instead of disposing the in-flight core. The Dart
// side memoizes too, but this is defense in depth for any direct
// `invoke('initialize')` that bypasses _ensureInitialized.
synchronized(pendingInitResults) {
val attempt = synchronized(pendingInitResults) {
pendingInitResults += result
if (isInitializing) {
Log.d(tag, "Init already in flight, queuing caller")
return
null
} else {
isInitializing = true
(++initAttemptCounter).also { activeInitAttempt = it }
}
isInitializing = true
}
if (attempt == null) {
Log.d(tag, "Init already in flight, queuing caller")
return
}
runOnMain {
if (!isCurrentInitAttempt(attempt) ||
(!audioOnly && activity !== coreContext) ||
(audioOnly && applicationContext !== coreContext)
) {
completePendingInits(attempt, success = false)
return@runOnMain
}
val gen: Int
val core: MpvPlayerCore
try {
@@ -198,28 +241,58 @@ open class MpvPlayerPlugin(
playerCore = core
} catch (e: Exception) {
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(success = false, errorMessage = e.message)
completePendingInits(attempt, success = false, errorMessage = e.message)
return@runOnMain
}
core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core
if (stale) {
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
val stale = gen != sessionGeneration ||
playerCore !== core ||
!isCurrentInitAttempt(attempt)
if (stale || !success) {
if (playerCore === core) playerCore = null
core.dispose()
if (stale) {
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else {
Log.d(tag, "Initialized: false")
}
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach).
// No-op on the audio-only core, which has no render layer.
core.setVisible(false)
Log.d(tag, "Initialized: $success")
Log.d(tag, "Initialized: true")
}
completePendingInits(success = !stale && success)
completePendingInits(attempt, success = !stale && success)
}
}
}
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
private fun isCurrentInitAttempt(attempt: Int): Boolean = synchronized(pendingInitResults) {
isInitializing && activeInitAttempt == attempt
}
private fun cancelPendingInits() {
val pending = synchronized(pendingInitResults) {
++initAttemptCounter
activeInitAttempt = null
isInitializing = false
val copy = pendingInitResults.toList()
pendingInitResults.clear()
copy
}
pending.forEach { it.success(false) }
}
internal fun completePendingInits(
attempt: Int,
success: Boolean,
errorMessage: String? = null
) {
val pending = synchronized(pendingInitResults) {
if (activeInitAttempt != attempt) return
activeInitAttempt = null
isInitializing = false
val copy = pendingInitResults.toList()
pendingInitResults.clear()
@@ -236,14 +309,7 @@ open class MpvPlayerPlugin(
private fun handleDispose(result: MethodChannel.Result) {
runOnMain {
val core = playerCore
++sessionGeneration
playerCore = null
// Any in-flight init callback is cancelled with the scope, so
// close out queued callers here instead of leaking them.
completePendingInits(success = false)
val core = takeCoreForTeardown()
core?.dispose {
Log.d(tag, "Disposed")
result.success(null)
@@ -261,13 +327,16 @@ open class MpvPlayerPlugin(
}
val core = playerCore
if (core == null) {
result.success(null)
if (core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
core.setProperty(name, value) {
result.success(null)
core.setProperty(name, value) { outcome ->
if (outcome.isFailure && outcome.exceptionOrNull() !is CancellationException) {
Log.w(tag, "MPV rejected property '$name'; keeping the previous value")
}
completeMpvPropertyResult(result, outcome)
}
}
@@ -14,25 +14,28 @@ internal class PlayerChannelBinding(
private val streamHandler: EventChannel.StreamHandler,
private val logTag: String
) {
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var methodChannel: MethodChannel? = null
private var eventChannel: EventChannel? = null
private var eventSink: EventChannel.EventSink? = null
val mainHandler = Handler(Looper.getMainLooper())
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(methodCallHandler)
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(streamHandler)
methodChannel = MethodChannel(binding.binaryMessenger, channelBase).also {
it.setMethodCallHandler(methodCallHandler)
}
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events").also {
it.setStreamHandler(streamHandler)
}
Log.d(logTag, "Attached to engine")
}
fun detach() {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
methodChannel?.setMethodCallHandler(null)
eventChannel?.setStreamHandler(null)
methodChannel = null
eventChannel = null
eventSink = null
Log.d(logTag, "Detached from engine")
}
@@ -0,0 +1,27 @@
package com.edde746.plezy.shared
/**
* Surface and display concerns that the ExoPlayer and mpv cores implement
* identically, so a plugin holding either one dispatches without branching
* on which backend is active.
*
* Only backend-independent members belong here: playback control
* (play/seek/track selection) stays off this interface because mpv drives it
* through properties and commands where ExoPlayer uses direct method calls.
*/
interface SurfacePlayerCore {
fun setVisible(visible: Boolean)
fun updateFrame()
fun onPipModeChanged(isInPipMode: Boolean)
fun requestAudioFocus(): Boolean
fun abandonAudioFocus()
fun clearVideoFrameRate()
fun setVideoFrameRate(
fps: Float,
videoDurationMs: Long,
extraDelayMs: Long,
videoWidth: Int,
videoHeight: Int,
onComplete: (switched: Boolean) -> Unit
)
}
@@ -0,0 +1,522 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Binder
import android.os.ParcelFileDescriptor
import android.os.Process
import android.system.Os
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
class SystemShelfArtworkProvider : ContentProvider() {
companion object {
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
}
override fun onCreate(): Boolean = context != null
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("Read-only artwork")
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
val callingUid = Binder.getCallingUid()
if (callingUid != Process.myUid()) {
val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_HOME)
val homePackage = appContext.packageManager
.resolveActivity(homeIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
val callerPackages = appContext.packageManager.getPackagesForUid(callingUid)
if (homePackage == null || callerPackages == null || homePackage !in callerPackages) {
throw FileNotFoundException("Artwork caller is not the active HOME launcher")
}
}
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
?: throw FileNotFoundException("Unknown artwork")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
internal class SystemShelfSyncSession(
internal val ownership: SystemShelfLifecycle.Ownership,
durationMillis: Long,
private val nanoTime: () -> Long = System::nanoTime,
internal val budget: SystemShelfArtworkStore.Budget = SystemShelfArtworkStore.Budget()
) {
private val deadlineNanos = nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis)
fun isExpired(): Boolean = nanoTime() >= deadlineNanos
fun isActive(): Boolean = !isExpired() && SystemShelfLifecycle.isCurrent(ownership)
fun remainingNanos(): Long = (deadlineNanos - nanoTime()).coerceAtLeast(0)
fun commitIfActive(block: () -> Boolean): Boolean = SystemShelfLifecycle.whileCurrent(ownership) {
if (isExpired()) false else block()
} ?: false
}
internal class SystemShelfArtworkStore(private val cacheDir: File) {
companion object {
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
const val MAX_ITEMS = 20
const val MAX_SYNC_DURATION_MS = 10_000L
const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^(?:[a-f0-9]{32}|[a-f0-9]{64})\\.art$")
private val deadlineAborter: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
}
}
data class Materialized(val uri: Uri, val file: File)
sealed class Prepared {
abstract val materialized: Materialized
data class Existing(override val materialized: Materialized) : Prepared()
data class Staged(
override val materialized: Materialized,
val stagingFile: File
) : Prepared()
}
data class Publication(val materialized: Materialized, val newlyPublished: Boolean)
class Budget(var remaining: Int = MAX_SYNC_BYTES) {
var consumed: Long = 0
private set
fun charge(bytes: Int): Boolean {
if (bytes <= 0) return true
consumed += bytes
if (bytes > remaining) {
remaining = 0
return false
}
remaining -= bytes
return true
}
}
private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun prepare(ownerId: String, source: String, session: SystemShelfSyncSession): Prepared? {
if (ownerId.isBlank()) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null
if (!session.isActive()) return null
val ownerKey = sha256(ownerId)
val key = sha256(ownerId + "\u0000" + source) + ".art"
val uri = contentUri(ownerKey, key)
val destination = File(File(root, ownerKey), key)
val materialized = Materialized(uri, destination)
resolveOwned(ownerId, uri)?.let { existing ->
return Prepared.Existing(materialized.copy(file = existing))
}
if (session.budget.remaining <= 0) return null
val remainingNanos = session.remainingNanos()
if (remainingNanos <= 0) return null
val connection = runCatching { url.openConnection() as? HttpURLConnection }.getOrNull()
?: return null
val abort = deadlineAborter.schedule(
{ connection.disconnect() },
remainingNanos,
TimeUnit.NANOSECONDS
)
var stagingFile: File? = null
var retainStagingFile = false
return try {
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos)
.coerceIn(1, Int.MAX_VALUE.toLong())
.toInt()
connection.instanceFollowRedirects = true
connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
connection.useCaches = false
connection.setRequestProperty("Accept", "image/*")
val status = connection.responseCode
if (!session.isActive() || status !in 200..299) return null
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
val contentLength = connection.contentLengthLong
val cap = minOf(MAX_IMAGE_BYTES, session.budget.remaining)
if (contentLength > cap) return null
val bytes = connection.inputStream.use { input ->
val output = ByteArrayOutputStream(
minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap)
)
val buffer = ByteArray(16 * 1024)
var total = 0
while (true) {
if (!session.isActive()) return null
val remaining = minOf(MAX_IMAGE_BYTES - total, session.budget.remaining)
if (remaining <= 0) {
if (contentLength >= 0 && total.toLong() == contentLength) break
return null
}
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
if (read < 0) break
if (!session.budget.charge(read)) return null
total += read
output.write(buffer, 0, read)
}
output.toByteArray()
}
if (!session.isActive() || !isSupportedImage(bytes)) return null
val directory = destination.parentFile ?: return null
if (!directory.mkdirs() && !directory.isDirectory) return null
val staged = File.createTempFile(".$key.", ".tmp", directory)
stagingFile = staged
staged.outputStream().use { output ->
output.write(bytes)
output.flush()
output.fd.sync()
}
if (!isSupportedImage(staged) || !session.isActive()) return null
retainStagingFile = true
Prepared.Staged(materialized, staged)
} catch (_: Exception) {
null
} finally {
if (!retainStagingFile) stagingFile?.delete()
abort.cancel(false)
connection.disconnect()
}
}
fun publish(prepared: Prepared): Publication? {
val expected = confinedCandidate(prepared.materialized.uri) ?: return null
val recorded = runCatching { prepared.materialized.file.canonicalFile }.getOrNull() ?: return null
if (recorded != expected) return null
resolve(prepared.materialized.uri)?.let { existing ->
if (prepared is Prepared.Staged && !deleteStagingFile(prepared.stagingFile, expected)) {
return null
}
return Publication(prepared.materialized.copy(file = existing), newlyPublished = false)
}
if (prepared !is Prepared.Staged) return null
val staged = confinedStagingFile(prepared.stagingFile, expected) ?: return null
if (!isSupportedImage(staged)) return null
val renamed = try {
Os.rename(staged.absolutePath, expected.absolutePath)
!staged.exists() || staged.renameTo(expected)
} catch (_: Exception) {
runCatching { staged.renameTo(expected) }.getOrDefault(false)
}
if (!renamed) return null
return if (!isSupportedImage(expected)) {
expected.delete()
null
} else {
Publication(prepared.materialized.copy(file = expected), newlyPublished = true)
}
}
fun discard(prepared: Iterable<Prepared>) {
prepared.forEach { candidate ->
if (candidate is Prepared.Staged) {
deleteStagingFile(candidate.stagingFile, candidate.materialized.file)
}
}
}
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
.scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY)
.appendPath("art")
.appendPath(ownerKey)
.appendPath(key)
.build()
fun resolve(uri: Uri): File? {
val candidate = confinedCandidate(uri) ?: return null
return candidate.takeIf(::isSupportedImage)
}
fun resolveOwned(ownerId: String, uri: Uri): File? {
if (ownerId.isBlank() || uri.pathSegments.getOrNull(1) != sha256(ownerId)) return null
return resolve(uri)
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapNotNullTo(HashSet()) {
runCatching { it.canonicalFile }.getOrNull()
}
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
for (ownerDirectory in root.listFiles().orEmpty()) {
for (file in ownerDirectory.listFiles().orEmpty()) {
val candidate = confinedCacheFile(file, canonicalRoot) ?: continue
if (candidate !in canonicalKeep) candidate.delete()
}
}
removeEmptyDirectories()
}
fun delete(files: Set<File>) {
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
files.forEach { file ->
confinedCacheFile(file, canonicalRoot)?.delete()
}
removeEmptyDirectories()
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun confinedCandidate(uri: Uri): File? {
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1]
val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
return runCatching {
val canonicalRoot = root.canonicalFile
val ownerDirectory = File(canonicalRoot, owner).canonicalFile
if (ownerDirectory.parentFile != canonicalRoot || ownerDirectory.name != owner) return null
val candidate = File(ownerDirectory, key).canonicalFile
if (candidate.parentFile != ownerDirectory || candidate.name != key) return null
candidate
}.getOrNull()
}
private fun confinedCacheFile(file: File, canonicalRoot: File): File? {
return runCatching {
val originalDirectory = file.parentFile ?: return null
val directory = originalDirectory.canonicalFile
if (
directory.parentFile != canonicalRoot ||
directory.name != originalDirectory.name
) {
return null
}
val candidate = file.canonicalFile
if (candidate.parentFile != directory || candidate.name != file.name) return null
candidate
}.getOrNull()
}
private fun confinedStagingFile(stagingFile: File, destination: File): File? {
val staged = runCatching { stagingFile.canonicalFile }.getOrNull() ?: return null
val canonicalDestination = runCatching { destination.canonicalFile }.getOrNull() ?: return null
val directory = canonicalDestination.parentFile ?: return null
if (
staged == canonicalDestination ||
staged.parentFile != directory ||
!staged.name.startsWith(".${canonicalDestination.name}.") ||
!staged.name.endsWith(".tmp") ||
!staged.isFile
) {
return null
}
return staged
}
private fun deleteStagingFile(stagingFile: File, destination: File): Boolean {
if (!stagingFile.exists()) return true
return confinedStagingFile(stagingFile, destination)?.delete() == true
}
private fun removeEmptyDirectories() {
root.listFiles()?.forEach { directory ->
if (directory.listFiles().isNullOrEmpty()) directory.delete()
}
}
private enum class ImageFormat {
PNG,
JPEG,
GIF,
WEBP
}
private fun isSupportedImage(file: File): Boolean {
val length = file.length()
if (!file.isFile || length !in 1L..MAX_IMAGE_BYTES.toLong()) return false
val header = ByteArray(12)
val headerSize = runCatching {
file.inputStream().use { input ->
var total = 0
while (total < header.size) {
val read = input.read(header, total, header.size - total)
if (read <= 0) break
total += read
}
total
}
}.getOrNull() ?: return false
val format = imageFormat(header, headerSize) ?: return false
if (!hasCompleteContainer(file, format, header, length)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeFile(file.absolutePath, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun isSupportedImage(bytes: ByteArray): Boolean {
val format = imageFormat(bytes, bytes.size) ?: return false
if (!hasCompleteContainer(bytes, format)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun imageFormat(bytes: ByteArray, size: Int): ImageFormat? {
if (
size >= 8 &&
bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte() &&
bytes[4] == 0x0d.toByte() &&
bytes[5] == 0x0a.toByte() &&
bytes[6] == 0x1a.toByte() &&
bytes[7] == 0x0a.toByte()
) {
return ImageFormat.PNG
}
if (
size >= 3 &&
bytes[0] == 0xff.toByte() &&
bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte()
) {
return ImageFormat.JPEG
}
if (
size >= 6 &&
bytes[0] == 0x47.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x38.toByte() &&
(bytes[4] == 0x37.toByte() || bytes[4] == 0x39.toByte()) &&
bytes[5] == 0x61.toByte()
) {
return ImageFormat.GIF
}
if (
size >= 12 &&
bytes[0] == 0x52.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x46.toByte() &&
bytes[8] == 0x57.toByte() &&
bytes[9] == 0x45.toByte() &&
bytes[10] == 0x42.toByte() &&
bytes[11] == 0x50.toByte()
) {
return ImageFormat.WEBP
}
return null
}
private fun hasCompleteContainer(
file: File,
format: ImageFormat,
header: ByteArray,
length: Long
): Boolean = runCatching {
if (format == ImageFormat.WEBP) return@runCatching webpLength(header) == length
RandomAccessFile(file, "r").use { input ->
when (format) {
ImageFormat.PNG -> {
if (length < 12) return@use false
input.seek(length - 12)
input.readInt() == 0 &&
input.readInt() == 0x49454e44 &&
input.readInt() == 0xae426082.toInt()
}
ImageFormat.JPEG -> {
if (length < 2) return@use false
input.seek(length - 2)
input.readUnsignedByte() == 0xff && input.readUnsignedByte() == 0xd9
}
ImageFormat.GIF -> {
input.seek(length - 1)
input.readUnsignedByte() == 0x3b
}
ImageFormat.WEBP -> false
}
}
}.getOrDefault(false)
private fun hasCompleteContainer(bytes: ByteArray, format: ImageFormat): Boolean {
val size = bytes.size
return when (format) {
ImageFormat.PNG ->
size >= 12 &&
bytes[size - 8] == 0x49.toByte() &&
bytes[size - 7] == 0x45.toByte() &&
bytes[size - 6] == 0x4e.toByte() &&
bytes[size - 5] == 0x44.toByte() &&
bytes[size - 4] == 0xae.toByte() &&
bytes[size - 3] == 0x42.toByte() &&
bytes[size - 2] == 0x60.toByte() &&
bytes[size - 1] == 0x82.toByte()
ImageFormat.JPEG ->
size >= 2 &&
bytes[size - 2] == 0xff.toByte() &&
bytes[size - 1] == 0xd9.toByte()
ImageFormat.GIF -> size >= 1 && bytes[size - 1] == 0x3b.toByte()
ImageFormat.WEBP -> webpLength(bytes) == size.toLong()
}
}
private fun webpLength(header: ByteArray): Long {
val riffSize = (header[4].toLong() and 0xff) or
((header[5].toLong() and 0xff) shl 8) or
((header[6].toLong() and 0xff) shl 16) or
((header[7].toLong() and 0xff) shl 24)
return riffSize + 8
}
private fun hasSupportedDimensions(options: BitmapFactory.Options): Boolean {
val width = options.outWidth
val height = options.outHeight
return width in 1..4096 &&
height in 1..4096 &&
width.toLong() * height <= 16_777_216L
}
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte) }
}
@@ -0,0 +1,36 @@
package com.edde746.plezy.watchnext
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/** Migrates versioned shelf state after updates and restores volatile launcher grants after boot. */
class SystemShelfUpdateReceiver private constructor(
private val executor: Executor,
private val ownsExecutor: Boolean
) : BroadcastReceiver() {
constructor() : this(Executors.newSingleThreadExecutor(), true)
internal constructor(executor: Executor) : this(executor, false)
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
val pending = goAsync()
executor.execute {
try {
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
provider.migrateShelfSchema()
} else {
provider.restoreReadGrants()
}
} finally {
pending?.finish()
if (ownsExecutor) (executor as ExecutorService).shutdown()
}
}
}
}
@@ -10,60 +10,98 @@ import androidx.tvprovider.media.tv.TvContractCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
class WatchNextPlugin :
class WatchNextPlugin() :
FlutterPlugin,
MethodChannel.MethodCallHandler {
internal constructor(executorFactory: () -> ExecutorService) : this() {
this.executorFactory = executorFactory
}
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
private var pendingDeepLink: String? = null
/**
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
return if (data.scheme == "plezy" && data.authority == "play") {
data.getQueryParameter("content_id")
} else {
null
}
return null
}
}
private var executorFactory: () -> ExecutorService = { Executors.newSingleThreadExecutor() }
private class EngineSession(val context: Context) {
private val closed = AtomicBoolean(false)
var lease: SystemShelfLifecycle.Lease? = null
var provider: WatchNextProvider? = null
fun close() {
closed.set(true)
}
fun isOpen(): Boolean = !closed.get()
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private var engineSession: EngineSession? = null
private var ioExecutor: ExecutorService? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val executor = executorFactory()
val session = EngineSession(binding.applicationContext)
ioExecutor = executor
engineSession = session
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
executor.execute {
val lease = SystemShelfLifecycle.acquireIf(session::isOpen) ?: return@execute
session.lease = lease
if (session.isOpen()) {
session.provider = WatchNextProvider(session.context, lease)
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
val session = engineSession
val executor = ioExecutor
session?.close()
engineSession = null
ioExecutor = null
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
if (session != null && executor != null) {
try {
executor.execute {
session.lease?.let(SystemShelfLifecycle::invalidate)
session.lease = null
session.provider = null
}
} catch (_: java.util.concurrent.RejectedExecutionException) {
Log.e(TAG, "System shelf lifecycle executor rejected detach")
} finally {
executor.shutdown()
}
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"clear" -> handleClear(call, result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
@@ -72,66 +110,78 @@ class WatchNextPlugin :
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
}
private fun ownerArguments(call: MethodCall): Pair<String, Long>? {
if (call.argument<Number>("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
val owner = call.argument<String>("ownerId")?.takeIf(String::isNotBlank) ?: return null
val generation = call.argument<Number>("generation")?.toLong()?.takeIf { it > 0 } ?: return null
return owner to generation
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
?: return result.error("INVALID_ARGS", "Missing items", null)
if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
return result.error("INVALID_ARGS", "Too many items", null)
}
val items = itemsData.mapNotNull(::parseWatchNextItem)
executeOnIo(executor, result) {
if (!session.isOpen()) return@executeOnIo false
val provider = session.provider ?: return@executeOnIo false
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
if (!session.isOpen()) return@executeOnIo false
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
executeOnIo(executor, result) {
if (!session.isOpen()) return@executeOnIo false
val provider = session.provider ?: return@executeOnIo false
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
if (!session.isOpen()) return@executeOnIo false
provider.clearAll(owner, generation, ownership, session::isOpen)
}
executeOnIo(result) { provider.clearAll() }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val executor = ioExecutor ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
?: return result.error("INVALID_ARGS", "Missing contentId", null)
executeOnIo(executor, result) {
if (!session.isOpen()) return@executeOnIo false
val provider = session.provider ?: return@executeOnIo false
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
if (!session.isOpen()) return@executeOnIo false
provider.removeItem(owner, generation, contentId, ownership, session::isOpen)
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
private fun executeOnIo(executor: ExecutorService, result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.execute {
executor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
} catch (_: Exception) {
Log.e(TAG, "System shelf IO operation failed")
mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
} catch (_: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
@@ -143,22 +193,18 @@ class WatchNextPlugin :
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
val type = when ((data["type"] as? String)?.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
posterSourceUri = data["posterSourceUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
@@ -169,16 +215,12 @@ class WatchNextPlugin :
)
}
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
} catch (_: Exception) {
Log.d(TAG, "Method channel not ready; deep link retained")
}
}
}
@@ -3,19 +3,103 @@ package com.edde746.plezy.watchnext
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
/**
* Wraps Android TvProvider API for Watch Next row integration.
* Manages WatchNextProgram entries for Plex "On Deck" content.
*/
class WatchNextProvider(private val context: Context) {
internal object SystemShelfLifecycle {
data class Lease internal constructor(internal val token: Long)
data class Ownership internal constructor(
internal val engineToken: Long,
internal val claimToken: Long
)
private val lock = Any()
private val operationLock = Any()
private var token = 0L
private var claimToken = 0L
private var currentOwner = ""
private var currentGeneration = 0L
fun acquire(): Lease = acquireIf { true }!!
fun acquireIf(isActive: () -> Boolean): Lease? = synchronized(operationLock) {
if (!isActive()) return@synchronized null
synchronized(lock) {
token += 1
claimToken += 1
currentOwner = ""
currentGeneration = 0
Lease(token)
}
}
fun invalidate(lease: Lease) {
synchronized(operationLock) {
synchronized(lock) {
if (token == lease.token) {
token += 1
claimToken += 1
}
}
}
}
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
synchronized(lock) {
if (
token != lease.token ||
ownerId.isBlank() ||
generation <= 0 ||
generation < currentGeneration ||
generation == currentGeneration &&
currentOwner.isNotEmpty() &&
currentOwner != ownerId
) {
null
} else {
currentOwner = ownerId
currentGeneration = generation
claimToken += 1
Ownership(token, claimToken)
}
}
}
fun isCurrent(ownership: Ownership): Boolean = synchronized(lock) {
token == ownership.engineToken && claimToken == ownership.claimToken
}
fun <T> whileCurrent(ownership: Ownership, block: () -> T): T? = synchronized(operationLock) {
if (!isCurrent(ownership)) return@synchronized null
val result = block()
if (isCurrent(ownership)) result else null
}
fun <T> exclusive(block: () -> T): T = synchronized(operationLock, block)
}
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
class WatchNextProvider internal constructor(
private val context: Context,
private val lifecycleLease: SystemShelfLifecycle.Lease?,
private val syncDurationMillis: Long = SystemShelfArtworkStore.MAX_SYNC_DURATION_MS
) {
constructor(context: Context) : this(context, SystemShelfLifecycle.acquire())
companion object {
private const val TAG = "WatchNextProvider"
private const val PREFS = "system_shelf_state"
private const val GRANTED_URIS = "granted_uris"
private const val GRANTED_PACKAGES = "granted_packages"
internal const val SHELF_SCHEMA_VERSION = 1
private const val SHELF_SCHEMA_VERSION_KEY = "shelf_schema_version"
internal fun forMaintenance(context: Context) = WatchNextProvider(context, null)
}
data class WatchNextItem(
@@ -23,7 +107,7 @@ class WatchNextProvider(private val context: Context) {
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val posterSourceUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
@@ -33,132 +117,447 @@ class WatchNextProvider(private val context: Context) {
val episodeNumber: Int?
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
val ops = ArrayList<ContentProviderOperation>()
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
private data class CommittedRow(
val id: Long,
val contentId: String,
val posterUri: Uri?
)
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir)
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
internal fun syncWatchNextPrograms(
ownerId: String,
generation: Long,
items: List<WatchNextItem>,
ownership: SystemShelfLifecycle.Ownership? = null,
isOperationActive: () -> Boolean = { true }
): Boolean {
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
if (!isOperationActive()) return false
val session = SystemShelfSyncSession(
operationOwnership,
syncDurationMillis
)
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
val previousArtwork = snapshotCommittedArtwork(ownerId) ?: return false
if (!isOperationActive() || !session.isActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val preparedArtwork = LinkedHashSet<SystemShelfArtworkStore.Prepared>()
val preparedBySource = HashMap<String, SystemShelfArtworkStore.Prepared?>()
try {
val preparedItems = items.map { item ->
val source = item.posterSourceUri
val localArtwork = if (source == null) {
null
} else {
val candidate = if (preparedBySource.containsKey(source)) {
preparedBySource[source]
} else {
artwork.prepare(ownerId, source, session).also { prepared ->
preparedBySource[source] = prepared
prepared?.let(preparedArtwork::add)
}
}
candidate?.materialized ?: previousArtwork[item.contentId]
}
PreparedWatchNextItem(item, localArtwork?.uri)
}
val newUris = preparedItems.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newPackages = consumerPackages()
if (!isOperationActive() || !session.isActive()) return false
return SystemShelfLifecycle.whileCurrent(session.ownership) {
if (!isOperationActive() || session.isExpired()) return@whileCurrent false
val publishedFiles = LinkedHashSet<java.io.File>()
preparedArtwork.forEach { candidate ->
val publication = artwork.publish(candidate)
if (publication == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (publication.newlyPublished) publishedFiles += publication.materialized.file
}
val referencedFiles = LinkedHashSet<java.io.File>()
preparedItems.forEach { item ->
val posterUri = item.localPosterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, posterUri)
if (file == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
referencedFiles += file
}
if (session.isExpired()) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
grantReadAccess(newUris, newPackages)
if (session.isExpired()) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
val preferencesCommitted = prefs.edit()
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, newPackages)
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
.commit()
if (!preferencesCommitted) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (session.isExpired() || !replaceRows(preparedItems)) {
val rollback = prefs.edit()
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, oldPackages)
if (oldSchemaVersion > 0) {
rollback.putInt(SHELF_SCHEMA_VERSION_KEY, oldSchemaVersion)
} else {
rollback.remove(SHELF_SCHEMA_VERSION_KEY)
}
rollback.commit()
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
artwork.deleteExcept(referencedFiles)
true
} ?: false
} finally {
artwork.discard(preparedArtwork)
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
}
fun clearAll(): Boolean = try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
/** Deletes rows first, then revokes grants and owned files. */
internal fun clearAll(
ownerId: String,
generation: Long,
ownership: SystemShelfLifecycle.Ownership? = null,
isOperationActive: () -> Boolean = { true }
): Boolean {
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
if (!isOperationActive()) return@whileCurrent false
val rowsCleared = deleteRows()
if (!rowsCleared) return@whileCurrent false
val uris = storedUris()
val packages = storedPackages()
reconcileReadAccess(uris, packages, emptySet(), emptySet())
artwork.deleteAll()
prefs.edit()
.remove(GRANTED_URIS)
.remove(GRANTED_PACKAGES)
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
.commit()
true
} ?: false
}
fun removeItem(contentId: String): Boolean {
/** Removes only data from a shelf schema older than the current on-device contract. */
fun migrateShelfSchema(): Boolean = SystemShelfLifecycle.exclusive {
if (prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0) >= SHELF_SCHEMA_VERSION) {
return@exclusive restoreReadGrantsOwned()
}
val rowsCleared = deleteRows()
if (!rowsCleared) return@exclusive false
val uris = storedUris()
val packages = storedPackages()
reconcileReadAccess(uris, packages, emptySet(), emptySet())
artwork.deleteAll()
prefs.edit()
.clear()
.putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
.commit()
}
/** Re-establishes reboot-volatile grants only for persisted, confined artwork files. */
fun restoreReadGrants(): Boolean = SystemShelfLifecycle.exclusive {
restoreReadGrantsOwned()
}
private fun restoreReadGrantsOwned(): Boolean {
val previousUris = storedUris()
val previousPackages = storedPackages()
val validUris = previousUris.filterTo(LinkedHashSet()) { artwork.resolve(it) != null }
val currentPackages = consumerPackages()
reconcileReadAccess(previousUris, previousPackages, validUris, currentPackages)
return prefs.edit()
.putStringSet(GRANTED_URIS, validUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, currentPackages)
.commit()
}
private fun snapshotCommittedArtwork(
ownerId: String
): Map<String, SystemShelfArtworkStore.Materialized>? {
val rows = queryCommittedRows() ?: return null
val snapshot = LinkedHashMap<String, SystemShelfArtworkStore.Materialized>()
rows.forEach { row ->
val uri = row.posterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, uri) ?: return@forEach
if (row.contentId !in snapshot) {
snapshot[row.contentId] = SystemShelfArtworkStore.Materialized(uri, file)
}
}
return snapshot
}
private fun queryCommittedRows(): List<CommittedRow>? {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
)
cursor?.use {
) ?: return null
cursor.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
context.contentResolver.delete(deleteUri, null, null)
return true
}
val providerIdIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
)
val providerDataIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
)
val intentIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI
)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (
idIndex < 0 ||
posterIndex < 0 ||
providerIdIndex < 0 &&
providerDataIndex < 0 &&
intentIndex < 0
) {
return null
}
val rows = ArrayList<CommittedRow>(it.count)
while (it.moveToNext()) {
val providerId = cursorString(it, providerIdIndex)
?.takeIf(String::isNotBlank)
val providerData = cursorString(it, providerDataIndex)
?.takeIf(String::isNotBlank)
val contentId = providerId
?: providerData
?: contentIdFromIntent(cursorString(it, intentIndex))
?: continue
val posterUri = it.getString(posterIndex)
?.takeIf(String::isNotBlank)
?.let(Uri::parse)
rows += CommittedRow(it.getLong(idIndex), contentId, posterUri)
}
rows
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
} catch (_: Exception) {
Log.e(TAG, "Failed to query committed Watch Next programs")
null
}
}
private fun cursorString(cursor: Cursor, index: Int): String? {
if (index < 0 || cursor.isNull(index)) return null
return if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
cursor.getBlob(index)?.toString(Charsets.UTF_8)
} else {
cursor.getString(index)
}
}
private fun contentIdFromIntent(value: String?): String? {
val uri = value?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return null
if (uri.scheme != "plezy" || uri.authority != "play") return null
return uri.getQueryParameter("content_id")?.takeIf(String::isNotBlank)
}
internal fun removeItem(
ownerId: String,
generation: Long,
contentId: String,
ownership: SystemShelfLifecycle.Ownership? = null,
isOperationActive: () -> Boolean = { true }
): Boolean {
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
if (!isOperationActive()) return@whileCurrent false
removeItemOwned(ownerId, contentId)
} ?: false
}
private fun removeItemOwned(ownerId: String, contentId: String): Boolean {
return try {
val rows = queryCommittedRows() ?: return false
val target = rows.firstOrNull { it.contentId == contentId } ?: return false
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
target.id
)
if (context.contentResolver.delete(deleteUri, null, null) <= 0) return false
val poster = target.posterUri ?: return true
if (rows.any { it.id != target.id && it.posterUri == poster }) return true
revokeReadAccess(setOf(poster))
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
artwork.resolveOwned(ownerId, poster)?.let { artwork.delete(setOf(it)) }
true
} catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item")
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0) {
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
items.forEach { item ->
val values = buildProgram(item).toContentValues().apply {
put(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
item.metadata.contentId.toByteArray(Charsets.UTF_8)
)
}
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
.withValues(values)
.build()
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs")
false
}
private fun deleteRows(): Boolean = try {
context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries")
false
}
internal fun consumerPackages(): Set<String> {
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val packageManager = context.packageManager
val selectedHome = packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo ?: return emptySet()
val packageName = selectedHome.packageName?.takeIf(String::isNotBlank) ?: return emptySet()
val activityName = selectedHome.name?.takeIf(String::isNotBlank) ?: return emptySet()
val isHomeHandler = packageManager.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL).any { candidate ->
candidate.activityInfo?.let { it.packageName == packageName && it.name == activityName } == true
}
return if (isHomeHandler) setOf(packageName) else emptySet()
}
private fun reconcileReadAccess(
previousUris: Set<Uri>,
previousPackages: Set<String>,
currentUris: Set<Uri>,
currentPackages: Set<String>
) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (previousPackages.isEmpty() && previousUris.isNotEmpty()) {
previousUris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
} else {
previousPackages.forEach { packageName ->
previousUris.forEach { uri ->
if (packageName !in currentPackages || uri !in currentUris) {
revokeReadAccess(packageName, uri, flags)
}
}
}
}
grantReadAccess(currentUris, currentPackages)
}
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
packages.forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
}
}
}
private fun revokeReadAccess(uris: Set<Uri>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
val packages = storedPackages()
if (packages.isEmpty()) {
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
return
}
packages.forEach { packageName ->
uris.forEach { uri -> revokeReadAccess(packageName, uri, flags) }
}
}
private fun revokeReadAccess(packageName: String, uri: Uri, flags: Int) {
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.revokeUriPermission(packageName, uri, flags)
} else {
context.revokeUriPermission(uri, flags)
}
}
}
private fun storedUris(): Set<Uri> = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNullTo(LinkedHashSet(), Uri::parse)
private fun storedPackages(): Set<String> = prefs.getStringSet(GRANTED_PACKAGES, emptySet()).orEmpty()
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
val metadata = item.metadata
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setType(metadata.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
.setTitle(metadata.title)
.setInternalProviderId(metadata.contentId)
.setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
metadata.description?.let(builder::setDescription)
item.localPosterUri?.let { uri ->
builder.setPosterArtUri(uri)
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
}
if (metadata.duration > 0) {
builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
if (metadata.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
metadata.episodeTitle?.let(builder::setEpisodeTitle)
metadata.seasonNumber?.let(builder::setSeasonNumber)
metadata.episodeNumber?.let(builder::setEpisodeNumber)
}
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
builder.setIntentUri(
Uri.Builder().scheme("plezy").authority("play")
.appendQueryParameter("content_id", metadata.contentId).build()
)
return builder.build()
}
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Fallback for user-entered hosts only: a self-hosted Plex/Jellyfin/Seerr
server, or a custom Watch Together relay, may be plain HTTP or fronted
by a private CA the device owner installed. Their hostnames are not
knowable at build time, so they can only be covered here. -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<!-- First-party / fixed endpoints. These carry account tokens, OAuth
codes, crash payloads and update metadata, and by construction never
live behind a private CA. System anchors only, i.e. the pre-2.9.1
behaviour. Every hard-coded https:// host in lib/ must appear here.
Pinned by test/android/network_security_config_test.dart. -->
<domain-config>
<!-- plex.tv, clients.plex.tv, app.plex.tv, discover.provider.plex.tv,
epg.provider.plex.tv (lib/services/plex_auth_service.dart:53-54,167,
lib/services/plex_discover_client.dart:57,
lib/services/plex_client/parts/live_tv.dart:3).
Does NOT match *.plex.direct — user PMS keeps base-config. -->
<domain includeSubdomains="true">plex.tv</domain>
<!-- ice.plezy.app: relay + /auth OAuth proxy + /posters
(lib/watch_together/services/watch_together_relay_endpoint.dart:8,
lib/services/trackers/oauth_proxy_client.dart:22,
lib/services/discord_rpc_service.dart:55);
bugs.plezy.app: Sentry DSN (lib/main.dart:90). -->
<domain includeSubdomains="true">plezy.app</domain>
<!-- api.trakt.tv incl. /oauth/device/*, /oauth/token
(lib/services/trakt/trakt_constants.dart:16,21-24). -->
<domain includeSubdomains="true">trakt.tv</domain>
<!-- api.myanimelist.net + myanimelist.net/v1/oauth2/token
(lib/services/trackers/mal/mal_constants.dart:19-20). -->
<domain includeSubdomains="true">myanimelist.net</domain>
<!-- graphql.anilist.co (lib/services/trackers/anilist/anilist_constants.dart:9). -->
<domain includeSubdomains="true">anilist.co</domain>
<!-- api.simkl.com + simkl.com/pin
(lib/services/trackers/simkl/simkl_constants.dart:21,33). -->
<domain includeSubdomains="true">simkl.com</domain>
<!-- data.simkl.in (lib/services/trackers/simkl/simkl_constants.dart:22). -->
<domain includeSubdomains="true">simkl.in</domain>
<!-- cdn.jsdelivr.net: appcast (lib/services/update_service.dart:18) and
anime-list mappings (anime_lists_mapping_store.dart:37,
fribb_mapping_store.dart:49). -->
<domain includeSubdomains="true">jsdelivr.net</domain>
<!-- Release check (lib/services/update_service.dart:161). -->
<domain includeSubdomains="true">api.github.com</domain>
<!-- TMDB artwork (lib/services/catalog/seerr_catalog_source.dart:163). -->
<domain includeSubdomains="true">image.tmdb.org</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
+15
View File
@@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge_test LANGUAGES CXX)
enable_testing()
add_executable(dovi_bridge_test dovi_bridge_test.cpp)
target_compile_features(dovi_bridge_test PRIVATE cxx_std_17)
target_include_directories(dovi_bridge_test PRIVATE fakes)
add_test(NAME dovi_bridge_test COMMAND dovi_bridge_test)
add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
@@ -0,0 +1,259 @@
#include <cstdarg>
#include <cstdio>
#include <string>
#include <vector>
#define DOVI_REAL_LINKED 1
#include "../../main/cpp/dovi_bridge.cpp"
struct DoviRpuOpaque {
std::string error;
};
enum class ParseResult { kValid, kError, kNull };
enum class WriteResult { kValid, kNull, kEmpty, kOversized };
static ParseResult parse_result;
static WriteResult write_result;
static int conversion_result;
static std::string parser_error;
static std::string last_log;
static std::vector<uint8_t> last_unspec_input;
static std::vector<uint8_t> encoded_output;
static int unspec_parse_calls;
static int raw_parse_calls;
static int conversion_calls;
static int write_calls;
static int rpu_free_calls;
static int data_free_calls;
extern "C" int __android_log_print(int, const char*, const char* format, ...) {
char buffer[2048];
va_list args;
va_start(args, format);
std::vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
last_log = buffer;
return static_cast<int>(last_log.size());
}
extern "C" DoviRpuOpaque* dovi_parse_unspec62_nalu(const uint8_t* data, size_t len) {
++unspec_parse_calls;
last_unspec_input.assign(data, data + len);
if (parse_result == ParseResult::kNull) return nullptr;
return new DoviRpuOpaque{parse_result == ParseResult::kError ? parser_error : ""};
}
// A raw parse deliberately succeeds so the malformed-NAL test detects any
// regression that retries framed input through dovi_parse_rpu.
extern "C" DoviRpuOpaque* dovi_parse_rpu(const uint8_t*, size_t) {
++raw_parse_calls;
return new DoviRpuOpaque{};
}
extern "C" const char* dovi_rpu_get_error(const DoviRpuOpaque* rpu) {
return rpu->error.empty() ? nullptr : rpu->error.c_str();
}
extern "C" void dovi_rpu_free(DoviRpuOpaque* rpu) {
++rpu_free_calls;
delete rpu;
}
extern "C" int32_t dovi_convert_rpu_with_mode(DoviRpuOpaque* rpu, uint8_t) {
++conversion_calls;
if (conversion_result != 0) rpu->error = "conversion error";
return conversion_result;
}
extern "C" const DoviData* dovi_write_unspec62_nalu(DoviRpuOpaque*) {
++write_calls;
if (write_result == WriteResult::kNull) return nullptr;
if (write_result == WriteResult::kEmpty) return new DoviData{nullptr, 0};
const size_t len = write_result == WriteResult::kOversized ? MAX_RPU_OUTPUT_SIZE + 1 : encoded_output.size();
auto* bytes = new uint8_t[len];
for (size_t i = 0; i < len; ++i) {
bytes[i] = write_result == WriteResult::kOversized ? 0 : encoded_output[i];
}
return new DoviData{bytes, len};
}
extern "C" void dovi_data_free(const DoviData* data) {
++data_free_calls;
delete[] data->data;
delete data;
}
static void resetFakes() {
parse_result = ParseResult::kValid;
write_result = WriteResult::kValid;
conversion_result = 0;
parser_error.clear();
last_log.clear();
last_unspec_input.clear();
encoded_output = {0x7c, 0x01, 0x19, 0x08, 0x55};
unspec_parse_calls = 0;
raw_parse_calls = 0;
conversion_calls = 0;
write_calls = 0;
rpu_free_calls = 0;
data_free_calls = 0;
}
static _jbyteArray byteArray(const std::vector<uint8_t>& bytes) {
_jbyteArray array;
array.bytes.reserve(bytes.size());
for (uint8_t byte : bytes) array.bytes.push_back(static_cast<jbyte>(byte));
return array;
}
static jint convert(
JNIEnv& env, _jbyteArray& payload, _jbyteArray& output, jint output_offset = 0, jint output_capacity = -1) {
if (output_capacity < 0) output_capacity = static_cast<jint>(output.bytes.size());
return Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
&env, nullptr, &payload, 0, static_cast<jint>(payload.bytes.size()), &output, output_offset, output_capacity, 2);
}
#define CHECK(condition) \
do { \
if (!(condition)) { \
std::fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (false)
static bool validUnspec62NalConvertsAndFreesAllocations() {
resetFakes();
const std::vector<uint8_t> framed_nal = {0x7c, 0x01, 0x19, 0x08, 0x22};
auto payload = byteArray(framed_nal);
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
const jint result = convert(env, payload, output, 2);
CHECK(result == static_cast<jint>(encoded_output.size()));
CHECK(last_unspec_input == framed_nal);
CHECK(raw_parse_calls == 0);
CHECK(unspec_parse_calls == 1);
CHECK(conversion_calls == 1);
CHECK(write_calls == 1);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
for (size_t i = 0; i < encoded_output.size(); ++i) {
CHECK(static_cast<uint8_t>(output.bytes[i + 2]) == encoded_output[i]);
}
return true;
}
static bool malformedTruncatedNalFailsWithoutRawFallback() {
resetFakes();
parse_result = ParseResult::kError;
parser_error.assign(2048, 'x');
auto payload = byteArray({0x7c, 0x01, 0x19});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
const jint result = convert(env, payload, output);
CHECK(result == CONVERT_FAILED);
CHECK(unspec_parse_calls == 1);
CHECK(raw_parse_calls == 0);
CHECK(conversion_calls == 0);
CHECK(write_calls == 0);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 0);
const std::string prefix = "RPU NAL parse failed: ";
CHECK(last_log == prefix + std::string(MAX_ERROR_LOG_LENGTH, 'x'));
return true;
}
static bool nullParserResultFailsWithoutFreeingNonexistentAllocation() {
resetFakes();
parse_result = ParseResult::kNull;
auto payload = byteArray({0x7c, 0x01});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(raw_parse_calls == 0);
CHECK(rpu_free_calls == 0);
CHECK(data_free_calls == 0);
return true;
}
static bool conversionFailureFreesRpu() {
resetFakes();
conversion_result = -1;
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 0);
return true;
}
static bool unusableWriterResultFreesBothAllocations() {
resetFakes();
write_result = WriteResult::kEmpty;
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
static bool destinationTooSmallFreesBothAllocations() {
resetFakes();
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(4, 0));
JNIEnv env;
CHECK(convert(env, payload, output) == DESTINATION_TOO_SMALL);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
static bool jniWriteFailureFreesBothAllocations() {
resetFakes();
auto payload = byteArray({0x7c, 0x01, 0x19, 0x08});
auto output = byteArray(std::vector<uint8_t>(16, 0));
JNIEnv env;
env.fail_next_write = true;
CHECK(convert(env, payload, output) == CONVERT_FAILED);
CHECK(rpu_free_calls == 1);
CHECK(data_free_calls == 1);
return true;
}
int main() {
struct TestCase {
const char* name;
bool (*run)();
};
const TestCase tests[] = {
{"valid conversion", validUnspec62NalConvertsAndFreesAllocations},
{"malformed truncated NAL", malformedTruncatedNalFailsWithoutRawFallback},
{"null parser result", nullParserResultFailsWithoutFreeingNonexistentAllocation},
{"conversion failure", conversionFailureFreesRpu},
{"unusable writer result", unusableWriterResultFreesBothAllocations},
{"destination too small", destinationTooSmallFreesBothAllocations},
{"JNI write failure", jniWriteFailureFreesBothAllocations},
};
for (const TestCase& test : tests) {
if (!test.run()) {
std::fprintf(stderr, "FAILED: %s\n", test.name);
return 1;
}
}
std::printf("Passed %zu dovi_bridge tests\n", sizeof(tests) / sizeof(tests[0]));
return 0;
}

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