Compare commits

...
769 Commits
Author SHA1 Message Date
github-actions[bot]andedde746 1e0988e381 chore: bump version to 2.5.0 2026-06-07 14:19:17 +02:00
edde746 8efd8f1dc8 fix(jellyfin): upload artwork as binary 2026-06-07 13:32:07 +02:00
edde746 38868e0078 fix: use device name for companion remote
close #1256
2026-06-07 12:58:13 +02:00
edde746 273d0c7985 fix(back): don't short-circuit side-nav back chain to home
The side-nav host's onSystemBack called _handleMainBack on the system
route-pop path, jumping straight to the home tab and skipping the dpad
back chain (content -> top tabs -> sidebar). Restore the original inert
behavior: canPop:false blocks the route-pop and the key path owns the
chain; the host still closes an open sheet on back. Bottom-nav unchanged.
2026-06-07 10:05:57 +02:00
edde746 4728564e0c refactor(back): host-owned system back in main_screen
Both main_screen hosts (side-nav + bottom-nav) drop their PopScopes and
pass canPop:false + onSystemBack to OverlaySheetHost, so a system back
with a sheet open closes the sheet instead of running tab-nav/exit. Key
(dpad) back path unchanged; onSystemBack mirrors it for a pure popRoute.
2026-06-07 10:05:57 +02:00
edde746 489bf914af refactor(back): host-owned sheet back in hub + media detail
hub_detail and media_detail (TV + non-TV) drop their hand-rolled PopScopes
and pass canPop/onSystemBack to OverlaySheetHost. Keeps appbar-focus-first
and Android-keyboard block behavior; fixes system-back-with-sheet-open
leaking to a screen pop. iOS swipe-back preserved.
2026-06-07 09:20:02 +02:00
edde746 248b5fd91a refactor(back): adopt host-owned sheet back in watch-together + player
watch_together, video_player, mobile_remote and live_tv_show_schedule now
pass canPop/onSystemBack to OverlaySheetHost instead of hand-rolling a
PopScope. Restores the iOS swipe-back on watch_together and centralizes
the sheet-close-on-back logic.
2026-06-07 09:13:49 +02:00
edde746 bbf7b1f9da feat(overlay-sheet): opt-in PopScope so the host owns system back
Adds nullable canPop + onSystemBack to OverlaySheetHost. When canPop is
set, the host installs its own PopScope that closes an open sheet on a
system/route back instead of popping the screen; null keeps today's
behavior. Lets screens drop their hand-rolled sheet-back PopScopes.
2026-06-07 09:06:29 +02:00
edde746 83899a38f3 fix(tvos): sync version 2026-06-07 08:51:02 +02:00
edde746 cda4c1e78a fix(watch-together): enable dpad long-press to open recent room actions
close #1259
2026-06-07 08:51:02 +02:00
edde746 01e5814943 fix(video): consolidate auto-skip cancellation 2026-06-07 06:13:14 +02:00
edde746 3cca29512d fix: bump MPVKit to v1.0.1 for profile 8 Dolby Vision decode
Routes all single-layer DV profile 8 (incl. compat 0) through the Apple
DV decode path, so VideoToolbox decodes it in hardware as true Dolby
Vision instead of failing over to software HDR10. Refs #1262.
2026-06-06 16:06:31 +02:00
edde746 8139fc2b17 fix(tvos): make HDR toggle disable Dolby Vision
HDR-off now forces SDR display criteria for DV like it does for HDR10,
and the runtime toggle re-applies the criteria. Refs #1262 (part A).
2026-06-06 14:17:52 +02:00
edde746 1ef4032666 feat: add startup section setting
close #1240
2026-06-06 13:08:02 +02:00
edde746 4c4b18e2cd fix(i18n): localize hardcoded UI strings
close #1257
2026-06-06 11:45:34 +02:00
edde746 f74314bbf0 fix(livetv): debounce rapid time-shift skips to stop overshoot 2026-06-06 10:48:51 +02:00
edde746 32421e4c84 fix(tv): reach app bar actions via up from tabs
close #1202
2026-06-06 08:51:08 +02:00
edde746 d90cc61541 fix(tv): symmetric full-card focus glow via overlay
close #1231
2026-06-06 05:09:07 +02:00
edde746 6b3e49d410 fix(ios): recover frozen video after Control Center
close #1232
2026-06-06 00:49:19 +02:00
edde746 a9b812f66c fix(test): restore CI unit tests 2026-06-05 20:34:44 +02:00
edde746 0fd0506551 feat(media): lazily page detail episodes
close #1245
2026-06-05 19:26:02 +02:00
edde746 e6ca5f254d fix(ios): correct anamorphic video aspect ratio
close #1244
2026-06-05 10:15:03 +02:00
edde746 de3219abb9 fix: align bottom sheet back header 2026-06-05 06:30:26 +02:00
edde746 8201d8dcda feat(libraries): add mobile browse options sheet 2026-06-04 20:41:34 +02:00
edde746 aed420525c feat(nav): move mobile settings to home menu 2026-06-04 16:41:02 +02:00
edde746 75b1c86306 feat(ui): add m3 app menus 2026-06-04 16:22:31 +02:00
edde746 43012da2b8 feat(tvos): add top shelf support 2026-06-04 15:38:12 +02:00
edde746 145881318e fix: harden server-scoped state 2026-06-04 13:47:06 +02:00
edde746 79c06b82d3 fix(jellyfin): align default track selection 2026-06-04 10:35:14 +02:00
edde746 e5bbdc06f9 feat(jellyfin): show media quality labels 2026-06-04 09:56:10 +02:00
edde746 0e7ff609b6 feat(media): show video and audio quality labels
close #1115
2026-06-04 08:20:12 +02:00
edde746 f3e108e867 fix(tvos): suppress duplicate select playback 2026-06-03 17:31:15 +02:00
edde746 92bfae322e fix(tvos): handle play pause remote
close #1230
2026-06-03 14:19:23 +02:00
edde746 08491511e9 fix(video): prevent play next EOF loop
close #1228
2026-06-02 15:52:14 +02:00
edde746 24b5080c38 fix(video): stabilize player chrome overlays 2026-06-02 14:20:24 +02:00
edde746 985c279b1f fix(exoplayer): correct transcode seek position double-count 2026-06-02 06:23:08 +02:00
edde746 cb2e56442d fix(subtitles): show search without subtitle tracks
close #1219
2026-06-01 19:08:17 +02:00
edde746 a425798bae fix(video): iOS video zoom 2026-06-01 15:21:41 +02:00
edde746 1020188883 fix(video): restore stretch fit startup 2026-06-01 12:40:59 +02:00
edde746 caf192fd78 fix(jellyfin): defer initial URL keyboard
close #1217
2026-06-01 11:49:21 +02:00
edde746 74b8dc4561 refactor: type server identifiers 2026-06-01 11:06:03 +02:00
edde746 7b59dac26b fix: stabilize detail action focus
close #1218
2026-06-01 08:34:38 +02:00
edde746 09f5121732 feat(metadata): add Jellyfin edit support 2026-06-01 08:34:38 +02:00
edde746 8f9d20a2e4 feat(jellyfin): add extras support 2026-06-01 08:34:38 +02:00
edde746 273f129b68 fix(i18n): remove unsupported newline guidance 2026-06-01 08:34:38 +02:00
github-actions[bot] 483c0b504e chore: update cask to 2.4.1 2026-06-01 01:13:01 +00:00
github-actions[bot] 7c38668a51 chore: bump version to 2.4.1 2026-06-01 00:56:52 +00:00
edde746 eba553413c fix(macos): allow LuaJIT under hardened runtime
close #1214
2026-06-01 02:46:37 +02:00
edde746 f8658027b8 fix(android): guard display HDR mode API
close #1215
2026-06-01 02:46:37 +02:00
github-actions[bot] 58bceb29cb chore: update cask to 2.4.0 2026-05-31 19:54:37 +00:00
github-actions[bot] c5d8f9c62b chore: bump version to 2.4.0 2026-05-31 19:30:17 +00:00
edde746 4ffa3d30f6 fix(player): preserve shader toggle preset
close #1208
2026-05-31 21:25:46 +02:00
edde746 d5c8778074 fix: harden reconnect and playback regressions 2026-05-31 20:33:38 +02:00
edde746 c6c76d6828 fix(dialogs): own text input resources 2026-05-31 15:34:00 +02:00
edde746 9cfc6c0f4a fix(tv): stabilize osk dismissal 2026-05-31 15:22:13 +02:00
edde746 2eae450aa0 fix(jellyfin): preserve add screen dpad focus 2026-05-31 15:00:35 +02:00
edde746 1b5334e72f fix(jellyfin): harden local discovery flow 2026-05-31 14:33:31 +02:00
edde746 e3db54940d feat(jellyfin): add local server discovery
close #1188
2026-05-31 10:51:08 +02:00
edde746 656d512858 ci: add build platform toggles 2026-05-31 09:26:55 +02:00
edde746 d9135e0d25 fix(player): log display HDR capabilities 2026-05-31 08:48:21 +02:00
edde746 4f636dfffd fix(ci): install libmpv scripting deps 2026-05-31 07:54:37 +02:00
edde746 6438b444bf fix(player): clarify Dolby Vision playback logging 2026-05-31 06:56:18 +02:00
edde746 8413e1e5a7 fix(player): reduce sync offset dpad step 2026-05-31 06:38:25 +02:00
edde746 5671f6e5d1 fix(plex): send playback session stream header 2026-05-31 06:30:51 +02:00
edde746 a4a0475fdb fix(player): let macOS shortcuts pass through
close #1206
2026-05-31 03:03:52 +02:00
edde746 9a740b91c1 fix(plex): select available playback version 2026-05-31 02:21:15 +02:00
edde746 63ab80fa82 fix(player): switch refresh rate before ExoPlayer open 2026-05-31 00:28:54 +02:00
edde746 0bb085c70c feat(macos): support start in fullscreen setting
close #1200
2026-05-30 23:58:38 +02:00
edde746 9825ce3c7c fix: select first playable Plex Part instead of always Part[0]
close #1201
2026-05-30 21:37:01 +02:00
edde746 f5012b36ea fix(media-detail): collapse action buttons that overflow small screens 2026-05-30 17:20:16 +02:00
edde746 6111c381af fix(libraries): reload when a server connects after initial load 2026-05-30 14:01:01 +02:00
edde746 00fce3a997 fix(downloads): cap aggregate progress below 100% until all episodes complete
Rounding the smoothed per-episode progress could fill the ring to 100% while a download was still finishing; only report 100% once every queued episode is complete. Also drop the stale episode-count diagnostic log that read leafCount/_totalEpisodeCounts solely for logging after the denominator change.
2026-05-30 08:48:44 +02:00
edde746 b8417f0277 style(downloads): apply dart format to download_utils.dart
Merged via #1195 without formatting; aligns it with the repo's dart format pre-commit gate.
2026-05-30 08:48:03 +02:00
DarkmaddaandGitHub 4148c1c8ff fix(downloads): scope show progress to queued episodes; manage completed downloads (#1195)
Progress ring now measures completion against the episodes actually queued (episodes.length) instead of the show's full leafCount, and advances smoothly using per-episode progress. The completed show/season download button reopens the download-options dialog (with a Delete row) so users can fetch more episodes or switch to sync instead of only deleting.
2026-05-30 08:40:17 +02:00
edde746 9e5098f356 fix(trackers): respect server completion threshold
close #1194
2026-05-30 08:30:06 +02:00
edde746 139eed9ca9 fix(templates): require substantive issue-form fields 2026-05-30 07:33:06 +02:00
23efb02636 fix(browse): keep show in browse after deleting its downloads
The browse grid listens for DeletionEvents and decrements each show's
leafCount, evicting the show when the count hits zero. Download-only
deletions (isDownloadOnly: true) fired one event per episode, so deleting
every downloaded episode of a show drove its leafCount to zero and removed
it from browse - even though the content still exists on the server.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:20:54 +02:00
Timo KoxandGitHub 294996eac1 feat(player): add end-of-video sleep timer option (#1111)
close #540
2026-05-30 07:16:01 +02:00
edde746 a4f0b92b4e fix(windows): stop taskbar icon flashing on minimize after playback 2026-05-30 03:01:48 +02:00
Micah MorrisonandGitHub 7cac050500 feat(playback): add option to exit fullscreen on player close (#1174)
Adds a setting to automatically exit fullscreen when the video player is closed.
2026-05-30 02:38:35 +02:00
edde746 1685ecc991 fix(playback): prevent native subtitle autoselect 2026-05-30 00:05:29 +02:00
edde746 a71711de5f fix(ci): update database schema expectation 2026-05-29 23:45:01 +02:00
edde746 7534a6c60a fix(downloads): apply progress watch snapshots 2026-05-29 21:37:27 +02:00
edde746 dba01f14bb fix(playback): harden watch progress edge cases 2026-05-29 21:16:01 +02:00
edde746 d93ea9813f fix(playback): harden offline source reporting 2026-05-29 19:55:01 +02:00
edde746 7501f461b9 fix(playback): sync downloaded watch progress
close #1171, close #1183
2026-05-29 19:55:01 +02:00
edde746 5e5702c961 fix: resume playback after iOS audio route changes
close #1185
2026-05-29 11:04:27 +02:00
edde746 4b477a4fe3 build(tvos): bump engine to 3.44.0+3 2026-05-29 08:29:18 +02:00
edde746 50ff514be8 feat(tv): wrap on-screen keyboard focus at edges 2026-05-29 05:03:10 +02:00
edde746 1c7a083d6c build(linux): enable lua and javascript in libmpv 2026-05-29 03:32:45 +02:00
edde746 097e48d705 fix(tv): trap d-pad focus at button row edges
close #1181
2026-05-29 03:31:53 +02:00
edde746 a931d5910b fix(tv): let text fields fall back to directional traversal
close #1176
2026-05-29 03:30:54 +02:00
edde746 1cd585eecb feat(android): sync external player progress
close #1175
2026-05-29 00:58:45 +02:00
edde746 9981b277c6 test: cover jellyfin trickplay aspect ratio 2026-05-29 00:18:53 +02:00
edde746 2b7ea5fe02 feat(video): add custom zoom controls
close #1159
2026-05-29 00:06:47 +02:00
edde746 47276e1bac feat(tv): add optional full-card layouts 2026-05-28 22:33:06 +02:00
edde746andGitHub c8670a8d6a Merge pull request #1178 from micahmo/fix/plex-bif-aspect-ratio
Fix scrub preview aspect ratio
2026-05-28 22:05:37 +02:00
Micah Morrison 9cc32e125b Fix BIF scrub preview aspect ratio and raise desktop size limit 2026-05-28 15:46:08 -04:00
edde746 4217da1a8f fix(android): recover ExoPlayer audio failures 2026-05-28 09:31:19 +02:00
edde746 704c37f97f fix(ui): tighten media detail spacing 2026-05-28 08:16:27 +02:00
edde746 0c8e0fc2ec fix(i18n): update Bulgarian generated strings 2026-05-28 05:55:55 +02:00
edde746 d6315a0f75 feat(desktop): improve snackbar behavior 2026-05-28 04:48:36 +02:00
edde746 92cdbdd723 chore: remove local MPVKit checkout 2026-05-28 04:07:27 +02:00
edde746andDimitar Topper Maznekov 3939ba809f feat(i18n): add Bulgarian language
Co-authored-by: Dimitar "Topper" Maznekov <23097319+TopperBG@users.noreply.github.com>
2026-05-28 04:01:37 +02:00
edde746 6642348a96 feat(jellyfin): support multiple server urls 2026-05-28 03:43:46 +02:00
edde746 0b29673a5e fix(plex): use provider continue watching hub
close #1166
2026-05-28 01:29:29 +02:00
edde746 3747a74cb3 fix(pip): guard callbacks after dispose 2026-05-28 01:14:38 +02:00
edde746 bcb7f1d3c1 fix(android): disable flutter deeplink routing 2026-05-28 00:48:59 +02:00
edde746 7b56b90f24 fix(main): guard tvOS menu updates after dispose 2026-05-28 00:37:07 +02:00
edde746 ba62129414 fix(playback): support Apple DV conversion 2026-05-28 00:33:20 +02:00
edde746 e559a6125d fix(detail): isolate overlay sheet scrolling 2026-05-28 00:29:13 +02:00
edde746 869b4fe230 chore: bump MPVKit 2026-05-28 00:14:06 +02:00
edde746 3b4110d7e5 fix(android): improve Dolby Vision fallback 2026-05-27 21:02:48 +02:00
edde746 7b4e399013 chore: bump MPVKit 2026-05-27 06:52:03 +02:00
edde746 5788d93e89 feat(profiles): sort by recent use
close #1151
2026-05-27 06:52:03 +02:00
edde746 08a806e457 fix(playback): restore system ui after video
close #1152
2026-05-27 06:52:03 +02:00
edde746 e45bfadc14 feat(libraries): add nav quick picker
close #1144
2026-05-27 06:52:03 +02:00
edde746 56cfb95af5 fix(playback): apply default quality to jellyfin
close #1150
2026-05-27 00:02:23 +02:00
github-actions[bot] 8e8942fb36 chore: update cask to 2.3.0 2026-05-26 21:34:32 +00:00
github-actions[bot] 02f2657d18 chore: bump version to 2.3.0 2026-05-26 21:13:32 +00:00
edde746 39709e5c42 test(tv): update keyboard focus expectations 2026-05-26 23:00:44 +02:00
edde746 a101ee76d6 fix(downloads): prevent duplicate native tasks
close #1141
2026-05-26 22:43:05 +02:00
edde746 4eadec5b17 fix(tv): pass root menu to system 2026-05-26 18:27:26 +02:00
edde746 be86a33057 fix(discover): hide duplicate continue watching
close #1113
2026-05-26 14:48:16 +02:00
edde746 ce19b9dfa7 fix(ui): theme recommendation text colors 2026-05-26 14:05:26 +02:00
edde746 15cc85147a fix(tv): improve virtual keyboard input 2026-05-26 14:01:20 +02:00
edde746 3fc3baef83 fix(tv): reveal details without input 2026-05-26 12:02:48 +02:00
edde746 91b926acac fix(android): respect vtt subtitle position 2026-05-26 06:34:59 +02:00
edde746 8b2ff18842 fix(android): improve mpeg-ts seeking 2026-05-26 03:14:19 +02:00
edde746 1958f8ca7e feat(jellyfin): add folder browsing and playback 2026-05-26 01:39:39 +02:00
edde746 8e53232961 fix(jellyfin): avoid side-loading negotiated subtitles 2026-05-25 23:39:55 +02:00
edde746 cec8993b80 fix: update win_http 2026-05-25 23:19:59 +02:00
edde746 366ae67341 feat(jellyfin): add unwatched library filter
close #1116
2026-05-25 22:43:39 +02:00
edde746 50be8565d9 fix(tv): prefer show art for episode backdrops
close #1123
2026-05-25 21:08:33 +02:00
edde746 9365fcf246 fix(tv): keep alpha bar alphabet visible 2026-05-25 16:17:27 +02:00
edde746 8d4ef2757a fix(ios): update MPVKit PiP subtitles 2026-05-25 15:31:41 +02:00
edde746 6b147e2053 fix(ios): enable PiP subtitles 2026-05-25 04:47:55 +02:00
github-actions[bot] 5fa8326341 chore: bump version to 2.2.1 2026-05-24 17:06:57 +00:00
edde746 c8cded70ef fix(tv): polish media detail rails
close #1117
2026-05-24 19:06:06 +02:00
edde746 8fe51075be fix(android): force display mode matching 2026-05-24 18:39:41 +02:00
edde746 d24c95f1e1 fix(android): trust Media3 TrueHD passthrough 2026-05-24 18:09:42 +02:00
edde746 1c8be031ae chore(ios): declare export compliance 2026-05-24 15:10:58 +02:00
github-actions[bot] 83c47b529c chore: update cask to 2.2.0 2026-05-24 06:17:35 +00:00
github-actions[bot] 9dce396fb4 chore: bump version to 2.2.0 2026-05-24 05:53:47 +00:00
edde746 eeaa831cc2 fix(search): guard stale refresh callbacks 2026-05-24 07:37:02 +02:00
edde746 f1a02ade17 fix(android): guard desktop fullscreen calls 2026-05-24 07:22:56 +02:00
edde746 dcd0b03096 fix(downloads): repair offline artwork 2026-05-24 05:27:19 +02:00
edde746 a189ee5774 fix(offline): honor settled profile visibility 2026-05-24 03:12:33 +02:00
edde746 e74df0d245 fix(plex): fail open on unknown transcode capability 2026-05-24 00:39:30 +02:00
edde746andGitHub d980540c09 Merge pull request #1024 from Mofeywalker/feat/harmonize-playback-speeds
fix: add missing 0.25 speed steps
2026-05-23 21:49:33 +02:00
edde746 a51215952f docs: remove duplicate screenshot link 2026-05-23 20:52:06 +02:00
edde746 993a0e4666 fix(tv): route remote keys to native text input 2026-05-23 19:19:59 +02:00
edde746 4c43d6fdec fix(jellyfin): prefer direct playback by default 2026-05-23 18:38:38 +02:00
edde746 1c58a12c90 fix(jellyfin): omit audio index from static fallback 2026-05-23 16:35:09 +02:00
edde746 b6d23ed089 fix(startup): speed offline fallback
close #1108
2026-05-23 16:00:00 +02:00
edde746 3a042d79f6 fix(player): persist shader hotkey toggles
close #1105
2026-05-23 01:37:57 +02:00
edde746 1a73fc585b fix(player): align playback settings label
close #1106
2026-05-23 00:34:16 +02:00
edde746 8ec2795da1 fix(tv): stabilize long rail focus scrolling 2026-05-22 23:55:54 +02:00
edde746 cbea1c8d8f fix(tv): correct long hub row focus scrolling 2026-05-22 21:32:26 +02:00
edde746 048d22cb99 fix(livetv): expand program summaries 2026-05-22 20:52:21 +02:00
edde746 31c01ab3e0 chore(site): update tv screenshots 2026-05-22 17:15:51 +02:00
edde746 16e31303ae fix(tv): keep spotlight background static 2026-05-22 17:06:51 +02:00
edde746 e301fec38c fix(ci): clear unused parameter warnings 2026-05-22 16:04:39 +02:00
edde746 e1e9adbb01 fix(ios): handle status bar scroll taps
close #1101
2026-05-22 15:40:03 +02:00
edde746 636ecc9d3a fix(tv): fit fallback logo titles 2026-05-22 13:34:30 +02:00
edde746 fc231a9e72 fix(playlists): load paginated items mutably
close #1100
2026-05-22 05:28:03 +02:00
edde746 c354193187 fix(tv): fade media detail transitions 2026-05-22 04:30:22 +02:00
edde746 bb7ed72073 fix(tv): stabilize detail preplay layout 2026-05-22 04:13:00 +02:00
edde746 fea973a72a fix(tv): smooth sidebar expansion behavior 2026-05-22 03:54:34 +02:00
edde746 487c7b87f6 fix(nav): hide active sidebar background during focus 2026-05-22 02:21:06 +02:00
edde746 afc94a50fc fix(tv): push sidebar content without resizing 2026-05-22 01:38:29 +02:00
edde746 2ebd730201 fix(tv): displace content beside sidebar 2026-05-22 00:14:16 +02:00
edde746 0dd7d7c522 feat(tv): push content beside sidebar 2026-05-21 21:01:14 +02:00
edde746 eaf0f4fa1a fix(tv): stabilize browse rail focus and loading 2026-05-21 18:17:00 +02:00
edde746 743a8668ec feat(tv): refine sidebar backdrop 2026-05-21 17:04:23 +02:00
edde746 6a74ff083f fix(mpv): avoid forcing HDR colorspace hint 2026-05-21 16:01:15 +02:00
edde746 9dc3a1a1b1 feat(tv): improve hub rail experience 2026-05-21 15:57:02 +02:00
edde746 2b45557743 fix(android): preserve mpv frame-rate startup position 2026-05-21 09:58:13 +02:00
edde746 11b97e626e fix(gamepad): update reconnect dependency 2026-05-21 08:41:03 +02:00
edde746 d8f69f7acd fix(plex): copy mkv attachments during transcode
close #1083
2026-05-21 08:08:25 +02:00
edde746 b547f8a47f fix(android): gate mpv startup refresh on display switch 2026-05-21 02:59:11 +02:00
edde746 f4c83bedc0 chore(tv): guard text input diagnostics 2026-05-21 00:42:19 +02:00
edde746 0582ab2947 fix(livetv): show active recording indicators
close #1076
2026-05-21 00:19:21 +02:00
edde746 209101796b refactor: use private named constructor params 2026-05-20 23:48:29 +02:00
edde746 448a5d084c fix(tv): hide season poster tab setting 2026-05-20 22:22:46 +02:00
edde746 1ebfee9354 fix(android): stabilize TrueHD tunneling 2026-05-20 21:00:02 +02:00
edde746 29e0654d47 fix(windows): launch VLC from default install path
close #1085
2026-05-20 20:16:34 +02:00
edde746 f565684842 fix(ci): stabilize analyzer and linux builds 2026-05-20 19:10:46 +02:00
edde746 5d6f34f756 fix(android): preserve tracks on DV reload 2026-05-20 18:27:42 +02:00
edde746 73735c5a00 fix(ci): unblock analyzer and windows build 2026-05-20 18:10:21 +02:00
edde746 c93a266722 fix(plex): support no-burn transcode subtitles 2026-05-20 17:21:31 +02:00
edde746 2b167a60f5 fix(jellyfin): pin merged version playback 2026-05-20 05:27:48 +02:00
edde746 63e807dd58 fix(tv): focus flattened episode rows
close #1081
2026-05-20 03:55:48 +02:00
edde746 16e3500c49 fix(android): gate TrueHD passthrough 2026-05-20 02:23:57 +02:00
edde746 6e47a5fbcf fix(tv): hide hero setting 2026-05-19 22:57:24 +02:00
edde746 b18774d60b fix(tv): complete detail rail behavior 2026-05-19 20:23:17 +02:00
edde746 0383a77b56 fix(jellyfin): pin version playback source 2026-05-19 20:12:19 +02:00
edde746 d58007a9e2 fix(ios): keep PiP timebase renderer-owned 2026-05-19 20:09:23 +02:00
edde746 7d60875e19 fix(tv): refine browse rail layout 2026-05-19 19:30:57 +02:00
edde746 6be1b7c486 fix(android): unwrap DV supplemental samples 2026-05-19 18:26:41 +02:00
edde746 88c65e1857 chore(tv): add text input routing diagnostics 2026-05-19 14:42:34 +02:00
edde746 95ff95d397 fix(player): capture media screenshots
close #1077
2026-05-19 14:17:42 +02:00
edde746 1978d710da fix(ci): unblock Flutter 3.44 checks 2026-05-19 12:38:43 +02:00
edde746 423be62218 fix(tv): pause gamepad during native text input 2026-05-19 12:10:49 +02:00
edde746 d2650667b8 fix(tv): preserve native text input routing 2026-05-19 11:17:44 +02:00
edde746 48cf8accfa fix(jellyfin): resolve versions on demand 2026-05-19 10:54:04 +02:00
edde746 565885cc1e fix(ios): add privacy usage descriptions 2026-05-19 09:31:26 +02:00
edde746 d0e74151b8 fix(ios): update MPVKit renderer recovery 2026-05-18 23:48:22 +02:00
edde746 89c9c6c49b chore(tvos): align Flutter engine with 3.44 2026-05-18 23:31:54 +02:00
edde746 e98d7febdd chore: upgrade Flutter to 3.44 2026-05-18 22:52:51 +02:00
edde746 55ee2c270d fix(android): strip DV init data 2026-05-18 22:06:39 +02:00
edde746 81b05f00df fix(ci): pin Flutter stable version 2026-05-18 20:37:00 +02:00
edde746 f4f2571168 fix(android): apply dv conversion mode during init 2026-05-18 20:32:34 +02:00
edde746 d85a622fbe fix(jellyfin): restore collection query compatibility 2026-05-18 19:24:07 +02:00
edde746 d3e8dcef86 fix(windows): pin patched connectivity_plus 2026-05-18 19:05:08 +02:00
edde746 e9c93415a8 fix(macos): guard MoltenVK heaps 2026-05-18 17:37:46 +02:00
edde746 68e27fdbb3 feat(remote): remember manual host address
close #1074
2026-05-18 17:22:10 +02:00
edde746 a679bae354 fix(android): expose DV conversion setting 2026-05-18 16:30:14 +02:00
edde746 5a68711f7b feat(ui): add pointer cursors to clickable controls
close #1068
2026-05-18 15:56:41 +02:00
edde746 324d145292 fix(jellyfin): support external audio playback 2026-05-18 13:43:58 +02:00
edde746 d004342c98 chore: add website source 2026-05-18 13:15:17 +02:00
edde746 d3d7957b62 docs: improve readme links and downloads 2026-05-18 12:35:04 +02:00
edde746 0e94558548 fix(jellyfin): reduce collection query cost 2026-05-18 10:33:10 +02:00
edde746 8d14a14781 docs: update readme showcase 2026-05-18 06:16:26 +02:00
edde746andGitHub 20f704a56d Merge pull request #1070 from PsychotherapistSam/main
fix(player): adjust performance overlay padding
2026-05-18 06:13:06 +02:00
github-actions[bot] 93f0da7a27 chore: bump version to 2.1.1 2026-05-17 22:30:27 +00:00
edde746 8b3f8856f7 fix(scroll): avoid pending scroll timers 2026-05-18 00:23:17 +02:00
edde746 39fb828580 fix(ios): update MPVKit subtitle geometry
close #1071
2026-05-18 00:10:14 +02:00
edde746 3e0f7b4f86 fix(libraries): stabilize alpha and sort controls 2026-05-17 22:36:15 +02:00
Sam 7e9f62dc7f fix(player): adjust performance overlay padding 2026-05-17 21:14:00 +02:00
edde746 c855dba3be fix(media): paginate large server lists 2026-05-17 20:25:39 +02:00
edde746 e726f9f528 fix(media): align detail hero spacing 2026-05-17 16:17:38 +02:00
edde746 2c54baca34 docs: clarify feature availability 2026-05-17 15:40:49 +02:00
github-actions[bot] 232d98c4ce chore: update cask to 2.1.0 2026-05-17 12:15:25 +00:00
github-actions[bot] d93c81a7cb chore: bump version to 2.1.0 2026-05-17 11:44:29 +00:00
edde746 326d53415b chore: bump build 2026-05-17 13:43:57 +02:00
edde746 87db591792 feat(livetv): group guide by source 2026-05-17 06:41:47 +02:00
edde746 6bad454830 feat(player): add screenshot shortcut
close #1055
2026-05-16 23:50:08 +02:00
edde746 cfd4aa307d fix(plex): tolerate transient resource failures 2026-05-16 22:54:53 +02:00
edde746 3798a0c332 fix(ci): update generated tracker mapping 2026-05-16 21:55:18 +02:00
edde746 be0e9686b5 fix(search): rank media search results
close #1056
2026-05-16 21:40:22 +02:00
edde746 7ec45274bd fix(media): prevent detail action layout churn 2026-05-16 21:09:41 +02:00
edde746 3e74937386 feat(trackers): sync anime watch state by episode 2026-05-16 20:54:39 +02:00
edde746 310c4280fc feat(ratings): add tracker rating sheet
close #1031
2026-05-16 18:03:30 +02:00
edde746 ee6e327b12 fix(media): hide poster loading icons 2026-05-16 14:12:28 +02:00
edde746 e71dcdaa6c fix(libraries): tighten browse chip spacing 2026-05-16 13:23:07 +02:00
edde746 1b969953b5 fix(libraries): refresh all tabs 2026-05-16 12:24:26 +02:00
edde746 7690a16efe feat(tv): add spotlight browse layouts 2026-05-16 11:56:12 +02:00
edde746 91298294b0 fix(tvos): stabilize remote select handling 2026-05-15 22:16:24 +02:00
edde746 5df3ec0c12 fix(playback): use display criteria for matching 2026-05-15 19:46:04 +02:00
edde746 ef4980db09 fix(tv): preserve native keyboard navigation
close #1051
2026-05-15 19:05:38 +02:00
edde746 e37602421b fix(discover): expose full continue watching
close #1045
2026-05-15 18:49:23 +02:00
edde746 562d19c207 fix(watch-together): restore host resume
close #1052
2026-05-15 18:33:02 +02:00
edde746 e54bbb2214 fix(ios): restore video presentation 2026-05-15 18:16:07 +02:00
edde746 db2a2c34d2 fix(apple): update MPVKit release 2026-05-15 18:14:13 +02:00
edde746 12b006ee0c fix(tvos): prime display criteria from metadata 2026-05-14 19:51:33 +02:00
edde746 be3efa8335 fix(ios): update MPVKit subtitle cropping 2026-05-14 15:44:00 +02:00
edde746 d1d549d602 fix(android): restore flutter textureview 2026-05-14 11:03:54 +02:00
edde746 7de2a62c55 fix(tvos): update MPVKit teardown 2026-05-14 10:33:57 +02:00
edde746 7b4a37e576 fix(downloads): clean orphaned temp files
close #1040
2026-05-14 09:05:06 +02:00
edde746 5a46857035 fix(companion): respect remote server setting
close #1038
2026-05-14 02:23:43 +02:00
edde746 7ffb20b501 fix(libraries): respect grouping type 2026-05-14 02:01:41 +02:00
edde746 856ac83e6a fix(tv): use textureview 2026-05-13 19:34:20 +02:00
edde746 fb8fc1db1c fix(tvos): support 2nd gen Siri Remote 2026-05-13 18:44:53 +02:00
Micah Morrison 76e8ea6901 Respect episode/season grouping type when browsing library 2026-05-13 11:56:37 -04:00
edde746 e8c27f00a7 fix(tvos): avoid unsupported native clients 2026-05-13 17:12:50 +02:00
edde746 9f19f550c3 fix(tvos): bump MPVKit pacing build 2026-05-13 15:46:29 +02:00
edde746 fce794cd6c fix(discover): preserve library order on home
close #1034
2026-05-13 15:26:23 +02:00
edde746 8f9947a874 fix(trackers): use catalog totals for anime completion
close #1025
2026-05-13 15:07:19 +02:00
edde746 126e6b9349 fix(trackers): sync scoped anime progress 2026-05-13 14:10:01 +02:00
edde746 fcbe459177 feat(cache): cleanup orphaned plexImageCache directory on startup 2026-05-13 11:06:52 +02:00
edde746 0e296f8659 fix(livetv): show guide without favorites 2026-05-13 09:22:17 +02:00
edde746 12073ea134 fix(player): clear stale tracks before opening media
close #1028
2026-05-13 09:22:17 +02:00
edde746 8aea796dbd feat(jellyfin): expand library sort options
close #1032
2026-05-13 08:00:45 +02:00
edde746 9af6f32157 fix(player): harden initialization lifecycle 2026-05-13 06:28:09 +02:00
edde746 b0f1a5d4f1 fix(tv): back button Menu on Siri Remote 2026-05-12 22:46:38 +02:00
edde746 de62c8530f fix(tvos): subtitle HDR path and player dispose hardening 2026-05-12 22:46:38 +02:00
edde746 0f4629f52b fix(tvos): explicitly link AVKit for avDisplayManager category 2026-05-12 22:46:38 +02:00
edde746 de129fbb0a fix(trackers): preserve Plex library context 2026-05-12 22:46:38 +02:00
edde746 3a01fa25a7 fix(tv): pin engine to 3.41.6+7 for Up/Down dpad fix 2026-05-12 22:46:38 +02:00
edde746 86c19fc1fc fix(downloads): pin downloader cleanup
close #1026
close #921
2026-05-12 19:10:35 +02:00
edde746 ae1f6d7822 fix(tvOS): trigger native Dolby Vision via preferredDisplayCriteria 2026-05-12 16:21:00 +02:00
edde746 4577f26aa8 fix(lifecycle): abort HTTP before close 2026-05-12 15:11:16 +02:00
Moritz Fey fd26f470a5 fix: add missing 0.25 speed steps 2026-05-12 11:46:31 +02:00
edde746 bae127e459 fix(database): handle cross-drive desktop DB migration
close #1022
2026-05-12 10:41:32 +02:00
edde746 606627cd0c fix(search): use backend icons for server indicator 2026-05-12 08:25:52 +02:00
edde746 3ddd89113c fix(livetv): show recording indicators in guide
close #1015
2026-05-12 06:53:52 +02:00
edde746 5139e39ed4 fix(sentry): upload symbols with dart plugin 2026-05-12 06:40:54 +02:00
edde746 3a6012cc21 fix(lifecycle): guard async state updates 2026-05-12 06:11:14 +02:00
edde746 37b1a26cde fix(profile): prevent duplicate PIN dialog pops 2026-05-12 05:49:38 +02:00
edde746 e5922f4870 fix(plex): restore library filters and sorts
close #1020
2026-05-12 05:36:48 +02:00
edde746 ae7f4a7871 fix(jellyfin): unblock large library loading
close #1018
2026-05-12 05:28:20 +02:00
edde746 9e85aa7be6 fix(tv): handle native text input completion 2026-05-12 01:00:03 +02:00
edde746 fd69fe668a test(tvOS): align virtual keyboard select expectation 2026-05-11 18:31:09 +02:00
edde746 9085d4d94b fix(tvOS): route dpad clicks through engine 2026-05-11 18:08:56 +02:00
edde746 0775ea0d26 fix(tv): restore profile pin keypad input
close #1011
2026-05-11 15:13:37 +02:00
edde746 d901f5efc2 fix(jellyfin): direct stream live tv
close #1010
2026-05-11 13:50:46 +02:00
edde746 fcd8198fcb fix(plex): use promoted home hubs
close #1004
2026-05-11 13:18:43 +02:00
edde746 fd1eaf9d96 fix(plex): respect per-show season display
close #1008
2026-05-11 12:55:59 +02:00
edde746 d0bd919ff4 refactor: migrate models to freezed + json_serializable 2026-05-11 12:54:26 +02:00
edde746 2bbd31d927 feat(jellyfin): add actor media browsing
close #1005
2026-05-11 11:54:37 +02:00
edde746 617f200cb5 fix(android): avoid pixel eac3 decoder fallback
close #1007
2026-05-11 11:14:32 +02:00
edde746 b96d734e8c fix(jellyfin): load collections from boxsets root
close #1006
2026-05-11 11:11:22 +02:00
edde746 89f256138d fix(player): flush mpv buffers on frame-rate startup 2026-05-11 10:50:11 +02:00
edde746 9b248a7a5b fix(macos): restore video layer on focus 2026-05-11 10:13:05 +02:00
github-actions[bot] 8d76dff353 chore: update cask to 2.0.0 2026-05-11 05:30:32 +00:00
github-actions[bot] eff75d2029 chore: bump version to 2.0.0 2026-05-11 05:11:41 +00:00
edde746 f9615491b3 refactor(media): migrate media item to freezed 2026-05-11 07:01:46 +02:00
edde746 4252bd88e9 docs: mention jellyfin support 2026-05-11 05:29:31 +02:00
edde746 00917a0393 refactor(media): split media and jellyfin client 2026-05-11 05:24:07 +02:00
edde746 4cdcd2f2e4 feat(sheets): position desktop sheets near cursor 2026-05-11 05:07:00 +02:00
edde746 27006cfa99 fix(profiles): localize profile UI strings 2026-05-11 04:43:21 +02:00
edde746 bdbd667584 feat(discord): self-host rich presence posters 2026-05-11 04:19:41 +02:00
edde746 4b243d38b9 fix(player): hide stale next marker
close #1003
2026-05-11 02:47:15 +02:00
edde746 cd31b2e2e6 fix(subtitles): remember search language
close #1002
2026-05-11 02:40:50 +02:00
edde746 f185ebd554 fix(downloads): improve sync rules navigation
close #1001
2026-05-10 23:18:09 +02:00
edde746 7d679c9d78 fix(player): gate frame-rate refresh on mpv playback-restart 2026-05-10 22:23:13 +02:00
edde746 17184881f3 chore(i18n): tighten translation copy 2026-05-10 15:45:39 +02:00
edde746 a39a2a25eb fix(profiles): improve pin entry 2026-05-10 14:58:55 +02:00
edde746 e0f1695260 fix(profiles): lazy-load plex auth 2026-05-10 14:35:25 +02:00
edde746 d0ffd49f69 fix(startup): recover failed server bind 2026-05-10 14:27:33 +02:00
edde746 bfb6aa01b9 fix(apple): use platform-specific mpv rendering 2026-05-10 13:33:02 +02:00
edde746 6f273b0aa8 fix(apple): align local network permissions 2026-05-10 09:39:43 +02:00
edde746 3a93ec80ec fix(jellyfin): include movie home hubs 2026-05-10 00:53:20 +02:00
edde746 dd0fd1c828 fix(profiles): reuse switch flow for menu 2026-05-10 00:36:08 +02:00
edde746 ec93062600 fix(app): harden playback and plex flows 2026-05-10 00:35:01 +02:00
edde746 822333bf34 fix(media): align rate chip 2026-05-09 15:11:15 +02:00
edde746 ffc20b26d4 fix(player): block tv background media resume
close #990
2026-05-09 14:37:00 +02:00
edde746 3a612df3d3 chore: remove in-app review 2026-05-09 11:47:37 +02:00
edde746 afda61119e fix(macos): sync restored fullscreen chrome 2026-05-09 11:35:36 +02:00
edde746 bb66ab6526 fix(player): sync restored volume state 2026-05-09 11:10:49 +02:00
edde746 ec2b94e367 fix(plex): increase Plex TV timeout 2026-05-09 10:46:19 +02:00
edde746 5401b5005f perf(player): reduce timeline layout churn 2026-05-09 10:02:32 +02:00
edde746 d38f7cf78f perf(player): reduce playback UI churn 2026-05-09 09:38:25 +02:00
edde746 633ce11c88 chore(deps): update MPVKit 2026-05-09 09:17:03 +02:00
edde746 7a3f63683e fix(ui): refresh stale watch state 2026-05-09 06:00:36 +02:00
edde746 3e6ebcdb6a fix(player): refresh Android MPV decoder on startup 2026-05-09 05:10:51 +02:00
edde746 c38c0cf55d fix(jellyfin): hide unsupported continue watching removal
close #995
2026-05-09 04:41:13 +02:00
edde746 5d2302b322 fix(player): stabilize Apple timeline progress 2026-05-08 12:34:07 +02:00
edde746 51a8b971f6 fix(jellyfin): keep poster fallback after recycling 2026-05-08 10:33:49 +02:00
edde746 3319df4538 fix(ui): improve context menu behavior 2026-05-08 09:53:08 +02:00
edde746 1e6bd01987 fix(player): harden Apple PiP handling 2026-05-08 07:40:41 +02:00
edde746 e10cc7d273 fix(player): accumulate repeated seeks 2026-05-08 06:57:41 +02:00
edde746 19dea4b31e refactor(ui): share bottom sheet scaffold 2026-05-08 06:50:34 +02:00
edde746 653fada5dc refactor(player): move quality picker into playback settings 2026-05-08 06:24:59 +02:00
edde746 65b42bca3f fix(android): stabilize mpv frame-rate startup 2026-05-08 06:12:04 +02:00
edde746 5fe5534bae fix(jellyfin): bound home artwork load 2026-05-08 06:06:09 +02:00
edde746 f07feaaa52 fix(plex): repair alpha jump bar 2026-05-08 05:36:39 +02:00
edde746 92014404f5 chore(apple): sync MPVKit package locks 2026-05-08 03:10:02 +02:00
edde746 e2a7a7ab4b chore(apple): bump MPVKit pin 2026-05-08 03:09:05 +02:00
edde746 cedd448ef2 feat(apple): use AVFoundation mpv output 2026-05-08 02:39:42 +02:00
edde746 2208d6c637 fix(database): tolerate retried v14 migration 2026-05-07 11:27:12 +02:00
edde746 187eeb19d3 fix(plex): retry slow library hubs 2026-05-07 04:14:31 +02:00
edde746 955c75db60 fix(nav): improve back exit behavior 2026-05-07 03:06:04 +02:00
edde746 9446f976a4 fix(tv): support hardware keyboard input 2026-05-07 00:52:34 +02:00
edde746 7988c83bec fix: improve d-pad focus navigation 2026-05-07 00:27:58 +02:00
edde746 ebec3ad20c fix: autofocus jellyfin server url 2026-05-06 13:19:01 +02:00
edde746 3b18b44cd2 fix(tv): show provider choices on login 2026-05-06 12:47:39 +02:00
edde746 cf4fc74d68 fix: make text fields focusable in traversal 2026-05-06 12:06:55 +02:00
edde746 c75c87f527 fix(android): sync mpv display fps override 2026-05-06 10:47:40 +02:00
edde746 e71c39271c fix(windows): recover mpv audio output 2026-05-06 10:22:04 +02:00
edde746 de547884f8 fix(tvos): stabilize remote input and player build 2026-05-06 08:47:13 +02:00
edde746 4700cc5bd4 fix: change icons 2026-05-06 07:46:02 +02:00
edde746 07d25f5d5f fix(android): stop forcing mpv display sync 2026-05-06 06:19:18 +02:00
edde746 1f09c07e96 feat(player): toggle chapter timeline markers
close #973
2026-05-06 05:26:47 +02:00
edde746 e8769e7059 fix(android): detect tv mode beyond leanback
close #980
2026-05-06 02:57:55 +02:00
edde746 88430ba7b6 feat(ios): support external display playback
close #984
2026-05-06 02:17:20 +02:00
edde746 25a2187bf4 refactor: reduce settings and media selector duplication 2026-05-06 01:33:54 +02:00
edde746 fb084d3ea1 fix(plex): stabilize Linux endpoint failover 2026-05-06 00:51:00 +02:00
edde746 da7aef4548 fix(profiles): avoid placeholder profile on first launch 2026-05-06 00:32:15 +02:00
edde746 91c23786f2 refactor: reduce model and focus boilerplate 2026-05-06 00:26:44 +02:00
edde746 18a5041334 fix(tvos): stabilize Siri Remote input 2026-05-05 23:49:31 +02:00
edde746 94dbae0af9 fix(playback): prevent spurious seek jumps
close #946
2026-05-05 09:52:19 +02:00
edde746 05c4d6dff3 feat(playback): restart before previous episode
close #979
2026-05-05 08:28:17 +02:00
edde746 ef2db78310 fix(jellyfin): fall back from missing season posters
close #981
2026-05-05 07:53:36 +02:00
edde746 955bc9548c refactor: adopt shared mixins, helpers, and sliver widgets 2026-05-05 03:06:56 +02:00
edde746 ef8026797c fix(tvos): sync build version from pubspec 2026-05-05 00:23:32 +02:00
edde746 ed4be7b96d refactor: strip obvious comments 2026-05-04 22:40:18 +02:00
edde746 fdaff3687d fix(playback): keep Plex playlist queue when entering the player
close #978
2026-05-04 21:34:14 +02:00
edde746 f573eda657 ci: fix build dependency cache 2026-05-04 13:23:23 +02:00
edde746 708a1391e3 fix(desktop): hide hub hover arrows when row isn't scrollable 2026-05-04 10:53:55 +02:00
edde746 9e4230597f fix(jellyfin): label per-library hubs with library name
close #975
2026-05-04 10:24:01 +02:00
edde746 720d0d2604 feat(livetv): add DVR and recording support 2026-05-04 09:31:53 +02:00
edde746 dee12ac336 bump tvOS engine to 3.41.6+1 2026-05-04 06:12:06 +02:00
edde746 5f9f0964b2 fix(exoplayer): stack overlapping SRT subtitle cues 2026-05-04 01:19:32 +02:00
edde746 bf89925d6a fix(android): preserve display mode across backend fallback 2026-05-04 01:19:32 +02:00
edde746 aa6e9648bb fix(templates): add Both option to feature request media server dropdown
close #971
2026-05-03 20:35:11 +02:00
edde746 c4b9da0950 refactor(images): replace flutter cache manager 2026-05-03 20:09:23 +02:00
edde746 c7b023222c fix(exoplayer): apply subtitle sync to ASS subtitles
close #936
2026-05-03 11:13:35 +02:00
edde746 060926b749 fix(macos): avoid double disposing mpv core 2026-05-03 10:59:30 +02:00
edde746 aa3c83bad1 fix(jellyfin): handle external transcode subtitles 2026-05-03 10:12:11 +02:00
edde746 70606c8ac5 fix(jellyfin): align playback and browse requests 2026-05-03 09:33:42 +02:00
edde746 5010833da9 fix(mpv): avoid blocking runtime calls 2026-05-03 08:23:44 +02:00
edde746 f5901389e0 fix(macos): restore traffic lights on player exit 2026-05-03 06:52:51 +02:00
edde746 11e1a1464e fix(playback): sync resume progress on exit 2026-05-03 06:45:54 +02:00
edde746 2664caf004 test(jellyfin): align watched fixtures with played flag 2026-05-03 03:45:00 +02:00
edde746 8594a7bc52 fix(jellyfin): respect played flag for watched state
close #965
2026-05-03 03:29:39 +02:00
edde746 8a7d767e54 fix(tvos): remove top shelf image alpha 2026-05-03 02:39:11 +02:00
edde746 d09893470f ci: isolate pub dependency cache 2026-05-03 02:27:03 +02:00
edde746 a40879849b fix(tvos): hide browser auth option 2026-05-03 02:00:39 +02:00
edde746 20953152b6 fix(tvos): dismiss overlays with back 2026-05-03 01:50:26 +02:00
edde746 876a44b244 feat(tvos): add virtual keyboard input 2026-05-03 01:32:58 +02:00
edde746 e9a706fa72 fix(tvos): enable wakelock plugin 2026-05-03 01:26:12 +02:00
edde746 8820c0e059 fix: avoid clear logo fallback shift 2026-05-02 22:38:11 +02:00
edde746 45770e1ad6 fix: unblock watch together resume
close #961
2026-05-02 22:36:49 +02:00
edde746 afdec05dbf fix: retry Plex home hubs
close #962
2026-05-02 19:18:42 +02:00
edde746 81e036dc4f feat: support Jellyfin media segments
close #960
2026-05-02 18:30:12 +02:00
edde746 8555254e81 fix(library): defer browse scroll reset 2026-05-02 11:31:18 +02:00
edde746 1781fc06ae fix: merge Jellyfin next up into continue watching 2026-05-02 11:19:04 +02:00
edde746 810f1e5da5 fix: stabilize hidden library focus traversal 2026-05-02 10:03:36 +02:00
edde746 244b291255 refactor: share controller disposal and Plex DTO parsing 2026-05-02 09:43:08 +02:00
edde746 7fc136f592 refactor: reduce settings and mapper boilerplate 2026-05-02 09:05:23 +02:00
edde746 130e0d6f40 refactor: streamline settings bindings 2026-05-02 08:44:27 +02:00
edde746 8fabf56337 refactor: split playback and media hotspots 2026-05-02 06:33:05 +02:00
edde746 b1e218f0ad chore: optimize workflow caching 2026-05-02 06:21:29 +02:00
edde746 624e8a2991 refactor: share settings and profile flows 2026-05-02 02:35:07 +02:00
edde746 0ac5f49e5e chore: clear analyzer hints 2026-05-02 02:34:26 +02:00
edde746 de6f8d6cb6 refactor: share media stream mapping 2026-05-02 02:29:46 +02:00
edde746 b42ee26840 refactor: share tracker auth plumbing 2026-05-02 02:02:50 +02:00
edde746 50bc245677 refactor: share low-risk UI and tracker helpers 2026-05-02 01:58:50 +02:00
edde746 646832c6b1 chore: bump version 2026-05-02 01:45:33 +02:00
edde746 43242673fb fix(profile): avoid PIN prompt on restart 2026-05-02 01:23:21 +02:00
edde746 87aa7d9d81 fix(library): reset browse grid focus from tab bar 2026-05-02 01:08:06 +02:00
edde746 91d9aad721 fix: improve subtitle search focus navigation 2026-05-02 01:05:54 +02:00
edde746 221902c946 fix(library): reset outer scroll when switching libraries 2026-05-02 00:22:19 +02:00
edde746 8a8c2ecb87 fix: reload migrated profiles before startup 2026-05-01 23:08:21 +02:00
edde746 f6c6808352 feat(library): scroll tabs and chips with content 2026-05-01 22:34:59 +02:00
edde746 036d99f11f perf(android): optimize DV conversion hot path
Remove avoidable per-RPU allocations and copies during DV fallback conversion, and add debug controls to force conversion modes while profiling playback.
2026-05-01 17:50:59 +02:00
edde746 e78f7cee5e fix: shrink short library sheets 2026-05-01 08:31:29 +02:00
edde746 f03c84e8dc fix: group mobile library dropdown by server 2026-05-01 07:55:15 +02:00
edde746 d5a12c404a fix: show library actions drag handle 2026-05-01 07:22:10 +02:00
edde746 dc74300ef5 fix: avoid macOS fullscreen playback stalls
close #934
2026-05-01 07:04:37 +02:00
edde746 b2c3fe61b3 fix: use runner Swift for native formatting 2026-05-01 06:56:56 +02:00
edde746 97e65a811c fix: hide backup settings on TV
close #937
2026-05-01 06:56:56 +02:00
edde746 d9d66fe54e fix: add HTTP fallback for native Plex hostnames 2026-05-01 06:45:10 +02:00
edde746 ed7dae601a fix: avoid duplicate play next countdown
close #942
2026-05-01 06:44:56 +02:00
edde746 024af35bf5 chore: add native formatting checks 2026-05-01 05:56:49 +02:00
edde746 9bd5732f2b chore: clean up redundant comments 2026-05-01 05:02:26 +02:00
edde746 117d1b8882 fix: fall back to wide hero art 2026-05-01 04:52:48 +02:00
edde746 f6aad1b8fc fix: allow downloaded detail artwork network fallback
close #948
2026-05-01 04:40:03 +02:00
edde746 c602936d2a fix: support forced skip marker fallback
close #956
2026-05-01 04:35:30 +02:00
edde746 a351ca70ba fix: localize remaining i18n strings 2026-05-01 04:12:55 +02:00
edde746andGitHub 396a5a8453 Merge pull request #955 from bryceknz/fix/951-test-expectations
Fix media version label tests
2026-05-01 03:54:39 +02:00
Bryce Kehoe db6d5cadd9 fix(media): uppercase named resolution labels
Display non-numeric Plex resolution labels in uppercase while preserving p suffixes for numeric heights.
2026-05-01 02:27:05 +01:00
Bryce Kehoe 5568266c99 test(media): fix UHD label expectations
Assert exact display labels for resolution-only media versions so the test matches displayLabel output without codec/container fields.
2026-05-01 02:27:05 +01:00
edde746 a605db31f9 fix: split app database tests for lint 2026-05-01 03:24:15 +02:00
edde746 35ca723c20 chore: update issue templates 2026-05-01 03:20:49 +02:00
edde746 7057ee93ac fix: defer profile binding before selection 2026-05-01 03:16:32 +02:00
edde746 6cde5b6bd1 style: format database generated code 2026-05-01 01:54:56 +02:00
edde746 08564bba20 feat: merge jellyfin support 2026-05-01 01:54:56 +02:00
edde746 495f2e3640 chore: update SDK constraints 2026-05-01 01:47:05 +02:00
edde746 31d2d9dc98 feat: jellyfin 2026-05-01 01:20:36 +02:00
edde746andGitHub 68d34f81a0 Merge pull request #952 from bryceknz/fix/951-uhd-version-labels
Fix UHD version labels
2026-05-01 00:59:30 +02:00
Bryce Kehoe e733a0abc7 test(media): cover UHD version labels
Add display label coverage for numeric resolutions and UHD values returned by Plex.
2026-04-30 22:09:54 +01:00
Bryce Kehoe c800baeb25 fix(media): format UHD version labels
Normalize Plex 4k/8k resolution values for display so version pickers show 4K/8K instead of appending a p suffix.
2026-04-30 22:09:54 +01:00
github-actions[bot] 4080c812d2 chore: update cask to 1.35.2 2026-04-30 14:50:20 +00:00
github-actions[bot] 617bf64105 chore: bump version to 1.35.2 2026-04-30 14:06:58 +00:00
edde746 9f19082d47 fix(android): hole-punch video and libass surfaces on API 36+ 2026-04-30 16:04:16 +02:00
github-actions[bot] 5b9719b96f chore: update cask to 1.35.1 2026-04-28 03:41:25 +00:00
github-actions[bot] 3fd4320e52 chore: bump version to 1.35.1 2026-04-28 03:18:12 +00:00
edde746 d66e5af289 fix(livetv): fall back to legacy channels endpoint when cloud guide errors 2026-04-28 04:21:40 +02:00
edde746 c5867861eb fix(playback): coalesce concurrent player init to fix MPV hang 2026-04-28 04:16:02 +02:00
edde746 fdede5f643 chore: dart format focusable_tab_chip.dart 2026-04-28 04:16:02 +02:00
edde746andGitHub bb0f70c75c Merge pull request #926 from micahmo/feature/show-posters
Show season posters on tab in show view
2026-04-27 15:31:39 +02:00
Micah Morrison dcffb74529 Gate season posters on tab behind a setting 2026-04-27 09:28:57 -04:00
github-actions[bot] d8f665ef97 chore: update cask to 1.35.0 2026-04-27 12:08:50 +00:00
github-actions[bot] 6bd203f606 chore: bump version to 1.35.0 2026-04-27 11:44:11 +00:00
edde746 240389fb6d fix(ci): upload symbols outside release path 2026-04-27 13:23:30 +02:00
edde746 ca6cb46ba8 fix: upload symbols as individual files 2026-04-27 12:44:59 +02:00
edde746 616ed72503 fix: exclude mal/anilist/simkl session keys from settings export 2026-04-27 12:17:34 +02:00
Micah Morrison 310d4b46b2 Show season posters on tab in show view 2026-04-26 15:11:24 -04:00
edde746 d6b47f9d51 fix(windows): suppress Steam Input duplicate actions 2026-04-26 20:46:25 +02:00
edde746 19a0128363 feat(playback): add ArtCNN shader presets
close #924
2026-04-26 20:29:38 +02:00
edde746 3433dcb21c fix(downloads): clean up stale SAF target file before re-enqueue 2026-04-26 19:15:06 +02:00
edde746 62e3cbcfd0 fix(playback): chain adjacent-episode load behind play queue 2026-04-26 18:44:35 +02:00
edde746 4688e1eb5e style: wrap long if-condition flagged by dart format 2026-04-26 18:44:35 +02:00
edde746 f5e0e8c8b5 feat(settings): add "Start in fullscreen" option on Windows/Linux
close #923
2026-04-26 18:12:30 +02:00
edde746 8c8fc13baf fix(playback): skip deleted/inaccessible Plex media versions before play
close #919
2026-04-26 18:12:30 +02:00
edde746andGitHub a9a8fa388e Merge pull request #925 from crtdll/main
fix(windows): fixed black screen while navigating between videos in full screen
2026-04-26 18:05:07 +02:00
CameronandGitHub 6371d7a36e fix(windows): fixed black screen while navigating between videos in full screen 2026-04-26 16:59:50 +01:00
edde746 425da3ab7d fix(overlay-sheet): wrap barrier in Positioned.fill 2026-04-25 19:59:36 +02:00
edde746 7aaa373bdf revert: desktop image eviction threshold to 1.5GB 2026-04-25 17:12:17 +02:00
edde746 23d958fac8 fix(downloads): guard ensureAbsolutePath against RangeError on short paths 2026-04-25 14:10:37 +02:00
edde746 e61f0002e5 test: episode nav, track selection, play queue, more mixins 2026-04-25 13:54:08 +02:00
edde746 303bd406c2 test: watch_together provider, more mixins, services, utils 2026-04-25 13:44:15 +02:00
edde746 ce7e936931 test: complex provider, mixin, and service tests 2026-04-25 13:29:48 +02:00
edde746 f049c65623 test: provider/mixin/service tests + visibleForTesting reset hooks 2026-04-25 13:15:01 +02:00
edde746 7f8c066062 test: add provider and drift database tests 2026-04-25 13:04:08 +02:00
edde746 edff1f29db test: scaffolding + first provider test (hidden_libraries) 2026-04-25 12:56:38 +02:00
edde746 87696d77d1 refactor(models): json_serializable for 9 manual fromJson models 2026-04-25 12:52:43 +02:00
edde746 0dbcda25e7 refactor(models): json_serializable for plex_filter 2026-04-25 12:45:54 +02:00
edde746 ebbcc60e13 perf(screens): cache Theme.of in hot media detail/discover paths 2026-04-25 12:44:15 +02:00
edde746 1ce561a1ad perf(images): add memCacheHeight to direct CachedNetworkImage sites 2026-04-25 12:37:41 +02:00
edde746 c36fee17dd refactor(providers): apply DisposableChangeNotifierMixin to 11 providers 2026-04-25 12:35:52 +02:00
edde746 eeed34e2df perf(folder-tree): use ListView.builder for lazy row build 2026-04-25 12:32:23 +02:00
edde746 52b70a0080 perf(mediaquery): use scoped accessors (sizeOf, paddingOf, etc.) 2026-04-25 12:30:53 +02:00
edde746 feba5b6f74 lint: enforce unawaited_futures, prefer_final_locals, etc. 2026-04-25 12:27:28 +02:00
edde746 c2cd2dea37 perf: cap hero decode, lower desktop image eviction threshold 2026-04-25 11:59:56 +02:00
edde746 5d87c6808d refactor(prefs): route services through sharedCache 2026-04-25 11:13:12 +02:00
edde746 0531c33b0c refactor(settings): typed Pref<T> declarations 2026-04-25 10:52:34 +02:00
edde746 e4e8299981 refactor: showLoadingDialog helper, json_serializable for plex_home 2026-04-25 03:25:09 +02:00
edde746 d0a93d57dd refactor: codebase review cleanup 2026-04-25 03:16:11 +02:00
edde746 1949c22b85 fix(trackers): dialog cancel pops screen behind 2026-04-24 23:32:13 +02:00
edde746 3a69e49ab8 perf: parallelize playback startup 2026-04-24 23:20:31 +02:00
edde746 df94e83592 fix(trackers): cancel connect immediately 2026-04-24 20:54:38 +02:00
edde746 1de91a598c refactor(tvos): cleanup 2026-04-24 20:21:30 +02:00
edde746 49a86e402c fix: stabilize grid column count when alpha bar shows 2026-04-24 19:19:39 +02:00
edde746 3d023962e1 feat: server grouping, hidden-library browsing
close #557, close #827
2026-04-24 19:11:25 +02:00
edde746 a13cfedbce fix(tvos): app store builds 2026-04-24 17:35:22 +02:00
edde746 2b59de91c7 chore: dart format 2026-04-24 14:19:07 +02:00
edde746 2479e1faaa feat: tvos support 2026-04-24 14:11:24 +02:00
edde746 77401bef32 fix(android): saf download deletion 2026-04-24 13:07:06 +02:00
edde746 e961bc6293 refactor: share tracker/trakt auth and connect flow 2026-04-24 12:32:25 +02:00
edde746 bf2584d90f feat: per-tracker library whitelist/blacklist
close #917
2026-04-24 10:30:16 +02:00
edde746 e8729a4e27 feat: oauth proxy for mal/anilist auth 2026-04-24 09:46:45 +02:00
edde746 1582fdbd5b feat: mal/anilist/simkl progress sync
close #914
2026-04-24 08:29:49 +02:00
edde746 431c20f817 feat: match/unmatch metadata 2026-04-23 19:02:14 +02:00
edde746 4a5cb224b1 feat: swap chapters/queue player icons 2026-04-23 18:26:03 +02:00
edde746 d5d9da046e feat: toggle auto update check on startup
close #913
2026-04-23 18:06:27 +02:00
edde746 0c6796ebe7 fix: desktop double-click fullscreen full area 2026-04-23 15:25:38 +02:00
edde746 d73b9182cc fix: stable image cache key across window resize 2026-04-23 13:58:25 +02:00
edde746 39bcd9b943 feat(android): flutter surfaceview rendering 2026-04-23 10:10:34 +02:00
edde746 ca5d87143b feat: global fullscreen toggle (F11 + sidebar)
close #907
2026-04-23 07:43:46 +02:00
edde746 1e54344ec3 feat: toggle episode number on cards 2026-04-22 21:35:03 +02:00
edde746 c10cf55f2d feat: transcoding 2026-04-22 21:07:12 +02:00
edde746 05aeee1124 test(server): relay integration coverage 2026-04-22 15:24:34 +02:00
edde746 74c6af2980 feat(server): persist rooms across restarts 2026-04-22 14:57:41 +02:00
edde746 0883fe2e36 chore(server): tune bugs memory limits 2026-04-22 14:29:36 +02:00
edde746 e95475f845 feat(sentry): symbol upload and hang tracking 2026-04-22 12:44:37 +02:00
edde746 b2cd0c2c37 fix: retry live tv tune, clamp timeline duration 2026-04-22 08:45:02 +02:00
edde746 06b3c9645c test: assert dispose completer fires 2026-04-22 02:44:54 +02:00
edde746 557ef69697 fix: guard setState/context against disposed widget 2026-04-21 22:29:57 +02:00
edde746 eb49ead83b fix: paginate collection/playlist/person endpoints
close #903
2026-04-21 21:51:20 +02:00
edde746 e2be6a0093 chore: remove jscpd and sonarqube 2026-04-21 20:26:19 +02:00
edde746 d4235a286a test: unit tests for utils, models, settings mpv parser 2026-04-21 20:09:58 +02:00
edde746 fd701e5d1f fix: restrict failover to working endpoint network class
close #902
2026-04-21 19:20:57 +02:00
edde746 e6c1923b0e refactor(exoplayer): drop duplicated mpv box-fit translation 2026-04-21 18:23:46 +02:00
edde746 839465b7f2 feat(exoplayer): box-fit modes (letterbox/cover/stretch) 2026-04-21 18:17:25 +02:00
edde746 7c331e51e6 fix: auto-skip credits honors auto-play-next
close #901
2026-04-21 17:37:31 +02:00
edde746 775998466d i18n: rename audio normalization to normalize loudness
close #896
2026-04-21 17:35:18 +02:00
edde746 ee020d81bd refactor: trakt/download hardening and cleanup sweep 2026-04-21 13:57:03 +02:00
edde746 93d196572f fix: clamp playlist focus index on remove 2026-04-21 12:36:15 +02:00
edde746 f69068eb63 feat(db): index DownloadedMedia hot columns 2026-04-21 12:05:50 +02:00
edde746 1edf3da984 refactor: consolidate dialogs to DialogActionButton 2026-04-21 12:05:21 +02:00
edde746 4665f2a5d8 fix: dispose services and subscriptions, persist client id 2026-04-21 12:04:48 +02:00
edde746 fd898e0567 fix: json parse safety, stop mutating input maps 2026-04-21 12:03:32 +02:00
edde746 48ba923f47 chore: bump mpvkit and libmpv-android 2026-04-21 10:39:24 +02:00
edde746 e1c9ce6f71 fix: gate http fallback for hostname https, throw on non-2xx in failover
close #900
2026-04-21 08:39:28 +02:00
edde746 5937e5c721 refactor: extract shared mixins and helpers to dedupe 2026-04-20 21:25:50 +02:00
edde746 3b7b532a8d fix: exclude trakt keys from settings export 2026-04-20 21:17:00 +02:00
edde746 bc57ffb930 feat: export/import settings 2026-04-20 20:17:57 +02:00
edde746 1fd415ae31 feat(android): display switch delay
close #894
2026-04-20 17:34:54 +02:00
edde746 6d265e7bae feat: episode next/prev hotkeys
close #897
2026-04-20 16:34:46 +02:00
edde746 4667b104d2 docs: refresh readme features 2026-04-20 13:52:19 +02:00
github-actions[bot] 2f524cffae chore: update cask to 1.34.2 2026-04-20 11:36:26 +00:00
github-actions[bot] 87215d9e7b chore: bump version to 1.34.2 2026-04-20 11:15:06 +00:00
edde746 65e19706f8 fix(exoplayer): external sub attach, non-ass render
close #895
2026-04-20 13:14:32 +02:00
edde746 f96342bd81 chore: bump libass to fp-3
close #893
2026-04-20 11:55:21 +02:00
github-actions[bot] f7a6ab25c3 chore: update cask to 1.34.1 2026-04-20 06:57:27 +00:00
github-actions[bot] fa88d35b2b chore: bump version to 1.34.1 2026-04-20 06:15:51 +00:00
edde746 64d5c1f6aa fix: force rediscover on manual reconnect 2026-04-20 08:15:16 +02:00
github-actions[bot] 56baa48223 chore: bump version to 1.34.0 2026-04-20 06:13:39 +00:00
edde746 ad3edd4652 feat: log connection failure detail 2026-04-19 23:05:22 +02:00
edde746 81bac6ee9b fix(exoplayer): libass performance 2026-04-19 23:01:40 +02:00
edde746 a715f4e26b feat: collection/playlist sync rules 2026-04-19 17:45:03 +02:00
edde746 6115c21b44 chore: prune unused i18n keys 2026-04-19 14:48:18 +02:00
edde746 f367260acd chore: i18n cleanup script 2026-04-19 14:47:52 +02:00
edde746 7f8b827c0e fix: reset completion latch on stream recovery
close #664
2026-04-19 14:08:20 +02:00
edde746 0a1abfcbbb fix: video playback tile description 2026-04-19 13:42:58 +02:00
edde746 f53592f32b fix: live tv dvr seek via keyboard/remote
close #789
2026-04-19 13:13:38 +02:00
edde746 fd1d187ede feat: timeline thumbnail on dpad key-repeat
close #805
2026-04-19 12:24:14 +02:00
edde746 b4e78febc0 feat: donation button 2026-04-19 11:52:46 +02:00
edde746 ff2928b18f feat: trakt integration
close #617
2026-04-19 11:31:27 +02:00
edde746 8b34307975 fix: re-select audio on renderer capabilities change
close #889
2026-04-18 18:36:44 +02:00
edde746 9cca40420c fix: guard player back pop with canPop 2026-04-18 18:08:51 +02:00
edde746 3da51f9d64 chore: pre-commit ci hook, dart format 2026-04-18 12:40:35 +02:00
edde746 2bd9e6655f feat: rate pill, fix snackbar placement
close #886
2026-04-18 08:37:43 +02:00
edde746 d2b01100a5 feat: force TV mode setting
close #885
2026-04-17 19:41:28 +02:00
edde746 47837edb01 fix: apply display matching on fullscreen enter 2026-04-17 15:03:50 +02:00
edde746 91057c12a4 feat: bold/italic subtitle toggles
close #883
2026-04-17 15:03:23 +02:00
edde746 66b32468eb fix: serialize file picker calls 2026-04-17 14:13:20 +02:00
edde746 1e62c6c756 feat: bandwidth limit modal on stream 500 2026-04-17 13:12:07 +02:00
edde746 f8132e7bdb fix: focus media detail info rows
close #758
2026-04-17 05:23:18 +02:00
edde746 dec2e1c9fc fix: add media3 hls dependency
close #881
2026-04-17 04:42:46 +02:00
edde746 ee20965218 fix: pin EPG titles, filter cloud channel list
close #614
2026-04-17 04:28:19 +02:00
edde746 2e429840d2 fix: fan out multi-airing EPG metadata
close #563
2026-04-17 03:36:27 +02:00
edde746 e63920e23b fix: native monitor-aware fullscreen on windows 2026-04-16 23:13:15 +02:00
edde746 11d2e40e34 fix: downsample offline hero art and clear logo 2026-04-16 22:50:58 +02:00
edde746 ddb4a91b08 fix: downsample local artwork in offline grids 2026-04-16 22:44:01 +02:00
edde746 157669bd76 feat: folder view thumbnails and rich metadata
close #821
2026-04-16 17:06:07 +02:00
edde746 f055759c7a fix: suppress position sync when app backgrounded 2026-04-16 15:43:08 +02:00
edde746 34815f22ee fix: watch together server races, reconnect readiness, session end 2026-04-16 15:24:44 +02:00
edde746 1d3ae3e1c5 fix: watch together bugs and server write serialization 2026-04-16 14:00:24 +02:00
edde746 3cf1ebc09b fix: watch together room re-join and participant discovery
close #875
2026-04-16 13:17:24 +02:00
edde746 360741674d feat: sync rules for downloads
close #867
2026-04-16 12:52:48 +02:00
edde746 c369f2c3d7 refactor: remove dead code across codebase 2026-04-16 08:14:42 +02:00
edde746 65e22fbbbd refactor: remove dead download code 2026-04-16 07:27:05 +02:00
edde746 74b2d76fea refactor: remove dead SAF copy methods 2026-04-16 07:27:05 +02:00
edde746 6c61e0e4c9 feat: namedTimeout for Sentry context 2026-04-16 07:27:05 +02:00
edde746 a60e1e7f42 feat: ExoPlayer audio-delay and sub-delay support
close #869
2026-04-16 06:44:46 +02:00
edde746 1b1c4b741b filter Windows disk-full errors from Sentry 2026-04-16 06:25:24 +02:00
edde746 f9d8549e56 fix: clamp tooltip upper bound to prevent ArgumentError 2026-04-16 06:13:45 +02:00
edde746 bdd9d24d32 fix: fallback to IOClient on WinHttp init failure 2026-04-16 06:11:13 +02:00
edde746 a7ba837cb1 fix: prevent white screen when no servers available 2026-04-16 06:02:18 +02:00
edde746 d04781e4b3 fix: use baseline x86_64 mpv build for older CPU compat 2026-04-16 04:19:43 +02:00
edde746 f87f0b0807 fix: suppress spurious media pause during frame rate switch
close #871
2026-04-16 04:11:33 +02:00
edde746 59346f4b67 fix: restore playWhenReady after renderer recreation 2026-04-16 03:42:46 +02:00
edde746 06174af16c fix: ensure crashpad_handler has executable permissions in .deb
close #870
2026-04-15 23:25:00 +02:00
edde746 f1867ddeb7 fix: preserve PiP during episode auto-play
close #865
2026-04-15 23:16:04 +02:00
edde746 79f7de1b4c chore: regenerate podfile locks 2026-04-15 17:53:46 +02:00
edde746 15de88463c fix: disable native HTTP error capturing in sentry 2026-04-15 17:51:28 +02:00
github-actions[bot] c032d97c5f chore: update cask to 1.33.1 2026-04-15 10:20:55 +00:00
github-actions[bot] 2eca47ab69 chore: bump version to 1.33.1 2026-04-15 10:02:27 +00:00
edde746 caa70dd460 fix: remove camera permission and hasCamera check 2026-04-15 12:01:55 +02:00
github-actions[bot] 6b0e65b563 chore: bump version to 1.33.0 2026-04-15 09:28:37 +00:00
edde746 e0308be8bb fix: mounted check in _toggleLibraryVisibility 2026-04-15 11:04:16 +02:00
edde746 4663c435c9 fix: artwork directory null crash on orientation change 2026-04-15 06:11:07 +02:00
edde746 8380980abd fix: update background_downloader fork ref 2026-04-15 00:32:18 +02:00
edde746 771c431188 fix: treat 500 as permanent failure, show error in UI 2026-04-14 22:29:05 +02:00
edde746 1e1bb9176a feat: watch together buffering indicators 2026-04-14 22:07:13 +02:00
edde746 6eb5c847af fix: download cleanup, retry circuit breaker, SAF resume 2026-04-14 21:54:00 +02:00
edde746 e50fdc4eb9 feat: circular dpad and filled nav chips on remote screen 2026-04-14 13:29:42 +02:00
edde746 4edf1a8c42 fix: auto-start remote server after home data loads 2026-04-14 13:03:34 +02:00
edde746 cb455d379c feat: inline discovery in remote screen 2026-04-14 12:58:41 +02:00
edde746 d093023ce7 feat: replace QR remote pairing with encrypted LAN discovery
Replace session ID + PIN auth with HKDF-derived home secret and
per-session AES-256-GCM encrypted channel. Auto-start remote server
on host devices, auto-discover hosts via authenticated UDP broadcast.
2026-04-14 12:33:40 +02:00
edde746 a55650bc40 fix: skip QR scanner on devices without cameras 2026-04-14 06:53:51 +02:00
edde746 6cbf0980c5 fix: filter HTTPClientError and DiscordStateException from sentry 2026-04-14 06:43:53 +02:00
edde746 4cb65c1bfa feat: mouse back button navigation 2026-04-14 06:30:18 +02:00
edde746 f30e47dbd7 fix: enforce min 2x DPR for TV image transcoding
close #860
2026-04-14 05:55:43 +02:00
edde746 7ab6b9720f feat: increase subtitle sync slider range to ±60s
close #851
2026-04-14 04:24:53 +02:00
edde746 2f7c87d437 fix: subtitle background not rendering on mpv 0.38+
close #773
2026-04-13 23:59:47 +02:00
edde746 bc4b4955da feat: auto-remove watched downloads, server watched threshold
close #856
2026-04-13 22:46:49 +02:00
edde746 f7e3408c86 fix: guard empty list access in alpha jump bar and download tree 2026-04-13 22:07:21 +02:00
edde746 2126a38f39 fix: bounds-safe action bar focus node access 2026-04-13 22:00:36 +02:00
edde746 b81f6eaa96 feat: watch together recent rooms, plex usernames, action toasts, shorter codes
close #855
2026-04-13 14:02:21 +02:00
edde746 17eb37cb37 fix: back navigation on screens with no app bar actions
close #854
2026-04-13 11:33:15 +02:00
github-actions[bot] 9208aa8d16 chore: update cask to 1.32.0 2026-04-13 06:35:14 +00:00
github-actions[bot] bd7f7894ed chore: bump version to 1.32.0 2026-04-13 06:03:17 +00:00
edde746 6dfdb62df8 feat: relay connection limits and container memory caps 2026-04-13 07:55:28 +02:00
edde746 3be302d69f feat: set sentry environment per distribution channel 2026-04-13 02:38:28 +02:00
edde746 66ebc547f0 fix: filter disk-full and dbus socket errors from sentry 2026-04-13 02:05:24 +02:00
edde746 3366b83a89 fix: hide spoilers in watch next thumbnails
close #849
2026-04-12 11:12:37 +02:00
edde746 cd3db94b5d fix: guard library dropdown against empty list 2026-04-12 10:14:43 +02:00
edde746 d56067e7cc fix: skip back navigation when overlay route is on top 2026-04-12 10:11:11 +02:00
edde746 7c3cee692c fix: one-shot select key activation in focus helpers 2026-04-12 10:00:26 +02:00
edde746 a948d82c24 switch from glitchtip 2026-04-12 09:11:42 +02:00
edde746 77893bb9ba fix: detail screen section alignment 2026-04-12 01:37:08 +02:00
edde746 1b2ecd9de4 fix: standardize bool parsing with flexibleBool helper
close #844
2026-04-11 21:22:15 +02:00
edde746andGitHub b92842f9ac Merge pull request #842 from jordankoehn/fix-live-tv-join-session-edge-case
Fix Live TV join session edge case
2026-04-11 08:09:51 +02:00
edde746 91d191b3d8 fix: file info bitrate units, multi-track display, tv scrolling
close #840, close #841
2026-04-11 08:04:12 +02:00
Jordan Koehn 21accaa883 Fix Live TV join session edge case
Under the circumstance that guide data is not available, program start
is equal to current time. So program start should be ignored so one can
tune to the oldest available recording offset.
2026-04-10 22:34:32 -04:00
edde746 c6f2e949a7 fix: use configured skip setting for dpad/timeline seeking
close #751, close #790
2026-04-10 22:31:41 +02:00
edde746 9d9d9bb311 feat: add square art and logo editing to metadata editor
close #837
2026-04-10 18:38:54 +02:00
edde746 0b20300572 fix: noisy audio pause on android tv 2026-04-10 10:24:19 +02:00
edde746 f47bcb69fd feat: auto-hide performance overlay with controls
close #834
2026-04-10 01:30:26 +02:00
edde746 c20bb93f1f fix: always show track selection button
close #833
2026-04-09 23:52:02 +02:00
edde746 497259ca16 fix: close http clients on server replacement 2026-04-09 23:34:01 +02:00
edde746 4322ffc6e5 fix: disable android tv pip lifecycle regressions 2026-04-09 06:39:17 +02:00
edde746 8b40e0993f fix: normalize EOF playback state
close #828
2026-04-09 05:55:03 +02:00
edde746 966b6371f5 fix: preserve download server affinity 2026-04-09 04:51:35 +02:00
edde746 5b493a70b0 feat: show snackbar on playback speed change
close #824
2026-04-09 03:48:14 +02:00
edde746 1cb793d781 feat: add sub-ass-override setting 2026-04-08 19:35:18 +02:00
edde746 7f237c9c7b refactor: reuse showTextInputDialog for episode count input 2026-04-08 18:08:39 +02:00
edde746 d0934295b0 feat: download all playlist items
close #241
2026-04-08 12:37:03 +02:00
edde746 8d40ba3011 fix: reduce blank space in media card bottom 2026-04-08 07:21:21 +02:00
edde746 f926deb63b feat: clickable cast members to browse actor filmography
close #723
2026-04-08 05:17:37 +02:00
edde746 b82c612f94 fix: remove unused parameter in focusable_slider 2026-04-08 04:40:12 +02:00
edde746 e7ae108c49 chore: bump win_http to 0.2.0 2026-04-08 04:24:06 +02:00
edde746 08689df2f5 chore: update Podfile.lock 2026-04-08 01:58:26 +02:00
edde746 87c308f6ca feat: use win_http on Windows 2026-04-08 01:54:40 +02:00
edde746 15b22f75a8 refactor: replace dio with package:http 2026-04-08 00:43:08 +02:00
edde746 8e7431e7e5 feat: download options picker for series/seasons 2026-04-07 22:59:35 +02:00
edde746 c034f9b66e fix: request utf-8 encoding for external subtitles
close #817
2026-04-07 18:34:47 +02:00
edde746 5cf53f8cfa fix: chapter keyboard shortcuts navigate chapters not episodes
close #820
2026-04-07 17:58:00 +02:00
edde746 e4de7f1ecb fix(tv): reduce live stream reconnect delay for faster fallback 2026-04-07 15:37:36 +02:00
edde746 bf9913ba0f fix(tv): re-tune on live stream fallback, fix decision 400 throw 2026-04-07 10:52:54 +02:00
edde746 6ac8c905c9 fix(tv): live stream direct-stream fallback chain 2026-04-07 07:58:42 +02:00
edde746 603b5b5105 fix: pip aspect ratio clamp values outside android limits 2026-04-07 07:35:03 +02:00
edde746 7528dcec71 fix: connection selection failover and timeout budget 2026-04-07 06:49:51 +02:00
github-actions[bot] d96848181d chore: update cask to 1.31.3 2026-04-07 04:28:16 +00:00
github-actions[bot] 16b6ea8f29 chore: bump version to 1.31.3 2026-04-07 04:11:07 +00:00
edde746 7150ea2987 fix: log reason for https upgrade failure 2026-04-07 05:48:44 +02:00
edde746 bc2febf480 fix: prefer https endpoints, catch h2 protocol errors 2026-04-07 05:48:44 +02:00
github-actions[bot] 11ec48a506 chore: update cask to 1.31.2 2026-04-07 02:40:18 +00:00
github-actions[bot] f08989cc99 chore: bump version to 1.31.2 2026-04-07 02:21:14 +00:00
edde746 424351092d fix: h2 fallback to http/1.1 on handshake failure 2026-04-07 04:20:41 +02:00
edde746 df72c3e6f4 chore: bump build number 2026-04-07 02:11:40 +02:00
edde746 82e605a1c6 fix: don't close database on mobile suspend 2026-04-07 02:11:09 +02:00
github-actions[bot] 7a91675054 chore: update cask to 1.31.1 2026-04-06 22:43:34 +00:00
github-actions[bot] edf4eb2090 chore: bump version to 1.31.1 2026-04-06 22:20:12 +00:00
edde746 de1b37b7e9 fix: library density migration crash on upgrade
close #812
2026-04-07 00:19:27 +02:00
edde746 928206a179 fix: log image load failures with rate limiting 2026-04-06 22:53:13 +02:00
edde746 52b8944d71 feat: use HTTP/2 for all network requests 2026-04-06 22:53:13 +02:00
github-actions[bot] 0db5cb7025 chore: update cask to 1.31.0 2026-04-06 20:51:22 +00:00
edde746 cef8f5efe2 fix: use sentry-dart fork to fix Windows build 2026-04-06 22:31:37 +02:00
github-actions[bot] 83ff87c79f chore: bump version to 1.31.0 2026-04-06 14:50:41 +00:00
edde746 00f5357403 rename "Episode Thumbnail" to "Thumbnail" in all translations 2026-04-06 16:45:12 +02:00
edde746 010ace58fa fix: dispose TextEditingControllers in settings dialogs 2026-04-06 16:43:04 +02:00
edde746 b3cf4ebf12 fix: await hidden libraries init before loading content 2026-04-06 16:19:23 +02:00
edde746 f707b7b5f2 Merge branch 'fix-live-tv-never-prompt-to-watch-from-start-of-recording' 2026-04-06 04:12:44 +02:00
edde746 29ab2ddad3 refactor: fix variable naming, indentation, remove debug log 2026-04-06 04:12:33 +02:00
edde746 627024bd13 fix: reduce spacing 2026-04-06 03:02:36 +02:00
edde746andGitHub c4a71048f5 Merge pull request #808 from eConnah/update-readme-nix
docs: update Nix package link to official search and add new listed co-maintainer
2026-04-06 02:45:18 +02:00
Connor Alecks bd07b7f011 docs: update Nix package link to official search and add new listed co-maintainer 2026-04-06 00:54:37 +02:00
Jordan Koehn 93496849ec fix: Live TV uses wrong begins at time 2026-04-05 11:54:27 -04:00
edde746 9984f42176 fix: tabular figures on timeline timestamps 2026-04-04 23:46:43 +02:00
edde746 a262c94f88 refactor: extract FocusableSlider widget 2026-04-04 19:22:23 +02:00
edde746 8f326e692d fix: dpad nav trapping in density slider 2026-04-04 16:30:13 +02:00
edde746 7b8a47aac1 fix: include previous episodes in play queue 2026-04-04 16:05:44 +02:00
edde746 b46f1cccb1 remove chapter_marker_painter 2026-04-04 01:36:03 +02:00
edde746 5880451401 fix: try DV conversion before MPV fallback on decoder hang 2026-04-04 00:19:10 +02:00
edde746 3c59452782 fix: subtitle track matching (forced flag parsing, title scoring) 2026-04-03 23:53:37 +02:00
edde746 7d35132509 feat: M3 2024 slider style for video player timelines
Use GappedTrackShape (no stop indicator) globally.
Timeline/volume sliders get local overrides for thinner
track, zero padding, and no overlay. Chapter markers
rendered as track gaps. Live TV timeline matches style.
Timeline thumb hidden when unfocused in dpad mode.
2026-04-03 23:14:11 +02:00
edde746 dbb3e14c50 fix: skip offline video when different version requested 2026-04-03 17:50:36 +02:00
edde746 d1d45c4998 refactor: restyle subtitle styling and external player settings screens 2026-04-03 17:25:02 +02:00
edde746 c9db6e528e feat: more consistent poster sizing
close #798
2026-04-03 17:04:20 +02:00
edde746 062fd82de1 fix: recover audio after Windows sleep via ao-null-reload 2026-04-03 12:19:36 +02:00
edde746 122cff359d fix: await database close on desktop app exit 2026-04-03 12:19:29 +02:00
edde746 ef96ccb623 fix: ordinal tiebreaker for identical track matching 2026-04-03 00:46:01 +02:00
edde746 8a66cf6d92 fix: add HID key labels for non-keyboard USB pages
close #793
2026-04-02 22:04:26 +02:00
edde746 16dd352231 fix: hide live badge when timeshifted
close #796
2026-04-02 17:35:52 +02:00
edde746 fb275ea242 refactor: replace iso 639 json asset with const dart map 2026-04-02 17:07:45 +02:00
edde746 533472299a feat: add search filter to collection selection dialog
close #784
2026-04-02 16:38:37 +02:00
edde746 da95901d39 fix: Windows native code bugs and dedup
- fix unsigned underflow in Utf8FromUtf16 when WideCharToMultiByte fails
- fix inverted freopen_s logic in console attach
- remove misleading WS_EX_APPWINDOW from GWL_STYLE mask
- close mutex handle on normal exit
- extract SetHDRStateForTarget, EnableComposition/DisableComposition, DisplayModeToMap helpers
- cache GetWindowsVersion result
2026-04-02 15:02:07 +02:00
edde746 29101c310b fix: clean up power-resume timer on dispose and destroy 2026-04-02 14:42:14 +02:00
edde746 91db6d0e57 fix: reload audio output on Windows wake from sleep 2026-04-02 14:36:50 +02:00
edde746 504da89223 fix: focus search input on tab selection
close #795
2026-04-02 14:18:02 +02:00
edde746 4a54b897ac fix: guard sdk_26100.h for newer Windows SDKs 2026-04-02 13:22:27 +02:00
edde746 9565682eda feat: Windows display mode matching (refresh rate, HDR) 2026-04-02 13:05:58 +02:00
edde746 17741a2f61 fix: live timeline focus style 2026-04-02 02:40:13 +02:00
edde746 f5407ecac8 fix: enable PiP and crop/fill for live TV 2026-04-02 02:38:16 +02:00
edde746 c57cbfb2f6 fix: live TV time-shift controls and timeline 2026-04-02 02:29:04 +02:00
edde746 339a767304 refactor: M3 settings screen redesign 2026-04-02 00:33:25 +02:00
edde746 4c534b44d4 fix: consistent borderless text input styling 2026-04-01 23:10:40 +02:00
edde746 984125a79e fix: subtitle search UI polish and keyboard input 2026-04-01 22:36:51 +02:00
edde746 17f1075e64 feat: subtitle search & download from OpenSubtitles
close #343
2026-04-01 22:20:59 +02:00
edde746 64a5874566 fix: close database on app shutdown 2026-04-01 20:30:05 +02:00
edde746 b16a6a6bd5 fix: companion remote acts as remote instead of host on Android TV 2026-04-01 16:23:31 +02:00
edde746 87da5b400e chore: remove tune debug logging 2026-04-01 16:05:36 +02:00
edde746 3f1da5f289 feat: live TV time-shift 2026-04-01 12:20:07 +02:00
edde746 45525a4f14 feat: live TV favorite channel ordering
close #780
2026-04-01 06:32:27 +02:00
edde746 fc7892daed fix: option picker dialog focus for TV/keyboard nav 2026-04-01 06:30:08 +02:00
edde746 51a49cfca3 fix: disable Impeller on Huawei/HONOR devices 2026-03-31 14:16:01 +02:00
edde746 da8b583286 feat: tag editing in metadata editor 2026-03-31 14:10:17 +02:00
edde746 2c12a07257 fix: reuse AudioTrack across seeks, reduce PCM buffer 2026-03-31 12:39:10 +02:00
edde746 3a82119c2a fix: library action confirmation and snackbar feedback 2026-03-31 10:37:10 +02:00
edde746 e6ea1b056a fix: overlay sheet builder context popping main route
close #779
2026-03-31 10:30:25 +02:00
edde746 1a96d2b418 feat: download version picker with smart series matching
close #767
2026-03-31 04:46:09 +02:00
edde746 8eb716505d fix: mpv dispose ANR on main thread 2026-03-30 21:42:11 +02:00
edde746 ddb157809a feat: play version context menu option 2026-03-30 08:03:39 +02:00
edde746 ad8faab9ea fix: android video surface lifecycle management
close #771
2026-03-29 18:07:50 +02:00
edde746 6a7644935f chore: remove steam scripts 2026-03-28 20:16:48 +01:00
edde746 684196aa36 perf: type-aware image memory cache bounds 2026-03-28 18:31:16 +01:00
edde746 56697303f9 fix: async mpv dispose on macOS 2026-03-28 17:55:34 +01:00
edde746 3306a28892 fix: isolate libdovi from JVM heap, reuse DV buffers 2026-03-28 17:38:11 +01:00
edde746 059509354d feat: configurable watch together relay server 2026-03-28 14:44:30 +01:00
edde746 53d50dbc3b fix: guard scroll operations against infinite extents 2026-03-28 13:51:39 +01:00
edde746 46ebda944a fix: flexible int parsing in PlexRole 2026-03-28 13:44:57 +01:00
edde746 0bb200a785 feat: play from beginning context menu option
close #761
2026-03-27 17:43:15 +01:00
edde746 d3602201c6 refactor: remove dead code 2026-03-27 17:39:43 +01:00
edde746 beb78fada0 fix: eager artwork dir init, guard back nav on root 2026-03-27 11:06:22 +01:00
edde746 a05efef3c8 fix: guard download action button focus node access 2026-03-27 10:39:50 +01:00
edde746 36d63860e2 chore: update pubspec.lock 2026-03-27 09:39:50 +01:00
edde746 ad9be7f132 Merge branch 'feature/server-tasks' 2026-03-27 09:38:24 +01:00
edde746 8e9968628a fix: server tasks panel polish 2026-03-27 09:37:53 +01:00
edde746 e33db4f6d7 fix: auto-focus skip intro button on TV when controls visible
close #742
2026-03-26 20:33:23 +01:00
edde746 ab5ad3c839 fix: use hubs API for continue watching
close #748
2026-03-26 18:58:18 +01:00
github-actions[bot] fa4af97fb0 chore: update cask to 1.30.0 2026-03-26 10:03:52 +00:00
Micah Morrison 25e3c4a543 Show server tasks 2026-03-25 11:33:01 -04:00
1182 changed files with 242081 additions and 48667 deletions
+9
View File
@@ -0,0 +1,9 @@
BasedOnStyle: Google
AlignAfterOpenBracket: AlwaysBreak
ColumnLimit: 120
IndentWidth: 2
ContinuationIndentWidth: 4
DerivePointerAlignment: false
LineEnding: LF
PointerAlignment: Left
SortIncludes: true
-19
View File
@@ -1,19 +0,0 @@
.git
build/
.dart_tool/
.idea/
*.tar.gz
nul
libmpv-prefix/
steamfiles/
pubspec_overrides.yaml
# Flutter ephemeral / generated plugin symlinks
linux/flutter/ephemeral/
windows/flutter/
macos/Flutter/
ios/Flutter/
android/.gradle/
android/app/build/
.flutter-plugins
.flutter-plugins-dependencies
+28
View File
@@ -0,0 +1,28 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{c,cc,cpp,h,hpp,m,mm}]
indent_style = space
indent_size = 2
[*.dart]
indent_style = space
indent_size = 2
[*.{kt,kts,java}]
indent_style = space
indent_size = 2
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ktlint_code_style = android_studio
ktlint_standard_max-line-length = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_property-naming = disabled
[*.swift]
indent_style = space
indent_size = 2
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -uo pipefail
if [ "${SKIP_HOOKS:-}" = "1" ]; then
echo "[pre-commit] skipped (SKIP_HOOKS=1)"
exit 0
fi
GIT_DIR="$(git rev-parse --git-dir)"
if [ -f "$GIT_DIR/MERGE_MSG" ] || [ -f "$GIT_DIR/MERGE_HEAD" ] || [ -f "$GIT_DIR/REVERT_HEAD" ]; then
echo "[pre-commit] skipped (merge/revert in progress)"
exit 0
fi
if git diff --cached --quiet; then
echo "[pre-commit] nothing staged — skipping checks"
exit 0
fi
ROOT="$(git rev-parse --show-toplevel)"
exec "$ROOT/scripts/ci_checks.sh"
+1
View File
@@ -1 +1,2 @@
github: edde746
liberapay: edde746
+15
View File
@@ -68,16 +68,31 @@ body:
- Windows
- macOS
- Android TV
- tvOS
- Linux
validations:
required: true
- type: dropdown
id: media-server
attributes:
label: Media Server
description: Are you using Jellyfin or Plex?
multiple: true
options:
- Jellyfin
- Plex
validations:
required: true
- type: input
id: device
attributes:
label: Device Information
description: What device are you using?
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
validations:
required: true
- type: textarea
id: logs
@@ -41,6 +41,8 @@ body:
1. As a user, I would...
2. This would help me...
3. The benefit would be...
validations:
required: true
- type: dropdown
id: platform
@@ -54,8 +56,23 @@ body:
- Windows
- macOS
- Android TV
- tvOS
- Linux
- All platforms
validations:
required: true
- type: dropdown
id: media-server
attributes:
label: Media Server
description: Which media server(s) is this for?
multiple: true
options:
- Jellyfin
- Plex
validations:
required: true
- type: textarea
id: additional
+259 -56
View File
@@ -2,13 +2,45 @@ name: Build
on:
workflow_dispatch:
inputs:
build_android:
description: Build Android
default: true
type: boolean
build_ios:
description: Build iOS
default: true
type: boolean
build_macos:
description: Build macOS
default: true
type: boolean
build_windows:
description: Build Windows
default: true
type: boolean
build_linux:
description: Build Linux
default: true
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
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
libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev
libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev
libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev
libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
jobs:
build-android:
if: ${{ inputs.build_android }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -27,16 +59,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache Gradle
uses: actions/cache@v4
@@ -49,7 +81,7 @@ jobs:
${{ runner.os }}-gradle-
- name: Install dependencies
run: flutter pub get
run: flutter pub get --enforce-lockfile --no-example
- name: Configure Android signing
run: |
@@ -62,7 +94,14 @@ 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 }}
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
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-android-apk
run: ./scripts/upload-symbols.sh android-apk
- name: Clean up keystore
if: always()
@@ -92,6 +131,7 @@ jobs:
plezy-android-x86_64.tar.gz
build-ios:
if: ${{ inputs.build_ios }}
runs-on: macos-26
permissions:
id-token: write
@@ -104,16 +144,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache CocoaPods
uses: actions/cache@v4
@@ -127,10 +167,17 @@ jobs:
${{ runner.os }}-pods-
- name: Install dependencies
run: flutter pub get
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 }}
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
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-ios
run: ./scripts/upload-symbols.sh ios
- name: Create IPA
run: |
@@ -150,6 +197,7 @@ jobs:
path: plezy-ios.ipa
build-macos:
if: ${{ inputs.build_macos }}
runs-on: macos-26
permissions:
id-token: write
@@ -162,16 +210,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache CocoaPods
uses: actions/cache@v4
@@ -185,10 +233,17 @@ jobs:
${{ runner.os }}-macos-pods-
- name: Install dependencies
run: flutter pub get
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 }}
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
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-macos
run: ./scripts/upload-symbols.sh macos
- name: Import Code Signing Certificate
env:
@@ -244,6 +299,11 @@ jobs:
APP_PATH="build/macos/Build/Products/Release/Plezy.app"
echo "Verifying signature..."
codesign -dvvv "$APP_PATH"
echo "Verifying signed entitlements..."
codesign -d --entitlements :- "$APP_PATH" > "$RUNNER_TEMP/macos-entitlements.plist"
/usr/libexec/PlistBuddy -c "Print" "$RUNNER_TEMP/macos-entitlements.plist"
/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.allow-jit" "$RUNNER_TEMP/macos-entitlements.plist"
/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.allow-unsigned-executable-memory" "$RUNNER_TEMP/macos-entitlements.plist"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
- name: Notarize Application
@@ -314,6 +374,7 @@ jobs:
macos-dmg-size.txt
build-windows-x64:
if: ${{ inputs.build_windows }}
runs-on: windows-latest
permissions:
contents: read
@@ -324,22 +385,36 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~\AppData\Local\Pub\Cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache Windows native dependencies
uses: actions/cache@v4
with:
path: build/windows/x64/_deps
key: windows-native-x64-${{ hashFiles('windows/CMakeLists.txt') }}
- name: Install dependencies
run: flutter pub get
run: flutter pub get --enforce-lockfile --no-example
- name: Build Windows x64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
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-x64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-x64
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
shell: pwsh
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-windows-x64
run: .\scripts\upload-symbols.ps1 windows-x64
- name: Upload x64 build
uses: actions/upload-artifact@v4
@@ -348,35 +423,54 @@ jobs:
path: build/windows/x64/runner/Release/
build-windows-arm64:
if: ${{ inputs.build_windows }}
runs-on: windows-11-arm
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Cache Windows native dependencies
id: windows-native-cache
uses: actions/cache@v4
with:
path: |
build/windows/arm64/_deps
build/windows/arm64/mpv-dev-arm64
key: windows-native-arm64-${{ hashFiles('windows/CMakeLists.txt') }}
- name: Install 7-Zip
if: steps.windows-native-cache.outputs.cache-hit != 'true'
run: choco install 7zip -y
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "master"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~\AppData\Local\Pub\Cache
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-arm64-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Install dependencies
run: flutter pub get
run: flutter pub get --no-example
- name: Build Windows arm64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
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-arm64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-arm64
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
shell: pwsh
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-windows-arm64
run: .\scripts\upload-symbols.ps1 windows-arm64
- name: Upload arm64 build
uses: actions/upload-artifact@v4
@@ -386,6 +480,7 @@ jobs:
package-windows:
needs: [build-windows-x64, build-windows-arm64]
if: ${{ inputs.build_windows }}
runs-on: windows-latest
permissions:
id-token: write
@@ -468,6 +563,7 @@ jobs:
win-installer-size.txt
build-linux-x64:
if: ${{ inputs.build_linux }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -480,27 +576,38 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
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 libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
packages: ${{ env.LINUX_APT_PACKAGES }}
version: 1.1
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y 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 libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
read -r -a packages <<< "$LINUX_APT_PACKAGES"
for attempt in 1 2 3; do
if sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y -o Acquire::Retries=3 "${packages[@]}"; then
exit 0
fi
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
sleep $((attempt * 15))
done
exit 1
- name: Cache libmpv build
id: libmpv-cache
@@ -517,13 +624,20 @@ jobs:
run: sudo gem install fpm
- name: Install dependencies
run: flutter pub get
run: flutter pub get --enforce-lockfile --no-example
- name: Build Linux x64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
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-x64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-x64
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-linux-x64
run: ./scripts/upload-symbols.sh linux-x64
- name: Build Linux Packages
run: ARCH_SUFFIX=x64 OUTPUT_DIR=$GITHUB_WORKSPACE python3 linux/packaging/build-packages.py
@@ -576,6 +690,7 @@ jobs:
plezy-linux-x64.pkg.tar.zst
build-linux-arm64:
if: ${{ inputs.build_linux }}
runs-on: ubuntu-24.04-arm
permissions:
id-token: write
@@ -588,27 +703,38 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "master"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-arm64-pub-
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
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 libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
packages: ${{ env.LINUX_APT_PACKAGES }}
version: 1.1
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y 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 libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
read -r -a packages <<< "$LINUX_APT_PACKAGES"
for attempt in 1 2 3; do
if sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y -o Acquire::Retries=3 "${packages[@]}"; then
exit 0
fi
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
sleep $((attempt * 15))
done
exit 1
- name: Cache libmpv build
id: libmpv-cache
@@ -625,13 +751,20 @@ jobs:
run: sudo gem install fpm
- name: Install dependencies
run: flutter pub get
run: flutter pub get --no-example
- name: Build Linux arm64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
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-arm64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-arm64
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/aarch64-linux-gnu/pkgconfig
- name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy'
env:
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
SENTRY_DIST: github-linux-arm64
run: ./scripts/upload-symbols.sh linux-arm64
- name: Find bundle directory
id: find-bundle
run: |
@@ -694,7 +827,8 @@ jobs:
plezy-linux-arm64.pkg.tar.zst
create-release:
needs: [build-android, build-ios, build-macos, package-windows, build-linux-x64, build-linux-arm64]
needs: [build-android, build-ios, build-macos, build-windows-x64, build-windows-arm64, package-windows, build-linux-x64, build-linux-arm64]
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) }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -704,10 +838,61 @@ jobs:
sparse-checkout: pubspec.yaml
sparse-checkout-cone-mode: false
- name: Download all artifacts
- name: Download Android artifacts
if: ${{ inputs.build_android }}
uses: actions/download-artifact@v4
with:
path: artifacts
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
@@ -790,21 +975,39 @@ jobs:
echo "Generated appcast.xml:"
cat appcast.xml
- name: Collect release files
id: release-files
run: |
{
echo 'files<<EOF'
if [ "${{ inputs.build_android }}" = "true" ]; then
echo artifacts/android-apk/plezy-android-arm64-v8a.tar.gz
echo artifacts/android-apk/plezy-android-armeabi-v7a.tar.gz
echo artifacts/android-apk/plezy-android-x86_64.tar.gz
fi
if [ "${{ inputs.build_ios }}" = "true" ]; then
echo artifacts/ios-ipa/plezy-ios.ipa
fi
if [ "${{ inputs.build_macos }}" = "true" ]; then
echo artifacts/macos-dmg/plezy-macos.dmg
fi
if [ "${{ inputs.build_windows }}" = "true" ]; then
echo artifacts/windows-x64-portable/plezy-windows-x64-portable.7z
echo artifacts/windows-arm64-portable/plezy-windows-arm64-portable.7z
echo artifacts/windows-installer/plezy-windows-installer.exe
fi
if [ "${{ inputs.build_linux }}" = "true" ]; then
echo 'artifacts/linux-x64/*'
echo 'artifacts/linux-arm64/*'
fi
echo appcast.xml
echo EOF
} >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/android-apk/plezy-android-arm64-v8a.tar.gz
artifacts/android-apk/plezy-android-armeabi-v7a.tar.gz
artifacts/android-apk/plezy-android-x86_64.tar.gz
artifacts/ios-ipa/plezy-ios.ipa
artifacts/macos-dmg/plezy-macos.dmg
artifacts/windows-x64-portable/plezy-windows-x64-portable.7z
artifacts/windows-arm64-portable/plezy-windows-arm64-portable.7z
artifacts/windows-installer/plezy-windows-installer.exe
artifacts/linux-x64/*
artifacts/linux-arm64/*
appcast.xml
files: ${{ steps.release-files.outputs.files }}
draft: true
prerelease: false
generate_release_notes: true
+36 -9
View File
@@ -23,22 +23,31 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Install dependencies
run: |
flutter pub get
- name: Verify generated files committed
run: |
scripts/codegen.sh
if ! git diff --exit-code lib/; then
echo "::error::Generated files (.g.dart / .freezed.dart) are out of date."
echo "Run 'scripts/codegen.sh' and commit the result."
exit 1
fi
- name: Verify formatting
run: |
# Find all Dart files excluding generated files
@@ -106,16 +115,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Install dependencies
run: |
@@ -130,6 +139,24 @@ jobs:
echo "No tests found, skipping test execution"
fi
native-format:
name: Native Formatting
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: Verify native formatting
run: scripts/format_native.sh --check
dependency-check:
name: Dependency Validation
runs-on: ubuntu-latest
@@ -143,16 +170,16 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-v2-
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Verify dependencies
run: |
-27
View File
@@ -1,27 +0,0 @@
name: SonarQube
on:
workflow_dispatch:
jobs:
sonarqube:
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Install dependencies
run: flutter pub get
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+16
View File
@@ -34,6 +34,20 @@ migrate_working_dir/
/build/
/debug-info/
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
tvos/build/
tvos/Pods/
tvos/Podfile.lock
tvos/Runner.xcworkspace/xcuserdata/
tvos/Runner.xcodeproj/xcuserdata/
tvos/Runner.xcodeproj/project.xcworkspace/xcuserdata/
tvos/Flutter/Generated.xcconfig
tvos/Flutter/flutter_export_environment.sh
tvos/Flutter/libPods-Runner.a
tvos/tvos_flutter_assets/
tvos/Runner/GeneratedPluginRegistrant.h
tvos/Runner/GeneratedPluginRegistrant.m
# Symbolication related
app.*.symbols
@@ -55,3 +69,5 @@ duplication-report/
**/fastlane/**/
**/fastlane/report.xml
maestro/**/*.png
server/deploy.sh
-21
View File
@@ -1,21 +0,0 @@
{
"threshold": 5,
"reporters": ["html", "console"],
"ignore": [
"**/*.g.dart",
"**/*.freezed.dart",
"**/*.mocks.dart",
"**/generated/**",
"**/*.config.dart"
],
"absolute": true,
"gitignore": true,
"format": ["dart"],
"minLines": 5,
"minTokens": 50,
"output": "./duplication-report",
"mode": "strict",
"formatsExts": {
"dart": ["dart"]
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"indentation" : {
"spaces" : 2
},
"lineLength" : 120,
"maximumBlankLines" : 1,
"respectsExistingLineBreaks" : true,
"rules" : {},
"version" : 1
}
+5 -3
View File
@@ -10,7 +10,8 @@
## Development
- Follow Dart/Flutter conventions
- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks)
- 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
- Test your changes thoroughly
@@ -19,8 +20,9 @@
The project includes automated CI checks that run on all pull requests:
1. **Code Formatting**: Ensures code follows Dart formatting standards
- Run locally: `dart format .` to format all files
1. **Code Formatting**: Ensures code follows Dart and native formatting standards
- Run locally: `dart format .` to format Dart files
- Run locally: `scripts/format_native.sh --fix` to format native files
- Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`)
- Generated files are reformatted automatically by build tools
+3 -3
View File
@@ -1,10 +1,10 @@
cask "plezy" do
version "1.29.0"
sha256 "5f856275e7d5fe66f25238708a41e9f04fa4d327a932a16626b1424807b05866"
version "2.4.1"
sha256 "c0658bf43b793a04755a9beaedd27c05c897f2c72298cd5da6531463baf15e0c"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
desc "Modern Plex client built with Flutter"
desc "Modern Plex and Jellyfin client built with Flutter"
homepage "https://github.com/edde746/plezy"
livecheck do
+103 -39
View File
@@ -3,13 +3,19 @@
Plezy
</h1>
A modern Plex client for desktop and mobile. Built with Flutter for native performance and a clean interface.
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
<p align="center">
<img src="assets/screenshots/macos-home.png" alt="Plezy macOS Home Screen" width="800" />
<p>
<a href="https://plezy.app">Website</a> ·
<a href="https://plezy.app/#screenshots">Screenshots</a> ·
<a href="#download">Download</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="LICENSE">License</a>
</p>
*More screenshots in the [screenshots folder](assets/screenshots/#readme)*
<p align="center">
<img src="assets/readme-showcase.webp" alt="Plezy mobile screenshots" width="900" />
</p>
## Download
@@ -17,10 +23,16 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
<a href='https://www.amazon.com/gp/product/B0GK65CVS1'><img height='60' alt='Available at the Amazon App Store' src='./assets/amazon-badge.png'/></a>
- [Windows (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
- [macOS (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg)
- [Linux (x64, arm64)](https://github.com/edde746/plezy/releases/latest) - .deb, .rpm, .pkg.tar.zst, and portable tar.gz available
- [NixOS/Nix](https://github.com/mio-19/nurpkgs/tree/main/pkgs/plezy) - Community package by [@mio-19](https://github.com/mio-19)
| Platform | Download |
| --- | --- |
| Windows | [Installer (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe) · [Portable x64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-x64-portable.7z) · [Portable arm64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-arm64-portable.7z) |
| macOS | [DMG (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) |
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
Package managers:
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
- **Homebrew** (macOS):
```bash
brew tap edde746/plezy https://github.com/edde746/plezy
@@ -32,48 +44,74 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
```
- **WinGet** (Windows):
```bash
winget install edde746.plezy
winget install edde746.Plezy
```
## Features
### 🔐 Authentication
- Sign in with Plex
- Automatic server discovery and smart connection selection
- Persistent sessions with auto-login
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
- Libraries, collections, and playlists
- Discover hub — Continue Watching, Next Up, trending, and recommendations
- Cross-server search
- Filtering, sorting, and alphabetical jump navigation
- Extras — trailers, deleted scenes, behind-the-scenes
### 📚 Media Browsing
- Browse libraries with rich metadata
- Advanced search across all media
- Collections and playlists
### 🎬 Playback
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision (not Linux)
- Full ASS/SSA subtitle support
- Audio and subtitle preferences synced with Plex profile
- HDR and Dolby Vision[^1]
- Full ASS/SSA subtitles with customizable styling
- Online subtitle search & download[^2]
- Audio & subtitle choices remembered per title
- Progress sync and resume
- Auto-play next episode
- Auto-play next episode with skip intro / skip credits
- Chapter navigation with thumbnail scrub previews
- Playback speed, audio sync offset, sleep timer
- Ambient lighting and GLSL shader presets[^3]
- Picture-in-Picture[^4]
- Refresh-rate matching[^5]
- External player launch (VLC, MX Player, etc.)
### 📺 Live TV & DVR
- EPG guide grid
- Channel tuning
- DVR recording rules and scheduled recordings
- Multi-server DVR support
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
- Live TV channel browsing with favorites
- DVR support with EPG guide, recording rules, and scheduled recordings[^2]
- Multi-server Live TV support where available
### 📥 Downloads
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
- Download media for offline viewing
- Background downloads with queue management
- Background queue with pause / resume
- Sync rules for automatic downloads
- Offline browsing with watch state sync-back on reconnect
### 👥 Watch Together
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
- Synchronized playback with friends
- Real-time play/pause and seek sync
- Real-time play / pause / seek sync
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
- Discord Rich Presence[^7]
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
- Plezy Remote — control desktop and TV from mobile
- Watch Next row[^6]
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Customizable keyboard shortcuts[^7]
- Metadata and artwork editing[^2]
- Settings import/export
- Localized in English plus 14 translations
[^1]: Not available on Linux.
[^2]: Plex only.
[^3]: Not available on iOS or tvOS.
[^4]: Android, iOS, and macOS.
[^5]: Windows, Android, and tvOS.
[^6]: Android TV only.
[^7]: Desktop only.
## Building from Source
### Prerequisites
- Flutter SDK 3.8.1+
- A Plex account with server access
- Flutter SDK 3.38.4+
- A Plex account or Jellyfin server with user credentials
### Setup
@@ -81,20 +119,46 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
git clone https://github.com/edde746/plezy.git
cd plezy
flutter pub get
dart run build_runner build
scripts/codegen.sh
flutter run
```
### Code Generation
After modifying model classes:
After modifying model classes or other generated sources:
```bash
dart run build_runner build --delete-conflicting-outputs
scripts/codegen.sh
```
After modifying translations:
```bash
dart run slang
```
### Local Checks
```bash
scripts/ci_checks.sh
```
To install the same pre-commit checks locally:
```bash
scripts/setup_hooks.sh
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
## License
Plezy is licensed under [GPL-3.0](LICENSE).
## Acknowledgments
- Built with [Flutter](https://flutter.dev)
- Designed for [Plex Media Server](https://www.plex.tv)
- Playback powered by [mpv](https://mpv.io) via [MPVKit](https://github.com/mpvkit/MPVKit) and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
+10
View File
@@ -7,6 +7,16 @@ analyzer:
plugins:
- dart_code_linter
linter:
rules:
unawaited_futures: true
cancel_subscriptions: true
close_sinks: true
use_super_parameters: true
prefer_final_locals: true
prefer_final_in_for_each: true
avoid_print: true
dart_code_linter:
extends:
- package:dart_code_linter/presets/recommended.yaml
+131 -131
View File
@@ -1,191 +1,191 @@
import java.util.Properties
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.0.6"
val mpvVersion = "v1.0.7"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val downloadLibmpv by tasks.registering {
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
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)
}
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
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)
}
}
val assVersion = "safety-2"
val assVersion = "fp-3"
val assDir = layout.buildDirectory.dir("libass").get().asFile
val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar")
val downloadLibass by tasks.registering {
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
}
stamp.writeText(assVersion)
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
}
stamp.writeText(assVersion)
}
}
val doviVersion = "2.3.1"
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
val doviAbis = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android",
"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 downloadLibdovi by tasks.registering {
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
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()
}
stamp.writeText(doviVersion)
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
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()
}
stamp.writeText(doviVersion)
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
defaultConfig {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
buildTypes {
release {
// Only use release signing if key.properties exists (not in CI/CD)
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
pickFirsts.add("lib/*/libc++_shared.so")
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Only use release signing if key.properties exists (not in CI/CD)
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
}
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
pickFirsts.add("lib/*/libc++_shared.so")
}
}
}
flutter {
source = "../.."
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
dependsOn(downloadLibdovi)
}
// Download libmpv and libass AARs before compilation
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
// 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")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
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")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
}
+5 -2
View File
@@ -6,12 +6,11 @@
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Camera permission for QR code scanning (companion remote pairing) -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.REORDER_TASKS"/>
<!-- Background download notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -23,6 +22,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<!-- Android TV support (not required, but allow detection) -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android TV Watch Next integration -->
@@ -59,6 +59,9 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+106 -65
View File
@@ -1,6 +1,9 @@
#include <jni.h>
#include <android/log.h>
#include <jni.h>
#include <cstring>
#include <new>
#include <vector>
#define TAG "DoviBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
@@ -11,95 +14,133 @@
#endif
static const char* BRIDGE_VERSION = "1.0.0";
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;
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
JNIEnv *env, jclass, jbyteArray payload, jint mode) {
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,
jint output_offset, jint output_capacity, jint mode) {
#if !DOVI_REAL_LINKED
return nullptr;
return CONVERT_FAILED;
#else
if (payload == nullptr) return nullptr;
if (payload == nullptr || output == nullptr) return CONVERT_FAILED;
if (payload_offset < 0 || payload_length <= 0 || output_offset < 0 || output_capacity < 0) {
return CONVERT_FAILED;
}
jsize len = env->GetArrayLength(payload);
if (len <= 0) return nullptr;
const jsize payload_array_len = env->GetArrayLength(payload);
const jsize output_array_len = env->GetArrayLength(output);
if (payload_offset > payload_array_len || payload_length > payload_array_len - payload_offset) {
return CONVERT_FAILED;
}
if (output_offset > output_array_len) return DESTINATION_TOO_SMALL;
jbyte *buf = env->GetByteArrayElements(payload, nullptr);
if (buf == nullptr) return nullptr;
const jsize logical_output_len = output_capacity < output_array_len ? output_capacity : output_array_len;
if (output_offset > logical_output_len) return DESTINATION_TOO_SMALL;
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
if (payload_length > MAX_RPU_INPUT_SIZE) {
LOGW("RPU payload too large (%d bytes), skipping", payload_length);
return CONVERT_FAILED;
}
// Copy to native heap so libdovi never touches JVM heap memory.
// Do not use GetPrimitiveArrayCritical here: it can block concurrent GC
// compaction during sustained playback. A thread-local scratch buffer avoids
// per-frame heap churn while keeping libdovi away from JVM heap memory.
thread_local std::vector<uint8_t> scratch;
try {
scratch.resize(static_cast<size_t>(payload_length));
} catch (...) {
return CONVERT_FAILED;
}
env->GetByteArrayRegion(payload, payload_offset, payload_length, reinterpret_cast<jbyte*>(scratch.data()));
if (env->ExceptionCheck()) {
return CONVERT_FAILED;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
const auto rpu_len = static_cast<size_t>(payload_length);
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
if (rpu == nullptr) {
return CONVERT_FAILED;
}
const char* err = dovi_rpu_get_error(rpu);
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) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
return CONVERT_FAILED;
}
const char *err = dovi_rpu_get_error(rpu);
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
if (rpu == nullptr) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
}
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
}
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
// 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));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
// Convert to target profile (mode 2 = P8.1 with no-op curves)
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
return nullptr;
}
// Write back as UNSPEC62 NAL
const DoviData *out = dovi_write_unspec62_nalu(rpu);
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);
return nullptr;
}
jbyteArray result = env->NewByteArray(static_cast<jsize>(out->len));
if (result != nullptr) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
reinterpret_cast<const jbyte *>(out->data));
}
// Write back as UNSPEC62 NAL
const DoviData* out = dovi_write_unspec62_nalu(rpu);
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);
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;
}
return result;
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;
}
env->SetByteArrayRegion(
output, output_offset, static_cast<jsize>(out->len), reinterpret_cast<const jbyte*>(out->data));
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
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(
JNIEnv *, jclass) {
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(JNIEnv*, jclass) {
#if DOVI_REAL_LINKED
return JNI_TRUE;
return JNI_TRUE;
#else
return JNI_FALSE;
return JNI_FALSE;
#endif
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(
JNIEnv *env, jclass) {
return env->NewStringUTF(BRIDGE_VERSION);
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(JNIEnv* env, jclass) {
return env->NewStringUTF(BRIDGE_VERSION);
}
File diff suppressed because it is too large Load Diff
@@ -19,149 +19,151 @@ import androidx.media3.extractor.TrackOutput
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
private val delegate: Extractor,
private val delegate: Extractor
) : Extractor {
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
}
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
delegate.seek(position, timeUs)
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
override fun release() = delegate.release()
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long
) : SeekMap {
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
override fun isSeekable(): Boolean = true
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
override fun getDurationUs(): Long = durationUs
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long,
) : SeekMap {
override fun isSeekable(): Boolean = true
override fun getDurationUs(): Long = durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
}
@@ -1,85 +1,362 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.util.Log
import android.view.Display
import android.view.WindowManager
enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP }
object DoviBridge {
private const val TAG = "DoviBridge"
private const val TAG = "DoviBridge"
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
true
} catch (_: UnsatisfiedLinkError) {
Log.w(TAG, "Native lib not found")
false
data class DvAutoDecision(
val mode: DvConversionMode,
val reason: String,
val bridgeReady: Boolean,
val displayDv: Boolean,
val nativeDecoder: Boolean,
val advertisedP7: Boolean,
val advertisedP8: Boolean,
val decoders: String,
val displayHdr: String
) {
fun logMessage(): String = "AUTO P7 DV decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
"displayDV=$displayDv, nativeDecoder=$nativeDecoder, advertisedP7=$advertisedP7, " +
"advertisedP8=$advertisedP8, decoders=$decoders, displayHdr=$displayHdr"
}
data class Dv7FallbackDecision(
val mode: DvConversionMode,
val reason: String,
val bridgeReady: Boolean,
val displayDv: Boolean,
val advertisedP8: Boolean,
val displayHdr: String
) {
fun logMessage(): String = "DV7 fallback decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
"displayDV=$displayDv, advertisedP8=$advertisedP8, displayHdr=$displayHdr"
}
private val DOLBY_VISION_MIME_TYPES = setOf(
"video/dolby-vision",
"video/hevcdv",
"video/dv_hevc"
)
const val CONVERT_FAILED = -1
const val DESTINATION_TOO_SMALL = -2
private data class DvProfileLevel(val profile: Int, val level: Int)
private data class DvDecoderCapability(
val name: String,
val mimeType: String,
val profileLevels: List<DvProfileLevel>
)
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
true
} catch (_: UnsatisfiedLinkError) {
Log.w(TAG, "Native lib not found")
false
}
}
private val conversionPathReady: Boolean by lazy {
nativeLoaded && runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
}
fun isAvailable(): Boolean = conversionPathReady
private val dolbyVisionDecoders: List<DvDecoderCapability> by lazy {
try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
codecList.codecInfos.flatMap { info ->
if (info.isEncoder) {
emptyList()
} else {
info.supportedTypes.mapNotNull { type ->
if (!DOLBY_VISION_MIME_TYPES.contains(type.lowercase())) return@mapNotNull null
val capabilities = runCatching { info.getCapabilitiesForType(type) }
.onFailure { Log.w(TAG, "Failed to query ${info.name} capabilities for $type", it) }
.getOrNull()
?: return@mapNotNull null
DvDecoderCapability(
name = info.name,
mimeType = type,
profileLevels = capabilities.profileLevels.map { DvProfileLevel(it.profile, it.level) }
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to query native Dolby Vision decoder support", e)
emptyList()
}
}
val hasNativeDolbyVisionDecoder: Boolean by lazy {
dolbyVisionDecoders.isNotEmpty().also {
Log.i(TAG, "Native Dolby Vision decoder available: $it; decoders=${describeDolbyVisionDecoders()}")
}
}
private fun deviceAdvertisesDvProfile(profile: Int, minApi: Int = 0): Boolean {
if (Build.VERSION.SDK_INT < minApi) return false
return dolbyVisionDecoders.any { decoder ->
decoder.profileLevels.any { it.profile == profile }
}
}
val deviceSupportsDvProfile7: Boolean by lazy {
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb)
.also { Log.i(TAG, "Device advertises exact DV Profile 7 (DvheDtb): $it") }
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
}
fun displaySupportsDolbyVision(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
return false
}
fun isAvailable(): Boolean = nativeLoaded &&
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
val display = getCurrentDisplay(context)
if (display == null) {
Log.i(TAG, "Display Dolby Vision support: false (no active display)")
return false
}
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
try {
if (Build.VERSION.SDK_INT < minApi) return false
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
return codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile }
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV profile $profile support", e)
return false
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
Log.w(TAG, "Display Dolby Vision support: false (failed to query HDR types)", error)
return false
}
val supported = hdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
Log.i(TAG, "Display Dolby Vision support: $supported; ${describeDisplayHdrCapabilities(display)}")
return supported
}
fun describeDisplayHdrCapabilities(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
return describeDisplayHdrCapabilities(display)
}
fun logSupportSummary(context: Context) {
Log.i(
TAG,
"DV support summary: bridgeReady=${isAvailable()}, displayDV=${displaySupportsDolbyVision(context)}, " +
"nativeDecoder=$hasNativeDolbyVisionDecoder, advertisedP7=$deviceSupportsDvProfile7, " +
"advertisedP8=$deviceSupportsDvProfile8, decoders=${describeDolbyVisionDecoders()}, " +
"displayHdr=${describeDisplayHdrCapabilities(context)}"
)
}
fun getConversionDecision(context: Context): DvAutoDecision {
val bridgeReady = isAvailable()
val displayDv = displaySupportsDolbyVision(context)
val nativeDecoder = hasNativeDolbyVisionDecoder
val advertisedP7 = deviceSupportsDvProfile7
val advertisedP8 = deviceSupportsDvProfile8
val mode = when {
!displayDv -> DvConversionMode.HEVC_STRIP
advertisedP7 -> DvConversionMode.DISABLED
advertisedP8 && bridgeReady -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
val reason = when {
!displayDv -> "active display does not support Dolby Vision; stripping DV metadata for HEVC fallback"
advertisedP7 -> "active display supports Dolby Vision and decoder advertises exact Profile 7; trying native DV first"
advertisedP8 && bridgeReady -> "active display supports Dolby Vision and decoder advertises Profile 8; converting to Profile 8.1"
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
else -> "Dolby Vision output path is unavailable; stripping DV metadata for HEVC fallback"
}
val decision = DvAutoDecision(
mode = mode,
reason = reason,
bridgeReady = bridgeReady,
displayDv = displayDv,
nativeDecoder = nativeDecoder,
advertisedP7 = advertisedP7,
advertisedP8 = advertisedP8,
decoders = describeDolbyVisionDecoders(),
displayHdr = describeDisplayHdrCapabilities(context)
)
Log.i(TAG, decision.logMessage())
return decision
}
fun getConversionMode(context: Context): DvConversionMode = getConversionDecision(context).mode
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackDecision(context: Context): Dv7FallbackDecision {
val bridgeReady = isAvailable()
val displayDv = displaySupportsDolbyVision(context)
val advertisedP8 = deviceSupportsDvProfile8
val mode = if (displayDv && advertisedP8 && bridgeReady) DvConversionMode.DV81 else DvConversionMode.HEVC_STRIP
val reason = when {
mode == DvConversionMode.DV81 -> "display supports Dolby Vision and decoder advertises Profile 8"
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
else -> "Dolby Vision output or Profile 8 support is unavailable"
}
val decision = Dv7FallbackDecision(
mode = mode,
reason = reason,
bridgeReady = bridgeReady,
displayDv = displayDv,
advertisedP8 = advertisedP8,
displayHdr = describeDisplayHdrCapabilities(context)
)
Log.i(TAG, decision.logMessage())
return decision
}
fun getDv7FallbackMode(context: Context): DvConversionMode = getDv7FallbackDecision(context).mode
private fun getCurrentDisplay(context: Context): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay
}
private fun describeDolbyVisionDecoders(): String {
if (dolbyVisionDecoders.isEmpty()) return "none"
return dolbyVisionDecoders.joinToString { decoder ->
val profiles = if (decoder.profileLevels.isEmpty()) {
"none"
} else {
decoder.profileLevels.joinToString(prefix = "[", postfix = "]") {
"${describeDvProfile(it.profile)}/${describeDvLevel(it.level)}"
}
}
"${decoder.name}(${decoder.mimeType}, profiles=$profiles)"
}
}
val deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
private fun describeHdrTypes(hdrTypes: IntArray): String {
if (hdrTypes.isEmpty()) return "none"
return hdrTypes.joinToString(prefix = "[", postfix = "]") { type ->
when (type) {
Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DOLBY_VISION"
Display.HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
Display.HdrCapabilities.HDR_TYPE_HLG -> "HLG"
Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10_PLUS"
else -> "unknown(${hex(type)})"
}
}
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
private fun describeDisplayHdrCapabilities(display: Display): String {
val hdrCapabilities = display.hdrCapabilities
val parts = mutableListOf(
"display=${display.displayId}:${display.name}",
"activeMode=${describeDisplayMode(display.mode)}",
"hdrCapabilities=${describeHdrTypes(hdrCapabilities.supportedHdrTypes)}",
"desiredLuminance=max=${formatLuminance(hdrCapabilities.desiredMaxLuminance)}, " +
"maxAvg=${formatLuminance(hdrCapabilities.desiredMaxAverageLuminance)}, " +
"min=${formatLuminance(hdrCapabilities.desiredMinLuminance)}"
)
parts += "supportedModes=${display.supportedModes.joinToString(prefix = "[", postfix = "]") { describeDisplayMode(it) }}"
return parts.joinToString(prefix = "{", postfix = "}")
}
private fun getDisplayHdrTypes(display: Display): IntArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
display.mode.supportedHdrTypes
} else {
@Suppress("DEPRECATION")
display.hdrCapabilities.supportedHdrTypes
}
private fun describeDisplayMode(mode: Display.Mode): String {
val hdr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
", hdr=${describeHdrTypes(mode.supportedHdrTypes)}"
} else {
""
}
return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz$hdr"
}
fun getConversionMode(): DvConversionMode = when {
!isAvailable() -> DvConversionMode.DISABLED
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
private fun formatLuminance(value: Float): String = if (value.isNaN() || value <= 0f) "unknown" else "${value}nits"
private fun describeDvProfile(profile: Int): String = when (profile) {
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPer -> "P0/DvavPer"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPen -> "P1/DvavPen"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDer -> "P2/DvheDer"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDen -> "P3/DvheDen"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr -> "P4/DvheDtr"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn -> "P5/DvheStn"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDth -> "P6/DvheDth"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb -> "P7/DvheDtb"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt -> "P8/DvheSt"
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavSe -> "P9/DvavSe"
else -> "unknown(${hex(profile)})"
}
private fun describeDvLevel(level: Int): String = when (level) {
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd24 -> "L1/Hd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd30 -> "L2/Hd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd24 -> "L3/Fhd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd30 -> "L4/Fhd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd60 -> "L5/Fhd60"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd24 -> "L6/Uhd24"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd30 -> "L7/Uhd30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd48 -> "L8/Uhd48"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd60 -> "L9/Uhd60"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd120 -> "L10/Uhd120"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k30 -> "L11/8k30"
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k60 -> "L12/8k60"
else -> "unknown(${hex(level)})"
}
private fun hex(value: Int): String = "0x${value.toString(16)}"
fun convertRpuNalu(
payload: ByteArray,
payloadOffset: Int,
payloadLength: Int,
output: ByteArray,
outputOffset: Int,
outputCapacity: Int,
mode: Int = 2
): Int {
if (!conversionPathReady || payloadLength <= 0) return CONVERT_FAILED
return runCatching {
nativeConvertDv7RpuToDv81(payload, payloadOffset, payloadLength, output, outputOffset, outputCapacity, mode)
}
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
.getOrDefault(CONVERT_FAILED)
}
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? {
if (!isAvailable() || payload.isEmpty()) return null
return runCatching { nativeConvertDv7RpuToDv81(payload, mode) }
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
.getOrNull()
}
@JvmStatic
private external fun nativeConvertDv7RpuToDv81(
payload: ByteArray,
payloadOffset: Int,
payloadLength: Int,
output: ByteArray,
outputOffset: Int,
outputCapacity: Int,
mode: Int
): Int
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
@JvmStatic
private external fun nativeIsConversionPathReady(): Boolean
@JvmStatic
private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray?
@JvmStatic
private external fun nativeIsConversionPathReady(): Boolean
@JvmStatic
private external fun nativeGetBridgeVersion(): String
@JvmStatic
private external fun nativeGetBridgeVersion(): String
}
File diff suppressed because it is too large Load Diff
@@ -14,22 +14,23 @@ import androidx.media3.extractor.TrackOutput
* Shared by DoviExtractorWrapper for both MP4 and MKV containers.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit,
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val emitLog: ((String, String, String) -> Unit)?,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
) : ExtractorOutput {
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)
onVideoTrackWrapped(wrapper)
return wrapper
}
return original
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 original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
@@ -38,23 +39,23 @@ class DoviExtractorOutputWrapper(
* DV Profile 7 → 8.1 conversion via inline NAL processing.
*/
class DoviExtractorWrapper(
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : Extractor {
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
delegate.read(input, seekPosition)
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) = delegate.seek(position, timeUs)
override fun release() = delegate.release()
override fun release() = delegate.release()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,11 +2,14 @@ package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioDeviceInfo
import android.os.Build
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioOutput
import androidx.media3.exoplayer.audio.AudioOutputProvider
@@ -17,39 +20,58 @@ import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider
import androidx.media3.exoplayer.audio.ForwardingAudioSink
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs
@OptIn(UnstableApi::class)
class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
val audioDelayUs = AtomicLong(0L)
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_500_000)
.setPcmBufferMultiplicationFactor(8)
.build()
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.build()
/** Called before Media3 reacts to an audio route/capability change. */
var onAudioCapabilitiesChanged: (() -> Unit)? = null
// Shared position: RawPositionAudioOutput writes the raw AudioTrack position,
// PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp.
val rawPositionUs = AtomicLong(Long.MIN_VALUE)
var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null
val defaultSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
.build()
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
return PositionFixAudioSink(defaultSink, rawPositionUs)
}
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
.build()
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.build()
// Shared position: RawPositionAudioOutput writes the raw AudioTrack position,
// PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp.
val rawPositionUs = AtomicLong(Long.MIN_VALUE)
val defaultSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
.build()
return PositionFixAudioSink(
defaultSink,
rawPositionUs,
audioDelayUs,
shouldBlockDirectAudioOutput,
onAudioCapabilitiesChanged,
audioDiagnosticsLogger
)
}
}
/**
@@ -75,186 +97,339 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
*/
@OptIn(UnstableApi::class)
private class PositionFixAudioSink(
sink: AudioSink,
private val rawPositionUs: AtomicLong
sink: AudioSink,
private val rawPositionUs: AtomicLong,
private val audioDelayUs: AtomicLong,
private val shouldBlockDirectAudioOutput: ((Format) -> Boolean)?,
private val onAudioCapabilitiesChanged: (() -> Unit)?,
private val log: ((String, String, String) -> Unit)?
) : ForwardingAudioSink(sink) {
private var startMediaTimeUs = Long.MIN_VALUE
private var suppressedErrorCount = 0
private var startMediaTimeUs = Long.MIN_VALUE
private var suppressedErrorCount = 0
// Speed tracking for position bypass
private var currentSpeed = 1.0f
private var refMediaTimeUs = Long.MIN_VALUE
private var refRawPositionUs = 0L
// Speed tracking for position bypass
private var currentSpeed = 1.0f
private var refMediaTimeUs = Long.MIN_VALUE
private var refRawPositionUs = 0L
override fun handleBuffer(
buffer: ByteBuffer,
presentationTimeUs: Long,
encodedAccessUnitCount: Int
): Boolean {
if (startMediaTimeUs == Long.MIN_VALUE) {
startMediaTimeUs = presentationTimeUs
refMediaTimeUs = presentationTimeUs
refRawPositionUs = 0
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
// over recoveryDurationUs to prevent video frame drops.
private var recoveryDurationUs = 0L
private val loggedDirectBlockMimeTypes = mutableSetOf<String>()
private var loggedFirstBuffer = false
override fun supportsFormat(format: Format): Boolean {
if (blocksDirectAudioOutput(format)) return false
return super.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int {
if (blocksDirectAudioOutput(format)) return AudioSink.SINK_FORMAT_UNSUPPORTED
return super.getFormatSupport(format)
}
private fun blocksDirectAudioOutput(format: Format): Boolean {
if (shouldBlockDirectAudioOutput?.invoke(format) != true) return false
val mimeType = format.sampleMimeType ?: "unknown"
if (loggedDirectBlockMimeTypes.add(mimeType)) {
log?.invoke(
"info",
"audio",
"Blocking direct $mimeType output; decoded PCM output will be preferred"
)
}
return true
}
override fun handleBuffer(
buffer: ByteBuffer,
presentationTimeUs: Long,
encodedAccessUnitCount: Int
): Boolean {
if (!loggedFirstBuffer && buffer.hasRemaining()) {
loggedFirstBuffer = true
log?.invoke(
"debug",
"audio-sink",
"First buffer: size=${buffer.remaining()}B, pts=${presentationTimeUs}us, accessUnits=$encodedAccessUnitCount"
)
}
if (startMediaTimeUs == Long.MIN_VALUE) {
startMediaTimeUs = presentationTimeUs
refMediaTimeUs = presentationTimeUs
refRawPositionUs = 0
val delayUs = audioDelayUs.get()
recoveryDurationUs = if (delayUs == 0L) 0L else maxOf(200_000L, 2L * abs(delayUs))
}
return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
// Capture reference point before speed changes
val rawPos = rawPositionUs.get()
if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) {
refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
refRawPositionUs = rawPos
}
currentSpeed = playbackParameters.speed
super.setPlaybackParameters(playbackParameters)
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long {
val delegatePos = super.getCurrentPositionUs(sourceEnded)
if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) {
return delegatePos
}
val rawPos = rawPositionUs.get()
val basePos = if (rawPos <= 0) {
delegatePos
} else {
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos
}
// Audio delay with transient counter-offset to prevent frame drops after seeks.
// After flush, offset ramps linearly from 0 to full over recoveryDurationUs.
val delayUs = audioDelayUs.get()
if (delayUs == 0L) return basePos
val elapsedUs = basePos - startMediaTimeUs
val netOffsetUs = if (recoveryDurationUs <= 0L || elapsedUs >= recoveryDurationUs) {
delayUs
} else {
(delayUs * elapsedUs) / recoveryDurationUs
}
return basePos + netOffsetUs
}
// --- Suppress timestamp discontinuity errors ---
override fun setListener(listener: AudioSink.Listener) {
super.setListener(
@OptIn(UnstableApi::class) object : AudioSink.Listener {
override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity()
override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs)
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
override fun onAudioCapabilitiesChanged() {
onAudioCapabilitiesChanged?.invoke()
listener.onAudioCapabilitiesChanged()
}
return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
}
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
override fun onSilenceSkipped() = listener.onSilenceSkipped()
override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
// Capture reference point before speed changes
val rawPos = rawPositionUs.get()
if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) {
refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
refRawPositionUs = rawPos
override fun onAudioSinkError(audioSinkError: Exception) {
if (isTimestampDiscontinuity(audioSinkError)) {
suppressedErrorCount++
return
}
listener.onAudioSinkError(audioSinkError)
}
currentSpeed = playbackParameters.speed
super.setPlaybackParameters(playbackParameters)
}
}
)
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long {
val delegatePos = super.getCurrentPositionUs(sourceEnded)
if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) {
return delegatePos
}
override fun flush() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
recoveryDurationUs = 0L
rawPositionUs.set(Long.MIN_VALUE)
super.flush()
}
val rawPos = rawPositionUs.get()
if (rawPos <= 0) return delegatePos
override fun reset() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
recoveryDurationUs = 0L
currentSpeed = 1.0f
rawPositionUs.set(Long.MIN_VALUE)
suppressedErrorCount = 0
super.reset()
}
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
return if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos
}
private fun isTimestampDiscontinuity(e: Exception): Boolean {
val name = e.javaClass.simpleName
val msg = e.message ?: ""
return name == "InvalidAudioTrackTimestampException" ||
name == "UnexpectedDiscontinuityException" ||
msg.contains("timestamp discontinuity", ignoreCase = true)
}
}
// --- Suppress timestamp discontinuity errors ---
override fun setListener(listener: AudioSink.Listener) {
super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener {
override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity()
override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs)
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged()
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
override fun onSilenceSkipped() = listener.onSilenceSkipped()
override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId)
override fun onAudioSinkError(audioSinkError: Exception) {
if (isTimestampDiscontinuity(audioSinkError)) {
suppressedErrorCount++
return
}
listener.onAudioSinkError(audioSinkError)
}
})
}
override fun flush() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
rawPositionUs.set(Long.MIN_VALUE)
super.flush()
}
override fun reset() {
startMediaTimeUs = Long.MIN_VALUE
refMediaTimeUs = Long.MIN_VALUE
refRawPositionUs = 0
currentSpeed = 1.0f
rawPositionUs.set(Long.MIN_VALUE)
suppressedErrorCount = 0
super.reset()
}
private fun isTimestampDiscontinuity(e: Exception): Boolean {
val name = e.javaClass.simpleName
val msg = e.message ?: ""
return name == "InvalidAudioTrackTimestampException" ||
name == "UnexpectedDiscontinuityException" ||
msg.contains("timestamp discontinuity", ignoreCase = true)
}
/**
* Wraps a text renderer to shift subtitle timing by [delayUs] microseconds.
* Positive delay → subtitles appear later, negative → earlier.
* Only render() needs the offset — the text renderer uses positionUs to decide
* which cues are active; other Renderer methods are timing-independent.
*/
@OptIn(UnstableApi::class)
internal class SubtitleDelayRenderer(
private val delegate: Renderer,
private val delayUs: AtomicLong
) : Renderer by delegate {
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs)
}
}
// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink ---
// Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation.
// DefaultAudioSink releases the AudioOutput on every flush (seek), which destroys the
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
@OptIn(UnstableApi::class)
private class RawPositionOutputProvider(
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong,
private val log: ((String, String, String) -> Unit)?
) : AudioOutputProvider {
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) =
delegate.getFormatSupport(config)
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) =
delegate.getOutputConfig(config)
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
val realOutput = delegate.getAudioOutput(config)
return RawPositionAudioOutput(realOutput, rawPositionUs)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
return cached
}
cached?.forceRelease()
cachedOutput = null
override fun addListener(listener: AudioOutputProvider.Listener) =
delegate.addListener(listener)
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
}
override fun removeListener(listener: AudioOutputProvider.Listener) =
delegate.removeListener(listener)
fun returnToCache(output: RawPositionAudioOutput) {
val existing = cachedOutput
if (existing != null && existing !== output) {
existing.forceRelease()
}
cachedOutput = output
}
override fun setClock(clock: Clock) = delegate.setClock(clock)
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
override fun release() = delegate.release()
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
override fun setClock(clock: Clock) = delegate.setClock(clock)
override fun release() {
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
delegate.release()
}
}
@OptIn(UnstableApi::class)
private class RawPositionAudioOutput(
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong,
private val provider: RawPositionOutputProvider,
private val log: ((String, String, String) -> Unit)?
) : AudioOutput {
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
return pos
private var loggedFirstWrite = false
private var writeCount = 0L
private var writtenBytes = 0L
private var failed = false
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
return pos
}
override fun play() = delegate.play()
override fun pause() = delegate.pause()
@Throws(AudioOutput.WriteException::class)
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long): Boolean {
val before = buffer.position()
val handled = try {
delegate.write(buffer, size, presentationTimeUs)
} catch (e: AudioOutput.WriteException) {
failed = true
rawPositionUs.set(Long.MIN_VALUE)
log?.invoke("warn", "audio-output", "Write failed; AudioTrack will not be reused: ${e.message}")
throw e
}
override fun play() = delegate.play()
override fun pause() = delegate.pause()
@Throws(AudioOutput.WriteException::class)
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) =
delegate.write(buffer, size, presentationTimeUs)
override fun flush() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.flush()
val written = buffer.position() - before
if (written > 0) {
writeCount++
writtenBytes += written.toLong()
if (!loggedFirstWrite) {
loggedFirstWrite = true
log?.invoke(
"debug",
"audio-output",
"First write: wrote=${written}B, handled=$handled, pts=${presentationTimeUs}us, writes=$writeCount, total=${writtenBytes}B"
)
}
}
return handled
}
override fun stop() = delegate.stop()
override fun flush() {
rawPositionUs.set(Long.MIN_VALUE)
loggedFirstWrite = false
writeCount = 0
writtenBytes = 0
delegate.flush()
}
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.release()
override fun stop() = delegate.stop()
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (failed) {
delegate.release()
return
}
if (Build.VERSION.SDK_INT >= 25) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
}
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
override fun getSampleRate() = delegate.getSampleRate()
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) =
delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) =
delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) =
delegate.setPreferredDevice(preferredDevice)
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.release()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
override fun getSampleRate() = delegate.getSampleRate()
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = delegate.setPreferredDevice(preferredDevice)
}
@@ -16,108 +16,115 @@ import java.util.zip.Inflater
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
private val delegate: TrackOutput,
private val delegate: TrackOutput
) : TrackOutput {
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
}
var active = false
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
var active = false
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
}
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
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 (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
}
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
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 (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
}
}
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
}
}
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
}
}
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
}
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
}
}
@@ -6,9 +6,9 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
/**
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
@@ -20,97 +20,97 @@ import androidx.media3.extractor.text.SubtitleParser
* - Skips ContentCompSettings for zlib tracks (not applicable)
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler,
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
companion object {
private const val TAG = "ZlibMkvExtractor"
companion object {
private const val TAG = "ZlibMkvExtractor"
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
}
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
File diff suppressed because it is too large Load Diff
@@ -10,301 +10,376 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
class MpvPlayerPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler,
ActivityAware,
com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead
// of each tearing down the in-flight core and starting their own — which
// was the root cause of #930.
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
@Volatile private var isInitializing = false
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
++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)
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"updateFrame" -> handleUpdateFrame(result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> result.success(null)
else -> result.notImplemented()
}
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
// Coalesce concurrent inits: the second caller waits for the first
// 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) {
pendingInitResults += result
if (isInitializing) {
Log.d(TAG, "Init already in flight, queuing caller")
return
}
isInitializing = true
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> result.success(null)
else -> result.notImplemented()
}
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
currentActivity.runOnUiThread {
val gen: Int
val core: MpvPlayerCore
try {
// Caller invariant: dispose() was already called explicitly,
// OR `playerCore?.isInitialized == true` and we early-exited
// above. We never tear down a core that's mid-initialization.
if (playerCore != null && playerCore?.isInitialized != true) {
Log.w(TAG, "Discarding stale uninitialized core before re-init")
playerCore?.dispose()
playerCore = null
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
gen = ++sessionGeneration
core = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
completePendingInits(success = false, errorMessage = e.message)
return@runOnUiThread
}
currentActivity.runOnUiThread {
try {
// Dispose stale core idempotently
playerCore?.dispose()
playerCore = null
val gen = ++sessionGeneration
val core = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
core.initialize { success ->
if (gen != sessionGeneration || playerCore !== core) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
result.success(false)
return@initialize
}
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
result.error("INIT_FAILED", e.message, null)
}
core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core
if (stale) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
}
completePendingInits(success = !stale && success)
}
}
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
core?.dispose {
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
val pending = synchronized(pendingInitResults) {
isInitializing = false
val copy = pendingInitResults.toList()
pendingInitResults.clear()
copy
}
for (r in pending) {
if (errorMessage != null) {
r.error("INIT_FAILED", errorMessage, null)
} else {
r.success(success)
}
}
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
// Any in-flight init callback is cancelled with the scope, so
// close out queued callers here instead of leaking them.
completePendingInits(success = false)
playerCore?.setProperty(name, value)
core?.dispose {
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
playerCore?.setProperty(name, value)
result.success(null)
}
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", null)
return
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", null)
return
}
val value = playerCore?.getProperty(name)
result.success(value)
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val format = call.argument<String>("format")
val id = call.argument<Int>("id")
if (name == null || format == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
return
}
nameToId[name] = id
playerCore?.observeProperty(name, format)
result.success(null)
}
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
val args = call.argument<List<String>>("args")
if (args == null) {
result.error("INVALID_ARGS", "Missing 'args'", null)
return
}
val core = playerCore
if (core == null) {
result.success(null)
return
}
core.command(args.toTypedArray()) {
result.success(null)
}
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
playerCore?.setVisible(visible)
result.success(null)
}
private fun handleUpdateFrame(result: MethodChannel.Result) {
playerCore?.updateFrame()
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs")
val core = playerCore
if (core == null) {
result.success(false)
return
}
core.setVideoFrameRate(fps, duration, extraDelayMs) { switched ->
result.success(switched)
}
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
// Open file descriptor off UI thread to prevent ANR on slow storage
Thread {
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val value = playerCore?.getProperty(name)
result.success(value)
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val format = call.argument<String>("format")
val id = call.argument<Int>("id")
// PlayerDelegate
if (name == null || format == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
return
}
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
nameToId[name] = id
playerCore?.observeProperty(name, format)
result.success(null)
}
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
val args = call.argument<List<String>>("args")
if (args == null) {
result.error("INVALID_ARGS", "Missing 'args'", null)
return
}
playerCore?.command(args.toTypedArray())
result.success(null)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
playerCore?.setVisible(visible)
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
playerCore?.setVideoFrameRate(fps, duration)
result.success(null)
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
// Open file descriptor off UI thread to prevent ANR on slow storage
Thread {
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
// PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
}
@@ -9,97 +9,97 @@ import android.os.Handler
import android.util.Log
class AudioFocusManager(
context: Context,
private val handler: Handler,
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
context: Context,
private val handler: Handler,
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
) {
companion object {
private const val TAG = "AudioFocusManager"
}
companion object {
private const val TAG = "AudioFocusManager"
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
var wasPlayingBeforeFocusLoss: Boolean = false
private set
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
var wasPlayingBeforeFocusLoss: Boolean = false
private set
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
log("Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss) {
onResume()
wasPlayingBeforeFocusLoss = false
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
log("Focus lost permanently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
log("Focus lost transiently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
log("Focus lost transiently (can duck)")
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
log("Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss) {
onResume()
wasPlayingBeforeFocusLoss = false
}
}
fun requestAudioFocus(): Boolean {
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
.build()
audioFocusRequest = focusRequest
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
return hasAudioFocus
}
fun abandonAudioFocus() {
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
log("Focus lost permanently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
log("Focus lost transiently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
log("Focus lost transiently (can duck)")
}
}
}
fun requestAudioFocus(): Boolean {
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
.build()
audioFocusRequest = focusRequest
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
fun release() {
abandonAudioFocus()
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
return hasAudioFocus
}
fun abandonAudioFocus() {
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun release() {
abandonAudioFocus()
}
}
@@ -0,0 +1,23 @@
package com.edde746.plezy.shared
import android.os.Build
object DeviceQuirks {
val isEWaste: Boolean
get() = isGooglePixelDevice || isGoogleTensorDevice
private val isGooglePixelDevice: Boolean
get() = (
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
Build.BRAND.equals("google", ignoreCase = true)
) &&
Build.MODEL.contains("Pixel", ignoreCase = true)
private val isGoogleTensorDevice: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
val soc = Build.SOC_MODEL
return soc.startsWith("Tensor", ignoreCase = true) ||
soc.startsWith("GS", ignoreCase = true)
}
}
@@ -1,6 +1,7 @@
package com.edde746.plezy.shared
import android.graphics.PixelFormat
import android.os.Build
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
@@ -8,52 +9,84 @@ import android.view.ViewGroup
object FlutterOverlayHelper {
/**
* Find the FlutterView container in the content view hierarchy.
* First tries to match by class name (works in debug builds),
* then falls back to picking the last ViewGroup with children
* (needed for release builds where FlutterView may be obfuscated).
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
return child
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our video container and has children.
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
return child
}
}
return null
/**
* Find the top-level container holding the Flutter render surface. Returns the
* direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView
* at any depth — `bringChildToFront` only works for direct children, so this is the
* node to pass to [configureFlutterZOrder].
*
* Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch)
* and push it below the direct-child level.
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child === excludeView || child !is ViewGroup) continue
if (findRenderSurface(child) != null) return child
}
return null
}
/**
* Configure z-ordering so the Flutter UI renders above the video surface.
*
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
* false for ExoPlayer (subtitle layer sits between video and Flutter)
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) {
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(zOrderOnTop)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
}
}
private fun findRenderSurface(root: ViewGroup): View? {
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is SurfaceView || child is TextureView) return child
if (child is ViewGroup) findRenderSurface(child)?.let { return it }
}
return null
}
/**
* Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
* the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
* bucket settings govern Z-order instead.
*
* The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics:
* `mSubLayer >= 0` (non-negative `order`) is composited above the parent window
* and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is
* behind the parent window and the SurfaceView is made visible by punching a
* transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the
* right sign matters: any view that draws on the parent canvas (e.g. Media3's
* `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with
* non-negative orders that sit on top of the same area.
*/
fun applyCompositionOrder(view: SurfaceView, order: Int) {
if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order
}
/**
* Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces.
*
* On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps
* directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics).
* On pre-36 SurfaceView builds the value is mapped to the legacy on-top bucket
* when positive. On TextureView builds the value is unused (view hierarchy order
* handles it).
*
* Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas
* and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over.
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) {
contentView.bringChildToFront(container)
when (val surface = findRenderSurface(container)) {
is SurfaceView -> {
if (Build.VERSION.SDK_INT >= 36) {
// Clear legacy bucket hints so compositionOrder is authoritative.
// Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its
// transparent-mode constructor, which otherwise pins it to z=1.
surface.setZOrderOnTop(false)
surface.setZOrderMediaOverlay(false)
surface.compositionOrder = compositionOrder
} else {
// Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top
// bucket so it renders above the video (default) and libass subtitle
// (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites
// mSubLayer internally, so don't call it here or it cancels setZOrderOnTop.
surface.setZOrderOnTop(compositionOrder > 0)
}
surface.holder.setFormat(PixelFormat.TRANSLUCENT)
}
is TextureView -> surface.isOpaque = false
}
}
}
@@ -6,179 +6,264 @@ import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.util.Log
import android.view.Surface
import android.view.Display
import android.view.WindowManager
import androidx.annotation.RequiresApi
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.abs
import kotlin.math.roundToInt
class FrameRateManager(
private val activity: Activity,
private val handler: Handler,
private val onDisplayChanged: () -> Unit,
private val log: (String) -> Unit = { Log.d(TAG, it) }
private val activity: Activity,
private val handler: Handler,
private val log: (String) -> Unit = { Log.d(TAG, it) }
) {
companion object {
private const val TAG = "FrameRateManager"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
companion object {
private const val TAG = "FrameRateManager"
private const val DISPLAY_SETTLE_MS = 2000L
private const val WATCHDOG_MARGIN_MS = 3000L
private const val RATE_TOLERANCE = 0.1f
}
private data class RefreshRateMatch(
val reason: String,
val priority: Int,
val error: Float
)
@RequiresApi(Build.VERSION_CODES.M)
private data class DisplayModeCandidate(
val mode: Display.Mode,
val match: RefreshRateMatch
)
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private var pendingSettleRunnable: Runnable? = null
private var watchdogRunnable: Runnable? = null
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
// Request a display frame-rate switch. Invokes [onComplete] once, either:
// - immediately with `switched=false` when no switch is needed (invalid fps,
// no matching mode, or already matching); or
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
// [extraDelayMs], with `switched=true`; or
// - via a watchdog if the real event never arrives, so the caller doesn't hang.
//
// The caller is responsible for pausing playback before calling and resuming
// it after [onComplete] fires.
fun setVideoFrameRate(
fps: Float,
videoDurationMs: Long,
extraDelayMs: Long,
onComplete: (switched: Boolean) -> Unit
) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
onComplete(false)
return
}
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
log(
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
)
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setDisplayMode(fps, extraDelayMs, onComplete)
} else {
onComplete(false)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
// Resolve any pending setVideoFrameRate future as "not switched" so
// the Dart caller's await doesn't hang on player dispose.
firePendingCompletion("clear", switched = false)
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
}
// Release pending callbacks/listener without restoring the display mode.
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
// window-scoped preferredDisplayModeId persists across the SurfaceView swap,
// letting MPV inherit the rate without a second HDMI renegotiation.
fun releasePending() {
Log.d(TAG, "releasePending")
currentVideoFps = 0f
firePendingCompletion("release", switched = false)
}
private fun cancelPendingCallbacks() {
pendingSettleRunnable?.let { handler.removeCallbacks(it) }
watchdogRunnable?.let { handler.removeCallbacks(it) }
pendingSettleRunnable = null
watchdogRunnable = null
}
private fun firePendingCompletion(reason: String, switched: Boolean) {
cancelPendingCallbacks()
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
val cb = pendingCompletion ?: return
pendingCompletion = null
log("complete reason=$reason, switched=$switched, currentMode=${currentModeDescription()}")
cb(switched)
}
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
// Resolve any previous pending op before starting a new one.
firePendingCompletion("superseded", switched = false)
pendingCompletion = onComplete
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
// Unregister immediately so a chatty display (e.g. several
// onDisplayChanged events during HDMI renegotiation) doesn't
// queue multiple settle callbacks.
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
pendingSettleRunnable = settle
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
// Watchdog: if the TV never signals a display change (silently ignoring
// the mode request), still complete after a bounded wait so the caller
// doesn't hang.
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
watchdogRunnable = watchdog
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
}
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
if (refreshRate <= 0f || fps <= 0f) return null
val exactError = abs(refreshRate - fps)
if (exactError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
setFrameRateS(fps, surface, videoDurationMs)
}
// API R's Surface.setFrameRate() only supports seamless switching (no
// CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to
// preferredDisplayModeId which directly sets the display mode.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
val multiple = (refreshRate / fps).roundToInt()
if (multiple > 1) {
val multipleError = abs(refreshRate - (fps * multiple))
if (multipleError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
return null
}
private fun currentRateMatch(fps: Float): RefreshRateMatch? {
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
currentDisplayMode()?.refreshRate
} else {
null
} ?: return null
return matchRefreshRate(current, fps)
}
private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
describeMode(currentDisplayMode())
} else {
"unavailable"
}
@RequiresApi(Build.VERSION_CODES.M)
private fun currentDisplay(): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display
} else {
@Suppress("DEPRECATION")
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
}
@RequiresApi(Build.VERSION_CODES.M)
private fun currentDisplayMode(): Display.Mode? = currentDisplay()?.mode
@RequiresApi(Build.VERSION_CODES.M)
private fun describeMode(mode: Display.Mode?): String {
if (mode == null) return "unknown"
return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz"
}
@RequiresApi(Build.VERSION_CODES.M)
private fun describeSupportedModes(modes: Array<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
@RequiresApi(Build.VERSION_CODES.M)
private fun findBestModeMatch(fps: Float, currentMode: Display.Mode, supportedModes: Array<Display.Mode>): DisplayModeCandidate? = supportedModes.asSequence()
.filter { mode ->
mode.physicalHeight == currentMode.physicalHeight &&
mode.physicalWidth == currentMode.physicalWidth
}
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
.minWithOrNull(
compareBy<DisplayModeCandidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
@RequiresApi(Build.VERSION_CODES.M)
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
log("setDisplayMode fps=$fps")
val display = currentDisplay()
if (display == null) {
log("display unavailable")
onComplete(false)
return
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
val supportedModes = display.supportedModes
if (supportedModes == null) {
log("supported display modes unavailable")
onComplete(false)
return
}
val currentMode = display.mode
log("supported modes=${describeSupportedModes(supportedModes)}")
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
handler.postDelayed({
onDisplayChanged()
}, 2000L)
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
val modeMatch = findBestModeMatch(fps, currentMode, supportedModes)
if (modeMatch == null) {
log("no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight}")
onComplete(false)
return
}
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
Log.d(TAG, "Short video, using seamless-only switching")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
return
}
var seamless = false
activity.display?.mode?.alternativeRefreshRates?.let { refreshRates ->
for (rate in refreshRates) {
if (fps.toString().startsWith(rate.toString()) ||
rate.toString().startsWith(fps.toString()) ||
rate % fps == 0f) {
seamless = true
break
}
}
}
if (seamless) {
log("Seamless switch available for ${fps}fps")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
Log.d(TAG, "User preference allows non-seamless switch")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
Log.d(TAG, "Non-seamless switch not allowed, using seamless-only")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
}
}
val modeToUse = modeMatch.mode
if (modeToUse.modeId == currentMode.modeId) {
log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed")
onComplete(false)
return
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
val currentMode = display.mode
var modeToUse = currentMode
for (mode in supportedModes) {
if (mode.physicalHeight != currentMode.physicalHeight ||
mode.physicalWidth != currentMode.physicalWidth) {
continue
}
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
modeToUse = mode
break
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
modeToUse = mode
break
}
}
if (modeToUse != currentMode) {
Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)")
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = modeToUse.modeId
activity.window?.attributes = attrs
}
registerDisplayListener()
}
}
log(
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
)
val window = activity.window
if (window == null) {
log("window unavailable")
onComplete(false)
return
}
registerDisplayListener(fps, extraDelayMs, onComplete)
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
}
}
@@ -1,6 +1,6 @@
package com.edde746.plezy.shared
interface PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
@@ -3,10 +3,10 @@ package com.edde746.plezy.shared
import android.graphics.Color
object ThemeHelper {
fun themeColor(mode: String?): Int? = when (mode) {
"oled" -> Color.BLACK
"dark" -> Color.parseColor("#0E0F12")
"light" -> Color.parseColor("#F7F7F8")
else -> null
}
fun themeColor(mode: String?): Int? = when (mode) {
"oled" -> Color.BLACK
"dark" -> Color.parseColor("#0E0F12")
"light" -> Color.parseColor("#F7F7F8")
else -> null
}
}
@@ -16,167 +16,169 @@ import java.util.concurrent.Executors
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class WatchNextPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
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 null
}
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
}
}
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
}
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
}
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 contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.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 (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
val contentId = pendingDeepLink
pendingDeepLink = null
result.success(contentId)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.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,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
seriesTitle = data["seriesTitle"] as? String,
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
)
}
private var pendingDeepLink: String? = null
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
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")
}
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
}
return null
}
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
}
}
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
}
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
}
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 contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.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 (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
val contentId = pendingDeepLink
pendingDeepLink = null
result.success(contentId)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.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,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
seriesTitle = data["seriesTitle"] as? String,
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
)
}
/**
* 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")
}
}
}
@@ -14,154 +14,151 @@ import androidx.tvprovider.media.tv.WatchNextProgram
*/
class WatchNextProvider(private val context: Context) {
companion object {
private const val TAG = "WatchNextProvider"
}
companion object {
private const val TAG = "WatchNextProvider"
}
data class WatchNextItem(
val contentId: String,
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
val lastEngagementTime: Long,
val seriesTitle: String?,
val seasonNumber: Int?,
val episodeNumber: Int?
data class WatchNextItem(
val contentId: String,
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
val lastEngagementTime: Long,
val seriesTitle: String?,
val seasonNumber: Int?,
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>()
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
)
/**
* 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 {
return try {
val ops = ArrayList<ContentProviderOperation>()
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
}
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
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
}
fun removeItem(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
),
null,
null,
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
)
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
}
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
context.contentResolver.delete(deleteUri, null, null)
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
fun clearAll(): Boolean {
return 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
}
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.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)
}
}
fun removeItem(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
),
null,
null,
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
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
false
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0)
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
else
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.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)
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
}
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)
return builder.build()
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)
return builder.build()
}
}
+12 -6
View File
@@ -1,5 +1,10 @@
require 'dotenv/load'
Dotenv.load("../../.env")
require 'shellwords'
PROJECT_ROOT = File.expand_path("../..", __dir__)
PROJECT_ROOT_ARG = Shellwords.escape(PROJECT_ROOT)
Dotenv.load(File.join(PROJECT_ROOT, ".env"))
default_platform(:android)
@@ -7,7 +12,7 @@ platform :android do
desc "Build and deploy to Google Play Store"
lane :release do
# Get version from pubspec.yaml
pubspec_path = "#{ENV['PWD']}/../pubspec.yaml"
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
@@ -19,11 +24,11 @@ platform :android do
UI.user_error!("Could not extract version from pubspec.yaml")
end
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
git_commit = `git rev-parse --short HEAD`.strip
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/aab")
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
upload_to_play_store(
track: "production",
@@ -33,7 +38,8 @@ platform :android do
)
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
sh("cd #{ENV['PWD']}/.. && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/apk")
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
upload_to_amazon_appstore(
apk_paths: [
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="75 75 363 363"><path fill="#02a9ff" d="M321.92 323.27V136.6c0-10.698-5.887-16.602-16.558-16.602h-36.433c-10.672 0-16.561 5.904-16.561 16.602v88.651c0 2.497 23.996 14.089 24.623 16.541 18.282 71.61 3.972 128.92-13.359 131.6 28.337 1.405 31.455 15.064 10.348 5.731 3.229-38.209 15.828-38.134 52.049-1.406.31.317 7.427 15.282 7.87 15.282h85.545c10.672 0 16.558-5.9 16.558-16.6v-36.524c0-10.698-5.886-16.602-16.558-16.602z"/><path fill="currentColor" d="M170.68 120 74.999 393h74.338l16.192-47.222h80.96L262.315 393h73.968l-95.314-273zm11.776 165.28 23.183-75.629 25.393 75.629z"/></svg>

After

Width:  |  Height:  |  Size: 633 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M24.2116 49.1581C22.6599 46.0424 32.8378 27.5879 35.9999 27.5879C39.1666 27.5895 49.3228 46.0764 47.7882 49.1581C46.2536 52.2398 25.7632 52.2738 24.2116 49.1581Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.481861 64.9951C-4.19479 55.6047 26.4765 0 36 0C45.5328 0 76.153 55.713 71.5274 64.9951C66.9018 74.2773 5.15852 74.3856 0.481861 64.9951ZM12.7358 56.847C15.8005 62.9995 56.2536 62.9314 59.2843 56.847C62.3149 50.761 42.2515 14.2605 36.0093 14.2605C29.767 14.2605 9.67118 50.6944 12.7358 56.847Z"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="25 37 900 900"><path fill="currentColor" d="M360.18,640.05h-93.37v-215.38h-.85l-71.97,95.92-70.16-95.92h-.96v215.38H29.5v-356.23h88.57l75.91,104.66,77.61-104.66h88.58v356.23Z"/><path fill="currentColor" d="M667.97,640.05h-93.37v-112.96h-101.99c.5,36.48,12.42,74.14,35.77,112.96l-75.8,50.14c-11.5-17.25-21.42-35.61-29.76-55.09-8.34-19.48-14.71-39.57-19.11-60.26-4.4-20.69-6.6-41.26-6.6-61.7s2.31-42.21,6.92-62.76c4.61-20.55,11.71-40.12,21.29-58.72,9.58-18.59,21.58-35.1,35.98-49.51,15.83-15.97,32.77-28.19,50.84-36.68,18.06-8.48,36.89-14.21,56.48-17.19,19.59-2.98,42.09-4.47,67.5-4.47h45.78l21.83,80.27h-73.25c-30.38,0-54.35,4.36-71.92,13.08-17.57,8.72-29.85,18.47-36.84,29.24-6.99,10.77-13.65,24.24-19.96,40.4h92.84v-71.76h93.37v264.99Z"/><path fill="currentColor" d="M797.69,559.78h122.54l-21.83,80.28h-194.08v-356.23h93.37v275.96Z"/></svg>

After

Width:  |  Height:  |  Size: 891 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="636.71 89.58 269.72 269.72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M774.47 359.3H677.74L768.67 224.5L677.74 89.58H774.47L865.4 224.5L774.47 359.3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z"/></svg>

After

Width:  |  Height:  |  Size: 481 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>

After

Width:  |  Height:  |  Size: 405 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>

After

Width:  |  Height:  |  Size: 327 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>

After

Width:  |  Height:  |  Size: 578 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>

After

Width:  |  Height:  |  Size: 437 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>

After

Width:  |  Height:  |  Size: 376 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/></svg>

After

Width:  |  Height:  |  Size: 794 B

-15
View File
@@ -1,15 +0,0 @@
## iOS
| Home | Library | Media Card | Season |
|------|---------|------------|--------|
| ![iOS Home - Content discovery and recommendations](ios-home.png) | ![iOS Library - Media collection view](ios-library.png) | ![iOS Media Card](ios-media-card.png) | ![iOS Season View](ios-season.png) |
## macOS
![macOS Home - Content discovery and recommendations](macos-home.png)
![macOS Library - Media collection view](macos-library.png)
![macOS Media Card](macos-media-card.png)
![macOS Season View](macos-season.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="205 196 1124 1124"><path fill="currentColor" d="M205.832 970.284c0 35.322 1.82 65.9 5.433 91.774 6.709 53.176 28.198 94.359 64.507 123.51 33.579 27.302 88.907 45.656 165.984 55.065 63.178 7.983 165.983 11.993 308.45 11.993 234.287 0 377.455-7.982 429.428-23.947 52.859-15.964 90.936-45.77 114.232-89.46 23.295-44.153 34.944-113.446 34.944-207.843 0-84.525-11.907-145.798-35.645-183.858-15.702-25.836-36.64-45.772-62.885-59.885-26.244-14.074-61.52-24.64-105.9-31.697-43.937-7.018-145.452-12.917-304.58-17.622-175.752-5.63-274.576-11.49-296.545-17.584-26.024-7.519-39-28.188-39-62.006 0-35.205 12.312-57.03 36.972-65.514 21.97-7.018 92.115-10.566 210.476-10.566 119.65 0 194.955 1.427 225.92 4.203 34.942 3.317 55.584 18.04 61.85 44.26 2.25 8.908 3.835 24.833 4.72 47.777h271c.368-22.442.589-38.83.589-49.127 0-95.475-21.527-161.676-64.58-198.657-31.847-26.683-83.858-45.386-156.032-56.183-54.7-7.944-148.4-11.915-281.099-11.915-204.024 0-333.149 4.704-387.407 14.075-60.082 10.797-104.942 33.047-134.505 66.748-39.477 44.538-59.198 121.158-59.198 229.823 0 51.555 4.017 93.278 12.09 125.129 14.338 57.649 44.822 98.407 91.451 122.276 36.309 18.741 98.861 29.538 187.622 32.353 34.058.926 108.444 4.204 223.228 9.833 111.172 5.63 184.23 8.445 219.212 8.445 50.645.926 82.678 8.213 96.168 21.787 9.843 9.832 14.783 26.915 14.783 51.286 0 35.63-7.852 58.612-23.518 68.908-11.206 7.018-34.502 11.953-69.887 14.73-18.837.963-87.84 1.658-207.011 2.12-86.954-.462-138.265-.924-153.93-1.387-31.369-.926-53.338-2.584-65.871-4.936-12.532-2.314-23.738-6.44-33.58-12.996-18.835-13.573-28.014-44.036-27.571-91.389H205.832v50.824-.347z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="transparent"/><path fill="currentColor" d="M45.99 14.39 30.17 30.22l-1.46-1.46 16.31-16.32c-.15-.3-.32-.58-.5-.87l-17.2 17.2 2.16 2.16-1.46 1.46-3.62-3.62L43.34 9.82c-.18-.27-.38-.53-.6-.78L23.01 28.78l4.31 4.31-1.46 1.46-14.4-14.4 1.46-1.46 8.62 8.62L41.37 7.46c-.12-.14-.26-.28-.4-.42C36.62 2.69 30.62 0 24 0S11.38 2.69 7.03 7.03C2.69 11.37 0 17.37 0 24c0 13.25 10.75 24 24 24 6.62 0 12.62-2.69 16.97-7.03C45.31 36.64 48 30.64 48 24c0-3.42-.71-6.67-2.01-9.61ZM21.54 25.9l-7.93-7.92 1.46-1.46L23 24.44l-1.46 1.46Zm2.16-2.16-7.93-7.92 1.46-1.46 7.93 7.92-1.46 1.46Zm.3 19.65c-10.7 0-19.39-8.7-19.39-19.39S13.3 4.61 24 4.61c2.91 0 5.72.63 8.33 1.88l-.89 1.88c-2.33-1.11-4.84-1.68-7.43-1.68-9.55 0-17.33 7.77-17.33 17.32s7.78 17.33 17.33 17.33c6.82 0 13.03-4.03 15.82-10.25l1.89.84C38.6 38.9 31.65 43.41 24.01 43.41Z"/></svg>

After

Width:  |  Height:  |  Size: 921 B

+17
View File
@@ -0,0 +1,17 @@
targets:
$default:
builders:
json_serializable:
options:
create_to_json: true
explicit_to_json: true
field_rename: none
include_if_null: true
checked: false
freezed:
options:
copy_with: true
equal: true
to_string: true
slang_build_runner:
enabled: false
+9 -116
View File
@@ -1,141 +1,34 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- file_picker (0.0.1):
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- Flutter (1.0.0)
- in_app_review (2.0.0):
- os_media_controls (0.2.4):
- Flutter
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- Sentry/HybridSDK (8.58.0)
- sentry_flutter (9.15.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.58.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.52.0):
- sqlite3/common (= 3.52.0)
- sqlite3/common (3.52.0)
- sqlite3/dbstatvtab (3.52.0):
- sqlite3/common
- sqlite3/fts5 (3.52.0):
- sqlite3/common
- sqlite3/math (3.52.0):
- sqlite3/common
- sqlite3/perf-threadsafe (3.52.0):
- sqlite3/common
- sqlite3/rtree (3.52.0):
- sqlite3/common
- sqlite3/session (3.52.0):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.52.0)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- universal_gamepad (0.1.0):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- Flutter (from `Flutter`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- Sentry
- sqlite3
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
Flutter:
:path: Flutter
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
universal_gamepad:
:path: ".symlinks/plugins/universal_gamepad/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
sentry_flutter: 8939e491bc1511868118c96cae47d945e6f69798
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
universal_gamepad: e10172778a8a399cce234494968f38724974919e
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
+102 -80
View File
@@ -8,20 +8,22 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
2CA552536A23AFD33344616B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB346A1D0705AB171F80B11B /* Pods_Runner.framework */; };
6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; };
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit */; };
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
702A247AA28B055172318FF9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
748A259343D7FF8DFD2C3910 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -48,24 +50,22 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
5C4660106A040D374746C713 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
6E3FE696F0F85B37E212CCEE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7F4B79C5218D46EDB4E433B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -75,7 +75,11 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
C208E69E3A3B935CE009B4D5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -83,7 +87,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2CA552536A23AFD33344616B /* Pods_RunnerTests.framework in Frameworks */,
748A259343D7FF8DFD2C3910 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -91,23 +95,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */,
702A247AA28B055172318FF9 /* Pods_Runner.framework in Frameworks */,
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
19B1F8004DD75C8EEB4662AB /* Frameworks */ = {
isa = PBXGroup;
children = (
61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */,
200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -116,11 +112,21 @@
path = RunnerTests;
sourceTree = "<group>";
};
3DBA2EB0C0219E797C60DE48 /* Frameworks */ = {
isa = PBXGroup;
children = (
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */,
75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
6A8A46242EDB370C0057B88C /* MpvPlayer */ = {
isa = PBXGroup;
children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */,
@@ -131,12 +137,12 @@
875C8AEFBBA3759A1849D674 /* Pods */ = {
isa = PBXGroup;
children = (
6E3FE696F0F85B37E212CCEE /* Pods-Runner.debug.xcconfig */,
5C4660106A040D374746C713 /* Pods-Runner.release.xcconfig */,
C208E69E3A3B935CE009B4D5 /* Pods-Runner.profile.xcconfig */,
030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */,
230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */,
6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */,
7F4B79C5218D46EDB4E433B0 /* Pods-Runner.debug.xcconfig */,
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */,
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */,
28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */,
3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */,
792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -144,6 +150,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -161,7 +168,7 @@
331C8082294A63A400263BE5 /* RunnerTests */,
875C8AEFBBA3759A1849D674 /* Pods */,
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */,
19B1F8004DD75C8EEB4662AB /* Frameworks */,
3DBA2EB0C0219E797C60DE48 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -196,7 +203,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
C5965E09A75FF4B9D47A6A5A /* [CP] Check Pods Manifest.lock */,
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
582EBCC1C90BEBEC365D972D /* Frameworks */,
@@ -212,17 +219,20 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
A7BAF2DBCB81960BDC752F05 /* [CP] Check Pods Manifest.lock */,
6838C6976E568CE05DBDE228 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
87CA3559B76E3AB2BA77B235 /* [CP] Embed Pods Frameworks */,
898098BAEC57934A6822754B /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -263,6 +273,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -313,7 +324,51 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
87CA3559B76E3AB2BA77B235 /* [CP] Embed Pods Frameworks */ = {
6838C6976E568CE05DBDE228 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
898098BAEC57934A6822754B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -345,50 +400,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
A7BAF2DBCB81960BDC752F05 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
C5965E09A75FF4B9D47A6A5A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -406,6 +417,7 @@
files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
@@ -524,7 +536,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */;
baseConfigurationReference = 28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -542,7 +554,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */;
baseConfigurationReference = 3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -558,7 +570,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */;
baseConfigurationReference = 792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -777,7 +789,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = b2179eec26c6ceda339e6c6b512e457341768761;
revision = 1b0134a2ea04a3b967f61a726b5864351280b420;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -788,7 +800,17 @@
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = MPVKit;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,14 +1,84 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "csqlite",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simolus3/CSQLite.git",
"state" : {
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
"revision" : "1b0134a2ea04a3b967f61a726b5864351280b420"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 3
"version" : 2
}
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -1,14 +1,84 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "csqlite",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simolus3/CSQLite.git",
"state" : {
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
"revision" : "1b0134a2ea04a3b967f61a726b5864351280b420"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 3
"version" : 2
}
+30 -6
View File
@@ -24,6 +24,8 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -33,12 +35,12 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_plexmediasvr._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>This app needs to connect to your Plex Media Server on your local network.</string>
<string>This app needs to connect to your media servers on your local network.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Plezy uses location access when iOS requires it to detect your local network and connect to your media servers.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Plezy needs photo library access so you can choose images to upload as media artwork.</string>
<key>NSCameraUsageDescription</key>
<string>Camera is used to scan QR codes for Companion Remote pairing.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
@@ -70,7 +72,7 @@
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
@@ -86,6 +88,28 @@
<string>Main</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplay</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>external-display</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplayNonInteractive</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>external-display</string>
</dict>
</array>
</dict>
</dict>
</dict>
@@ -0,0 +1,172 @@
import UIKit
final class ExternalDisplayManager {
static let shared = ExternalDisplayManager()
private weak var activeCore: MpvPlayerCore?
private var externalScene: UIWindowScene?
private var externalWindow: UIWindow?
private var externalRootViewController: UIViewController?
private init() {}
static func isExternalDisplayRole(_ role: UISceneSession.Role) -> Bool {
role.rawValue == "UIWindowSceneSessionRoleExternalDisplay"
|| role.rawValue == "UIWindowSceneSessionRoleExternalDisplayNonInteractive"
}
static var hasActiveApplicationScene: Bool {
applicationWindowScenes.contains { $0.activationState == .foregroundActive }
}
static func mainApplicationWindow() -> UIWindow? {
let scenes = applicationWindowScenes.sorted {
scenePriority($0.activationState) < scenePriority($1.activationState)
}
for scene in scenes {
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
return window
}
}
for scene in scenes {
if let window = scene.windows.first(where: { !$0.isHidden }) {
return window
}
}
return scenes.first?.windows.first
}
private static var applicationWindowScenes: [UIWindowScene] {
UIApplication.shared.connectedScenes.compactMap { scene in
guard let windowScene = scene as? UIWindowScene,
!isExternalDisplayRole(windowScene.session.role)
else { return nil }
return windowScene
}
}
private static func scenePriority(_ state: UIScene.ActivationState) -> Int {
switch state {
case .foregroundActive:
return 0
case .foregroundInactive:
return 1
case .background:
return 2
case .unattached:
return 3
@unknown default:
return 4
}
}
var videoSuperview: UIView? {
externalRootViewController?.view
}
func attach(core: MpvPlayerCore) {
activeCore = core
discoverExistingExternalScene()
activeCore?.externalDisplayDidChange()
}
func detach(core: MpvPlayerCore) {
if activeCore === core {
activeCore = nil
}
}
@discardableResult
func connect(scene: UIWindowScene) -> UIWindow? {
guard Self.isExternalDisplayRole(scene.session.role) else { return nil }
externalScene = scene
let window = ensureWindow(for: scene)
activeCore?.externalDisplayDidChange()
print("[ExternalDisplayManager] External display connected")
return window
}
func disconnect(scene: UIScene) {
guard scene === externalScene else { return }
externalWindow?.isHidden = true
externalWindow = nil
externalRootViewController = nil
externalScene = nil
activeCore?.externalDisplayDidChange()
print("[ExternalDisplayManager] External display disconnected")
}
func update(scene: UIWindowScene) {
guard scene === externalScene else { return }
externalRootViewController?.view.frame = scene.coordinateSpace.bounds
activeCore?.externalDisplayDidChange()
}
private func discoverExistingExternalScene() {
guard externalScene == nil else { return }
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene,
Self.isExternalDisplayRole(windowScene.session.role)
else { continue }
_ = connect(scene: windowScene)
return
}
}
private func ensureWindow(for scene: UIWindowScene) -> UIWindow {
if let existing = externalWindow, existing.windowScene === scene {
return existing
}
externalWindow?.isHidden = true
let rootViewController = UIViewController()
rootViewController.view.frame = scene.coordinateSpace.bounds
rootViewController.view.backgroundColor = .black
let window = UIWindow(windowScene: scene)
window.rootViewController = rootViewController
window.backgroundColor = .black
window.isHidden = false
externalRootViewController = rootViewController
externalWindow = window
return window
}
}
class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene,
ExternalDisplayManager.isExternalDisplayRole(session.role)
else { return }
window = ExternalDisplayManager.shared.connect(scene: windowScene)
}
func sceneDidDisconnect(_ scene: UIScene) {
ExternalDisplayManager.shared.disconnect(scene: scene)
window = nil
}
func windowScene(
_ windowScene: UIWindowScene,
didUpdate previousCoordinateSpace: UICoordinateSpace,
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation,
traitCollection previousTraitCollection: UITraitCollection
) {
ExternalDisplayManager.shared.update(scene: windowScene)
}
}
+178 -242
View File
@@ -1,8 +1,44 @@
import AVFoundation
import AVKit
import UIKit
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
#if os(tvOS)
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
// and is not supported by the Plezy flow. Provide a no-op shell so callers
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
// never attempted at runtime.
protocol MpvPipDelegate: AnyObject {
func pipWillStart()
func pipDidStart()
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
func pipSetPlaying(_ playing: Bool)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
var isPipPlaying: Bool { get }
var pipDuration: Double { get }
}
class MpvPipController: NSObject {
static var isSupported: Bool { false }
weak var delegate: MpvPipDelegate?
var isPipActive: Bool { false }
var autoStartEnabled: Bool { false }
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {}
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
completion(false)
}
func stopPip() {}
func invalidatePlaybackState() {}
func syncTimebase(currentTime: Double, isPlaying: Bool) {}
func teardown() {}
}
#else
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
/// Called when PiP is about to start (system or app-initiated)
func pipWillStart()
func pipDidStart()
@@ -12,358 +48,258 @@ protocol MpvPipDelegate: AnyObject {
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
/// Get total duration in seconds
var pipDuration: Double { get }
}
}
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
weak var delegate: MpvPipDelegate?
/// Pointer to the sample buffer layer for passing to mpv as `wid`
var layerPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
}
// MARK: - Initialization
override init() {
super.init()
setup()
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
self.sampleBufferLayer = sampleBufferDisplayLayer
super.init()
setup()
}
private func setup() {
guard #available(iOS 15.0, *) else { return }
guard #available(iOS 15.0, *) else { return }
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
let view = UIView(frame: window.bounds)
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
createPipController()
createPipController()
}
/// Helper that conforms to the iOS 15+ delegate protocols
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *) else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = helper
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = helper
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
func setAutoStart(_ enabled: Bool) {
guard #available(iOS 14.2, *) else { return }
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
guard #available(iOS 14.2, *) else { return }
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
}
/// Push a black frame to the sample buffer layer so PiP has content
/// to display immediately (before vo_pip decodes the first real frame).
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault, Int(width), Int(height),
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
)
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
// Fill with black
CVPixelBufferLockBaseAddress(pb, [])
if let base = CVPixelBufferGetBaseAddress(pb) {
memset(base, 0, CVPixelBufferGetDataSize(pb))
}
CVPixelBufferUnlockBaseAddress(pb, [])
// Use current timebase time for PTS (if available) so the frame isn't stale
let pts: CMTime
if let tb = sampleBufferLayer.controlTimebase {
pts = CMTimebaseGetTime(tb)
} else {
pts = CMTime(value: 0, timescale: 30)
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
// Create format description and sample buffer
var formatDesc: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescriptionOut: &formatDesc
)
guard let fmt = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescription: fmt,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard let sb = sampleBuffer else { return }
// Set DisplayImmediately so it shows regardless of timebase timing
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true) as? [NSMutableDictionary],
let dict = attachments.first {
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
}
sampleBufferLayer.enqueue(sb)
}
/// Sync the layer's controlTimebase with the actual playback position.
/// This makes the PiP progress bar show the correct time.
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
func syncTimebase(currentTime: Double, isPlaying: Bool) {
guard let timebase = sampleBufferLayer.controlTimebase else { return }
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
CMTimebaseSetTime(timebase, time: cmTime)
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
}
/// Ensure the layer has a timebase and blank frame so the system considers
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
/// Ensure the layer has MPVKit's renderer-owned timebase before PiP starts.
func warmLayer(currentTime: Double, isPlaying: Bool) {
if sampleBufferLayer.controlTimebase == nil {
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
allocator: kCFAllocatorDefault,
sourceClock: CMClockGetHostTimeClock(),
timebaseOut: &timebase
)
if let tb = timebase {
sampleBufferLayer.controlTimebase = tb
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
if sampleBufferLayer?.controlTimebase == nil {
print("[MpvPipController] Waiting for MPVKit renderer timebase before PiP")
}
}
// MARK: - Public API
static var isSupported: Bool {
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
}
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
/// readiness check since the scene is about to deactivate.
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
guard let pipController = pipController else {
completion(false)
return
guard let pipController = pipController else {
completion(false)
return
}
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
} else {
hasFrame = true
}
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = sampleBufferLayer.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = sampleBufferLayer.isReadyForDisplay
} else {
hasFrame = true
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
attempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
} else {
print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))")
completion(false)
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
attempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
} else {
print(
"[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))"
)
completion(false)
}
tryStart()
}
tryStart()
}
func stopPip() {
pipController?.stopPictureInPicture()
pipController?.stopPictureInPicture()
}
/// Invalidate the playback state so PiP updates its UI (play/pause button)
func invalidatePlaybackState() {
pipController?.invalidatePlaybackState()
guard #available(iOS 15.0, *) else { return }
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP removes the container view from the window and
/// destroys the AVPictureInPictureController so the system can no longer
/// trigger auto-PiP after the player is disposed.
/// Fully tear down PiP without touching the shared inline display layer.
func teardown() {
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = nil
delegateHelper = nil
sampleBufferLayer.flushAndRemoveImage()
sampleBufferLayer.controlTimebase = nil
sampleBufferLayer.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = nil
delegateHelper = nil
}
/// Flush enqueued sample buffers from the layer to free video frame memory
func flushLayer() {
sampleBufferLayer.flushAndRemoveImage()
}
}
}
// MARK: - PiP Delegate Helper (iOS 15+)
// MARK: - PiP Delegate Helper (iOS 15+)
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
AVPictureInPictureSampleBufferPlaybackDelegate
{
{
weak var controller: MpvPipController?
private var isRestoring = false
init(controller: MpvPipController) {
self.controller = controller
super.init()
self.controller = controller
super.init()
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:
@escaping (Bool) -> Void
) {
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will stop")
print("[MpvPipController] PiP will stop")
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
}
func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
let duration = controller?.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
let duration = controller?.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
return !(controller?.delegate?.isPipPlaying ?? false)
return !(controller?.delegate?.isPipPlaying ?? false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
) {
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
controller?.delegate?.pipSkip(byInterval: seconds)
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
guard let delegate = controller?.delegate else {
completionHandler()
return
}
delegate.pipSkip(byInterval: seconds, completion: completionHandler)
}
}
}
#endif // !os(tvOS)
+597 -155
View File
@@ -1,196 +1,638 @@
import Libmpv
import AVFoundation
#if os(tvOS)
import AVKit
#endif
import QuartzCore
import UIKit
/// Core MPV player using Metal rendering for iOS.
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
class MpvPlayerCore: MpvPlayerCoreBase {
private var containerView: UIView?
private weak var window: UIWindow?
private var containerView: UIView?
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
private var isDisposed = false
private var activeDisplayCriteriaKey: String?
var isPipStarting = false
var isPipStarting = false
func initialize(in window: UIWindow) -> Bool {
guard !isInitialized else {
print("[MpvPlayerCore] Already initialized")
return true
}
self.window = window
let container = UIView(frame: window.bounds)
container.backgroundColor = .clear
container.isUserInteractionEnabled = false
let layer = MpvMetalLayer()
layer.frame = container.bounds
layer.contentsScale = UIScreen.main.nativeScale
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
container.layer.addSublayer(layer)
containerView = container
metalLayer = layer
window.insertSubview(container, at: 0)
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
container.removeFromSuperview()
metalLayer = nil
containerView = nil
return false
}
setupNotifications()
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
return true
func initialize(in window: UIWindow) -> Bool {
guard !isInitialized else {
print("[MpvPlayerCore] Already initialized")
return true
}
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard let mpv else { return false }
self.window = window
print("[MpvPlayerCore] Switching to pip VO for PiP")
let container = UIView(frame: window.bounds)
container.backgroundColor = .black
container.isUserInteractionEnabled = false
metalLayer?.removeFromSuperlayer()
let layer = MpvVideoLayer()
layer.frame = container.bounds
layer.contentsScale = window.screen.nativeScale
layer.isOpaque = true
layer.backgroundColor = UIColor.black.cgColor
layer.videoGravity = .resizeAspect
mpv_set_property_string(mpv, "vid", "no")
container.layer.addSublayer(layer)
containerView = container
videoLayer = layer
var pointer = Int64(Int(bitPattern: layerPtr))
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer)
window.insertSubview(container, at: 0)
mpv_set_property_string(mpv, "vo", "pip")
mpv_set_property_string(mpv, "vid", "auto")
print("[MpvPlayerCore] Switched to pip VO successfully")
return true
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
container.removeFromSuperview()
videoLayer = nil
containerView = nil
return false
}
func switchToGpuNextVO() -> Bool {
guard let mpv, let metalLayer else { return false }
setupNotifications()
#if os(iOS)
ExternalDisplayManager.shared.attach(core: self)
#endif
print("[MpvPlayerCore] Switching back to gpu-next VO")
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
return true
}
mpv_set_property_string(mpv, "vid", "no")
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
var layer = metalLayer
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
func setVisible(_ visible: Bool) {
guard containerView != nil else { return }
applyGpuNextOptions()
mpv_set_property_string(mpv, "vid", "auto")
isVisible = visible
if visible { refreshExternalDisplayAttachment() }
setContainerHidden(!visible)
if !visible { mainBlankView?.isHidden = true }
}
if metalLayer.superlayer == nil, let containerView {
containerView.layer.addSublayer(metalLayer)
}
func updateFrame(_ frame: CGRect? = nil) {
guard let videoLayer, let containerView else { return }
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
return true
withoutLayerAnimations {
if let frame {
containerView.frame = frame
videoLayer.frame = containerView.bounds
} else if let superview = containerView.superview {
containerView.frame = superview.bounds
videoLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
videoLayer.frame = containerView.bounds
}
mainBlankView?.frame = window?.bounds ?? .zero
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
videoLayer.contentsScale = scale
}
func setVisible(_ visible: Bool) {
guard let containerView else { return }
#if os(iOS)
updateEDRMode(sigPeak: lastSigPeak)
#endif
}
if visible {
containerView.removeFromSuperview()
window?.insertSubview(containerView, at: 0)
}
func externalDisplayDidChange() {
refreshExternalDisplayAttachment()
}
containerView.isHidden = !visible
private func refreshExternalDisplayAttachment() {
guard containerView != nil else { return }
let externalSuperview = externalVideoSuperview
if let externalSuperview {
moveContainerView(to: externalSuperview)
setMainBlankViewVisible(true)
} else if isVisible, let window {
moveContainerView(to: window)
setMainBlankViewVisible(false)
} else {
setMainBlankViewVisible(false)
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer, let containerView else { return }
setContainerHidden(!isVisible)
updateFrame()
}
if let frame {
containerView.frame = frame
metalLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
metalLayer.frame = containerView.bounds
private var externalVideoSuperview: UIView? {
#if os(iOS)
isVisible && !isPipActive && !isPipStarting
? ExternalDisplayManager.shared.videoSuperview
: nil
#else
nil
#endif
}
private func moveContainerView(to superview: UIView) {
guard let containerView else { return }
withoutLayerAnimations {
if containerView.superview !== superview {
containerView.removeFromSuperview()
superview.insertSubview(containerView, at: 0)
} else if superview.subviews.first !== containerView {
superview.insertSubview(containerView, at: 0)
}
containerView.frame = superview.bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
}
private func setMainBlankViewVisible(_ visible: Bool) {
guard visible, let window else {
mainBlankView?.removeFromSuperview()
mainBlankView = nil
return
}
let blankView = mainBlankView ?? UIView(frame: window.bounds)
withoutLayerAnimations {
blankView.backgroundColor = .black
blankView.isUserInteractionEnabled = false
blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
blankView.frame = window.bounds
if blankView.superview !== window {
blankView.removeFromSuperview()
window.insertSubview(blankView, at: 0)
} else if window.subviews.first !== blankView {
window.insertSubview(blankView, at: 0)
}
blankView.isHidden = false
}
mainBlankView = blankView
}
private func setContainerHidden(_ hidden: Bool) {
withoutLayerAnimations {
containerView?.isHidden = hidden
}
}
private func withoutLayerAnimations(_ updates: () -> Void) {
CATransaction.begin()
CATransaction.setDisableActions(true)
updates()
CATransaction.commit()
}
/// Nudge mpv to present the current paused frame after leaving PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
}
private func restoreVideoPresentation() {
guard !isPipActive else { return }
refreshExternalDisplayAttachment()
recoverDisplayLayerIfNeeded()
updateEDRMode(sigPeak: lastSigPeak)
if isPaused { forceDraw() }
}
private func recoverDisplayLayerIfNeeded() {
guard let videoLayer else { return }
var requiresFlush = false
if #available(iOS 14.0, tvOS 14.0, *) {
requiresFlush = videoLayer.requiresFlushToResumeDecoding
}
let status = videoLayer.status
guard requiresFlush || status == .failed else { return }
videoLayer.flush()
}
override func updateEDRMode(sigPeak: Double) {
#if os(iOS)
guard let videoLayer else { return }
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0
if #available(iOS 26.0, *) {
withoutLayerAnimations {
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
}
} else if #available(iOS 17.0, *) {
withoutLayerAnimations {
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}
}
#endif
}
let scale = UIScreen.main.nativeScale
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
@discardableResult
override func updateDisplayCriteria(
doviProfile: Int64,
doviLevel: Int64,
doviCompatibilityId: Int64?,
fps: Double,
width: Int32,
height: Int32,
sigPeak: Double,
gamma: String?,
primaries: String?,
colorMatrix: String?
) -> Bool {
#if os(tvOS)
guard let window = containerView?.window ?? self.window else { return false }
let displayManager = window.avDisplayManager
if width <= 0 || height <= 0 {
clearDisplayCriteria(displayManager, reason: "no video dimensions")
return false
}
let refreshRate = Float(fps > 0 ? fps : 0)
let sourceHasDolbyVision = doviProfile > 0
guard sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil else {
clearDisplayCriteria(displayManager, reason: "no display metadata")
return false
}
let sourceBaseRange = Self.resolveBaseDisplayDynamicRange(
sigPeak: sigPeak,
gamma: gamma,
primaries: primaries,
colorMatrix: colorMatrix,
doviCompatibilityId: doviCompatibilityId
)
let sourceRange: DisplayDynamicRange = sourceHasDolbyVision ? .dolbyVision : sourceBaseRange
// The HDR toggle (`hdrEnabled`) is authoritative on tvOS: when it's off,
// drive the HDMI link in SDR regardless of the source Dolby Vision
// included so turning HDR off actually leaves DV mode (issue #1262).
// This is the only path that gates the HDMI mode on tvOS;
// target-colorspace-hint is inert in the avfoundation VO and EDR is iOS.
var displayRange: DisplayDynamicRange
if !hdrEnabled {
displayRange = .sdr
} else if sourceHasDolbyVision {
displayRange = .dolbyVision
} else {
displayRange = Self.supportedDisplayDynamicRange(for: sourceBaseRange)
}
guard displayManager.isDisplayCriteriaMatchingEnabled else {
clearDisplayCriteria(displayManager, reason: "matching disabled")
return false
}
guard #available(tvOS 17.0, *) else {
clearDisplayCriteria(displayManager, reason: "display criteria unavailable")
return false
}
var formatDescription = Self.makeDisplayFormatDescription(
dynamicRange: displayRange,
width: width,
height: height,
doviProfile: doviProfile,
doviLevel: doviLevel,
doviCompatibilityId: doviCompatibilityId)
if formatDescription == nil, sourceHasDolbyVision, hdrEnabled {
displayRange = sourceBaseRange
formatDescription = Self.makeDisplayFormatDescription(
dynamicRange: displayRange,
width: width,
height: height,
doviProfile: doviProfile,
doviLevel: doviLevel,
doviCompatibilityId: doviCompatibilityId)
}
guard let formatDescription else {
clearDisplayCriteria(displayManager, reason: "format description failed")
return false
}
let criteriaKey =
"\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)"
if activeDisplayCriteriaKey == criteriaKey { return true }
displayManager.preferredDisplayCriteria = AVDisplayCriteria(
refreshRate: refreshRate,
formatDescription: formatDescription
)
activeDisplayCriteriaKey = criteriaKey
print(
"[MpvPlayerCore] preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
)
return true
#else
return false
#endif
}
#if os(tvOS)
private enum DisplayDynamicRange: String {
case sdr = "SDR"
case hdr10 = "HDR10"
case hlg = "HLG"
case dolbyVision = "Dolby Vision"
}
private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) {
if activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
displayManager.preferredDisplayCriteria = nil
activeDisplayCriteriaKey = nil
print("[MpvPlayerCore] preferredDisplayCriteria cleared (\(reason))")
}
}
private static func resolveBaseDisplayDynamicRange(
sigPeak: Double,
gamma: String?,
primaries: String?,
colorMatrix: String?,
doviCompatibilityId: Int64?
) -> DisplayDynamicRange {
let normalizedGamma = normalizeColorTag(gamma)
let normalizedPrimaries = normalizeColorTag(primaries)
let normalizedColorMatrix = normalizeColorTag(colorMatrix)
if normalizedGamma.contains("hlg") || normalizedGamma.contains("arib") {
return .hlg
}
if normalizedGamma.contains("pq") || normalizedGamma.contains("smpte2084")
|| normalizedGamma.contains("st2084") || sigPeak > 1.0
|| normalizedPrimaries.contains("bt2020") || normalizedColorMatrix.contains("bt2020")
{
return .hdr10
}
switch doviCompatibilityId {
case 1, 6:
return .hdr10
case 4:
return .hlg
case 2:
return .sdr
default:
break
}
return .sdr
}
private static func normalizeColorTag(_ value: String?) -> String {
value?.lowercased().filter { $0.isLetter || $0.isNumber } ?? ""
}
private static func supportedDisplayDynamicRange(for range: DisplayDynamicRange) -> DisplayDynamicRange {
let availableModes = AVPlayer.availableHDRModes
switch range {
case .dolbyVision:
if availableModes.contains(.dolbyVision) { return .dolbyVision }
if availableModes.contains(.hdr10) { return .hdr10 }
if availableModes.contains(.hlg) { return .hlg }
return .sdr
case .hdr10:
return availableModes.contains(.hdr10) ? .hdr10 : .sdr
case .hlg:
return availableModes.contains(.hlg) ? .hlg : .sdr
case .sdr:
return .sdr
}
}
private static func makeDisplayFormatDescription(
dynamicRange: DisplayDynamicRange,
width: Int32,
height: Int32,
doviProfile: Int64,
doviLevel: Int64,
doviCompatibilityId: Int64?
) -> CMVideoFormatDescription? {
if dynamicRange == .dolbyVision {
// Profile 8.x always carries a compatibility id; profile 5 has none.
// We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8
// because mpv does not expose the compat id and that's by far the
// most common case.
let fallbackCompat: Int64 = doviProfile == 8 ? 1 : 0
let compat = UInt8(truncatingIfNeeded: doviCompatibilityId ?? fallbackCompat)
return makeDolbyVisionFormatDescription(
width: width,
height: height,
profile: UInt8(truncatingIfNeeded: doviProfile),
level: UInt8(truncatingIfNeeded: doviLevel),
compatibility: compat
)
}
let extensions: [CFString: Any]
switch dynamicRange {
case .hdr10:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
case .hlg:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
case .sdr:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_709_2,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_ITU_R_709_2,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2,
]
case .dolbyVision:
return nil
}
var fd: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
codecType: kCMVideoCodecType_HEVC,
width: width,
height: height,
extensions: extensions as CFDictionary,
formatDescriptionOut: &fd
)
return status == noErr ? fd : nil
}
/// Nudge mpv to present the current paused frame after switching back from PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
/// Build a synthetic 'dvh1' `CMVideoFormatDescription` from the Dolby Vision
/// metadata mpv exposes. Used solely as a hint object for
/// `AVDisplayCriteria(refreshRate:formatDescription:)` it is never
/// enqueued onto the sample-buffer layer.
private static func makeDolbyVisionFormatDescription(
width: Int32,
height: Int32,
profile: UInt8,
level: UInt8,
compatibility: UInt8
) -> CMVideoFormatDescription? {
// 24-byte Dolby Vision configuration record (dvcC profile 7, dvvC 8).
// Layout from ETSI TS 103 572 §7.1.1 same packing as FFmpeg's
// videotoolbox_dovi_extradata_create (in 0002 patch):
// [0] dv_version_major (= 1)
// [1] dv_version_minor (= 0)
// [2..3] big-endian uint16: profile<<9 | level<<3 | rpu<<2 | el<<1 | bl
// [4] compatibility<<4 | md_compression<<2
// [5..23] reserved zero
var dovi = [UInt8](repeating: 0, count: 24)
dovi[0] = 1
dovi[1] = 0
let flags: UInt16 =
(UInt16(profile) & 0x7f) << 9
| (UInt16(level) & 0x3f) << 3
| (1 << 2) // rpu_present_flag
| (1 << 0) // bl_present_flag
dovi[2] = UInt8((flags >> 8) & 0xff)
dovi[3] = UInt8(flags & 0xff)
dovi[4] = (compatibility & 0x0f) << 4
// CoreMedia carries codec-specific boxes under
// kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms.
let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString
let atoms: [CFString: Any] = [recordKey: Data(dovi) as CFData]
let extensions: [CFString: Any] = [
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: atoms as CFDictionary,
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
var fd: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
codecType: kCMVideoCodecType_DolbyVisionHEVC, // 'dvh1'
width: width,
height: height,
extensions: extensions as CFDictionary,
formatDescriptionOut: &fd
)
return status == noErr ? fd : nil
}
#endif
func dispose() {
// Guard double-dispose: the plugin calls dispose() then drops the
// strong ref, which fires deinit dispose() again. The second call
// would re-enter and crash on weak-ref formation during dealloc.
guard !isDisposed else { return }
isDisposed = true
// Reset the HDMI mode hint synchronously while self is still alive
// and on main. An async-to-main dispatch here would be drained after
// dealloc (the plugin sets playerCore = nil right after this call
// returns), leaving the link stuck at the last clip's refresh rate.
updateDisplayCriteria(
doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil,
fps: 0, width: 0, height: 0, sigPeak: 0,
gamma: nil, primaries: nil, colorMatrix: nil)
NotificationCenter.default.removeObserver(self)
#if os(iOS)
ExternalDisplayManager.shared.detach(core: self)
#endif
disposeSharedState(destroySynchronously: false)
videoLayer?.removeFromSuperlayer()
videoLayer = nil
containerView?.removeFromSuperview()
containerView = nil
mainBlankView?.removeFromSuperview()
mainBlankView = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
private func setupNotifications() {
#if os(iOS)
let scene = window?.windowScene
NotificationCenter.default.addObserver(
self,
selector: #selector(enterBackground),
name: UIScene.didEnterBackgroundNotification,
object: scene
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enterForeground),
name: UIScene.willEnterForegroundNotification,
object: scene
)
NotificationCenter.default.addObserver(
self,
selector: #selector(sceneDidActivate),
name: UIScene.didActivateNotification,
object: scene
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(enterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#endif
}
@objc private func enterBackground() {
isBackgrounded = true
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
}
override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }
print("[MpvPlayerCore] Entering background - disabling video")
setProperty("vid", value: "no")
}
var edrHeadroom: CGFloat = 1.0
if #available(iOS 16.0, *) {
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
metalLayer.wantsExtendedDynamicRangeContent =
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
}
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
)
@objc private func enterForeground() {
isBackgrounded = false
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
}
func dispose() {
NotificationCenter.default.removeObserver(self)
disposeSharedState(destroySynchronously: false)
print("[MpvPlayerCore] Entering foreground - enabling video")
setProperty("vid", value: "auto")
}
metalLayer?.removeFromSuperlayer()
metalLayer = nil
containerView?.removeFromSuperview()
containerView = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(enterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
@objc private func enterBackground() {
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
}
print("[MpvPlayerCore] Entering background - disabling video")
if mpv != nil {
mpv_set_option_string(mpv, "vid", "no")
}
}
@objc private func enterForeground() {
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
}
print("[MpvPlayerCore] Entering foreground - enabling video")
if mpv != nil {
mpv_set_option_string(mpv, "vid", "auto")
}
#if os(iOS)
@objc private func sceneDidActivate() {
isBackgrounded = false
if isPipActive {
return
}
setProperty("vid", value: "auto")
restoreVideoPresentation()
}
#endif
}
+481 -352
View File
@@ -5,392 +5,521 @@ import AVKit
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
// MARK: - Properties
// MARK: - Properties
private var playerCore: MpvPlayerCore?
var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar?
var nameToId: [String: Int] = [:]
private var playerCore: MpvPlayerCore?
var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar?
var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() }
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP
private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false
private var isManualPipRequest = false
private var pipTimebaseSyncTimer: Timer?
private var pendingInlineRestoreAfterPip = false
private var sceneActivationObserverRegistered = false
// PiP
private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false
private var isManualPipRequest = false
private var pipTimebaseSyncTimer: Timer?
private var pendingInlineRestoreAfterPip = false
private var sceneActivationObserverRegistered = false
// MARK: - FlutterPlugin Registration
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger()
)
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger()
)
let pipChannel = FlutterMethodChannel(
name: "com.plezy/pip",
binaryMessenger: registrar.messenger()
)
static func register(with registrar: FlutterPluginRegistrar) {
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger()
)
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger()
)
let pipChannel = FlutterMethodChannel(
name: "com.plezy/pip",
binaryMessenger: registrar.messenger()
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
instance.pipChannel = pipChannel
let instance = MpvPlayerPlugin()
instance.registrar = registrar
instance.pipChannel = pipChannel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
pipChannel.setMethodCallHandler(instance.handlePipCall)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
pipChannel.setMethodCallHandler(instance.handlePipCall)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
// MARK: - FlutterPlugin Method Handler
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "setDisplayCriteria":
handleSetDisplayCriteria(call: call, result: result)
case "setVisible":
handleSetVisible(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
case "updateFrame":
handleUpdateFrame(result: result)
case "setLogLevel":
handleSetLogLevel(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - FlutterStreamHandler
// MARK: - PiP
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
private func ensurePipController() -> MpvPipController? {
if let existing = pipController { return existing }
guard let layer = playerCore?.sampleBufferDisplayLayer else { return nil }
let controller = MpvPipController(sampleBufferDisplayLayer: layer)
controller.delegate = self
pipController = controller
return controller
}
private func registerSceneActivationObserver() {
guard !sceneActivationObserverRegistered else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(sceneDidActivate),
name: UIScene.didActivateNotification,
object: nil
)
sceneActivationObserverRegistered = true
}
private func unregisterSceneActivationObserver() {
guard sceneActivationObserverRegistered else { return }
NotificationCenter.default.removeObserver(
self, name: UIScene.didActivateNotification, object: nil)
sceneActivationObserverRegistered = false
}
private var isSceneActive: Bool {
#if os(iOS)
ExternalDisplayManager.hasActiveApplicationScene
#else
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
#endif
}
private func restoreInlinePlayerAfterPip() {
guard pendingInlineRestoreAfterPip,
let playerCore = playerCore,
!playerCore.isPipActive,
!playerCore.isPipStarting
else { return }
print("[MpvPlayerPlugin] Restoring inline player after PiP")
playerCore.setVisible(true)
playerCore.updateFrame()
if playerCore.isPaused {
playerCore.forceDraw()
}
pendingInlineRestoreAfterPip = false
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
// MARK: - FlutterPlugin Method Handler
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "setVisible":
handleSetVisible(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
case "updateFrame":
handleUpdateFrame(result: result)
case "setLogLevel":
handleSetLogLevel(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - PiP
private func ensurePipController() -> MpvPipController {
if let existing = pipController { return existing }
let controller = MpvPipController()
controller.delegate = self
pipController = controller
return controller
}
private func registerSceneActivationObserver() {
guard !sceneActivationObserverRegistered else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(sceneDidActivate),
name: UIScene.didActivateNotification,
object: nil
)
sceneActivationObserverRegistered = true
}
private func unregisterSceneActivationObserver() {
guard sceneActivationObserverRegistered else { return }
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
sceneActivationObserverRegistered = false
}
private var isSceneActive: Bool {
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
}
private func restoreInlinePlayerAfterPip() {
guard pendingInlineRestoreAfterPip,
let playerCore = playerCore,
!playerCore.isPipActive,
!playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] Restoring inline player after PiP")
playerCore.setVisible(true)
playerCore.updateFrame()
if playerCore.isPaused {
playerCore.forceDraw()
}
pendingInlineRestoreAfterPip = false
}
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
self.enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
let pip = self.ensurePipController()
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
} else {
self.pipController?.setAutoStart(false)
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
self.enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
guard let pip = self.ensurePipController() else {
result(nil)
return
}
}
}
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func switchToPipAndPrepare() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
let pip = ensurePipController()
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.isPipStarting = true
pip.pushBlankFrame()
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
}
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard MpvPipController.isSupported else {
result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"])
return
}
guard playerCore != nil else {
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
return
}
guard let pip = switchToPipAndPrepare() else {
result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"])
return
}
isManualPipRequest = manual
pip.startPip(waitForFrame: manual) { [weak self] started in
if started {
result?(["success": true])
} else {
self?.cleanupPip(notify: false)
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"])
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
} else {
self.pipController?.setAutoStart(false)
}
}
}
/// Unified cleanup for all PiP exit paths
private func cleanupPip(notify: Bool, pause: Bool = false) {
playerCore?.isPipStarting = false
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
pipController?.flushLayer()
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
if pause { playerCore?.setProperty("pause", value: "yes") }
pendingInlineRestoreAfterPip = restoredInlineVO
if pendingInlineRestoreAfterPip {
if isSceneActive {
restoreInlinePlayerAfterPip()
} else {
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
}
}
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
}
/// Scene became active restore inline playback if needed and re-warm the
/// sample-buffer layer so future auto-PiP remains possible.
@objc private func sceneDidActivate() {
restoreInlinePlayerAfterPip()
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
}
// MARK: - Timebase Sync
private func syncPipTimebase() {
guard let playerCore = playerCore, let pipController = pipController else { return }
pipController.syncTimebase(
currentTime: playerCore.timePos,
isPlaying: !playerCore.isPaused
)
}
private func startPipTimebaseSync() {
stopPipTimebaseSync()
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.syncPipTimebase()
}
}
private func stopPipTimebaseSync() {
pipTimebaseSyncTimer?.invalidate()
pipTimebaseSyncTimer = nil
}
// MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
if self.playerCore?.isInitialized == true {
self.registerSceneActivationObserver()
result(true)
return
}
guard let window = self.findKeyWindow() else {
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
return
}
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
self.registerSceneActivationObserver()
core.setVisible(false)
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
self.pendingInlineRestoreAfterPip = false
self.unregisterSceneActivationObserver()
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
return
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
pipController?.invalidatePlaybackState()
if playerCore?.isPipActive == true { syncPipTimebase() }
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
/// Prepare the shared AVFoundation display layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func preparePip() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
guard let pip = ensurePipController() else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.setPipSubtitleCompositing(true)
playerCore.isPipStarting = true
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
}
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard MpvPipController.isSupported else {
result?([
"success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+",
])
return
}
guard playerCore != nil else {
result?([
"success": false, "errorCode": "failed", "errorMessage": "Player not initialized",
])
return
}
guard let pip = preparePip() else {
result?([
"success": false, "errorCode": "pip_prepare_failed",
"errorMessage": "Failed to prepare PiP",
])
return
}
// MARK: - Helpers
isManualPipRequest = manual
pip.startPip(waitForFrame: manual) { [weak self] started in
if started {
result?(["success": true])
} else {
self?.cleanupPip(notify: false)
result?([
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
])
}
}
}
private func findKeyWindow() -> UIWindow? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }) else {
return nil
/// Unified cleanup for all PiP exit paths
private func cleanupPip(notify: Bool, pause: Bool = false) {
playerCore?.setPipSubtitleCompositing(false)
playerCore?.isPipStarting = false
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
if pause {
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
}
pendingInlineRestoreAfterPip = true
if pendingInlineRestoreAfterPip {
if isSceneActive {
restoreInlinePlayerAfterPip()
} else {
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
}
}
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
}
/// Scene became active restore inline playback if needed and re-warm the
/// sample-buffer layer so future auto-PiP remains possible.
@objc private func sceneDidActivate() {
restoreInlinePlayerAfterPip()
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
}
// MARK: - Timebase Sync
private func syncPipTimebase() {
guard let playerCore = playerCore, let pipController = pipController else { return }
pipController.syncTimebase(
currentTime: playerCore.timePos,
isPlaying: !playerCore.isPaused
)
}
private func startPipTimebaseSync() {
stopPipTimebaseSync()
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) {
[weak self] _ in
self?.syncPipTimebase()
}
}
private func stopPipTimebaseSync() {
pipTimebaseSyncTimer?.invalidate()
pipTimebaseSyncTimer = nil
}
// MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
if self.playerCore?.isInitialized == true {
self.registerSceneActivationObserver()
result(true)
return
}
guard let window = self.findKeyWindow() else {
result(
FlutterError(
code: "NO_WINDOW", message: "Could not find key window", details: nil))
return
}
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
result(
FlutterError(
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
self.registerSceneActivationObserver()
core.setVisible(false)
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
self.pendingInlineRestoreAfterPip = false
self.unregisterSceneActivationObserver()
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String
else {
result(
FlutterError(
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
details: nil))
return
}
guard let core = playerCore else {
result(nil)
return
}
core.setPropertyAsync(name, value: value) { [weak self] _ in
if name == "pause" {
self?.pipController?.invalidatePlaybackState()
if core.isPipActive == true { self?.syncPipTimebase() }
}
result(nil)
}
}
private func handleSetDisplayCriteria(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing arguments", details: nil))
return
}
guard let core = playerCore else {
result(nil)
return
}
guard let raw = args["criteria"] as? [String: Any] else {
DispatchQueue.main.async {
core.setServerDisplayCriteria(nil)
result(nil)
}
return
}
let criteria = ServerDisplayCriteria(
doviProfile: int64Value(raw["doviProfile"]) ?? 0,
doviLevel: int64Value(raw["doviLevel"]) ?? 0,
doviCompatibilityId: int64Value(raw["doviCompatibilityId"]),
fps: doubleValue(raw["fps"]) ?? 0,
width: Int32(truncatingIfNeeded: int64Value(raw["width"]) ?? 0),
height: Int32(truncatingIfNeeded: int64Value(raw["height"]) ?? 0),
gamma: stringValue(raw["transfer"]),
primaries: stringValue(raw["primaries"]),
colorMatrix: stringValue(raw["matrix"])
)
DispatchQueue.main.async {
core.setServerDisplayCriteria(criteria)
result(nil)
}
}
private func int64Value(_ value: Any?) -> Int64? {
switch value {
case let value as Int64:
return value
case let value as Int:
return Int64(value)
case let value as NSNumber:
return value.int64Value
case let value as String:
return Int64(value)
default:
return nil
}
}
private func doubleValue(_ value: Any?) -> Double? {
switch value {
case let value as Double:
return value
case let value as NSNumber:
return value.doubleValue
case let value as String:
return Double(value)
default:
return nil
}
}
private func stringValue(_ value: Any?) -> String? {
guard let value else { return nil }
let string = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
return string.isEmpty ? nil : string
}
// MARK: - Helpers
private func findKeyWindow() -> UIWindow? {
#if os(iOS)
return ExternalDisplayManager.mainApplicationWindow()
#else
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
for scene in scenes {
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
return window
}
return window
}
}
for scene in scenes {
if let window = scene.windows.first(where: { !$0.isHidden }) {
return window
}
}
return scenes.first?.windows.first
#endif
}
}
// MARK: - MpvPipDelegate
extension MpvPlayerPlugin: MpvPipDelegate {
func pipWillStart() {
// If PiP was system-initiated (not via our enterPip), switch VO now
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO")
if switchToPipAndPrepare() == nil {
print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP")
pipController?.stopPip()
}
func pipWillStart() {
// If PiP was system-initiated (not via our enterPip), prepare the shared layer now.
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] System-initiated PiP detected, preparing shared layer")
if preparePip() == nil {
print("[MpvPlayerPlugin] PiP preparation failed for system-initiated PiP")
pipController?.stopPip()
}
}
func pipDidStart() {
playerCore?.isPipStarting = false
playerCore?.isPipActive = true
pendingInlineRestoreAfterPip = false
pipChannel?.invokeMethod("onPipChanged", arguments: true)
syncPipTimebase()
startPipTimebaseSync()
func pipDidStart() {
playerCore?.isPipStarting = false
playerCore?.isPipActive = true
pendingInlineRestoreAfterPip = false
pipChannel?.invokeMethod("onPipChanged", arguments: true)
syncPipTimebase()
startPipTimebaseSync()
if isManualPipRequest {
isManualPipRequest = false
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
}
if isManualPipRequest {
isManualPipRequest = false
UIControl().sendAction(
#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
}
}
func pipDidStop(restored: Bool) {
cleanupPip(notify: true, pause: !restored)
func pipDidStop(restored: Bool) {
cleanupPip(notify: true, pause: !restored)
}
func pipDidFailToStart(error: Error?) {
cleanupPip(notify: true)
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
}
func pipDidFailToStart(error: Error?) {
cleanupPip(notify: true)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) {
guard let playerCore = playerCore else {
completion()
return
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
pipController?.invalidatePlaybackState()
syncPipTimebase()
let newTime = max(0, playerCore.timePos + seconds)
playerCore.commandAsync(["seek", String(newTime), "absolute"]) { [weak self] _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.pipController?.invalidatePlaybackState()
completion()
}
}
}
func pipSkip(byInterval seconds: Double) {
guard let playerCore = playerCore else { return }
let newTime = max(0, playerCore.timePos + seconds)
playerCore.command(["seek", String(newTime), "absolute"])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.syncPipTimebase()
self?.pipController?.invalidatePlaybackState()
}
}
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
var pipDuration: Double { playerCore?.duration ?? 0 }
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
var pipDuration: Double { playerCore?.duration ?? 0 }
}
+11 -3
View File
@@ -1,15 +1,23 @@
require 'dotenv/load'
Dotenv.load("../../.env")
require 'shellwords'
PROJECT_ROOT = File.expand_path("../..", __dir__)
PROJECT_ROOT_ARG = Shellwords.escape(PROJECT_ROOT)
Dotenv.load(File.join(PROJECT_ROOT, ".env"))
default_platform(:ios)
platform :ios do
desc "Build and deploy to App Store"
lane :deploy_appstore do
git_commit = `git rev-parse --short HEAD`.strip
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit}")
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
# Upload debug symbols
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
# Upload to App Store
upload_to_app_store(
+365
View File
@@ -0,0 +1,365 @@
import '../media/media_backend.dart';
import '../models/plex/plex_home_user.dart';
import '../services/plex_auth_service.dart';
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
/// [MediaBackend] for places that only care about persistence/auth shape
/// (e.g. database column values).
enum ConnectionKind {
plex,
jellyfin;
String get id => switch (this) {
ConnectionKind.plex => 'plex',
ConnectionKind.jellyfin => 'jellyfin',
};
static ConnectionKind fromId(String id) => switch (id) {
'plex' => ConnectionKind.plex,
'jellyfin' => ConnectionKind.jellyfin,
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
};
MediaBackend get backend => switch (this) {
ConnectionKind.plex => MediaBackend.plex,
ConnectionKind.jellyfin => MediaBackend.jellyfin,
};
}
/// Health snapshot for a connection. Updated by the orchestrator each time a
/// session is established or refreshed.
enum ConnectionStatus { unknown, online, offline, authError, disabled }
/// A media server connection — a unit of authentication the user added.
///
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
/// optional active Home profile. A `JellyfinConnection` is a single server +
/// user. Most users only ever add one connection.
sealed class Connection {
String get id;
ConnectionKind get kind;
String get displayName;
ConnectionStatus get status;
DateTime get createdAt;
DateTime? get lastAuthenticatedAt;
/// Backend kind as a [MediaBackend] — for UI that branches on backend
/// (badges, etc.). Just a passthrough to [kind.backend].
MediaBackend get backend => kind.backend;
/// Primary label shown in connection-list UIs. Plex shows the active
/// profile/account name; Jellyfin shows the server name.
String get displayLabel;
/// Secondary line shown beneath [displayLabel] in connection-list UIs.
/// Plex: server count; Jellyfin: `userName · baseUrl`. May be null when
/// no useful subtitle exists.
String? get displaySubtitle;
/// Backend-specific config payload, persisted as JSON. Each subclass
/// defines the schema.
Map<String, Object?> toConfigJson();
}
/// A Plex account connection.
///
/// Fields here mirror what [PlexAuthService] gathers during PIN OAuth: an
/// account token (long-lived), the per-device client identifier (so plex.tv
/// doesn't see a "new device" each launch), and the optional Home user the
/// user has switched into.
class PlexAccountConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@override
final DateTime? lastAuthenticatedAt;
/// plex.tv account access token.
final String accountToken;
/// Per-device client identifier. Stable across launches.
final String clientIdentifier;
/// Display name shown for this connection (typically the Plex account email
/// or username, fallback "Plex").
final String accountLabel;
/// Active Home user, or `null` for the main account.
final PlexHomeUser? activeProfile;
/// Servers discovered for this account (cached). Populated by the auth
/// flow and refreshed periodically.
final List<PlexServer> servers;
PlexAccountConnection({
required this.id,
required this.accountToken,
required this.clientIdentifier,
required this.accountLabel,
this.activeProfile,
this.servers = const [],
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
});
@override
ConnectionKind get kind => ConnectionKind.plex;
@override
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
? '${activeProfile!.title} · $accountLabel'
: accountLabel;
@override
String get displayLabel => displayName;
@override
String? get displaySubtitle => servers.length == 1 ? '1 Plex server' : '${servers.length} Plex servers';
PlexAccountConnection copyWith({
String? id,
String? accountToken,
String? clientIdentifier,
String? accountLabel,
PlexHomeUser? activeProfile,
bool clearActiveProfile = false,
List<PlexServer>? servers,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
return PlexAccountConnection(
id: id ?? this.id,
accountToken: accountToken ?? this.accountToken,
clientIdentifier: clientIdentifier ?? this.clientIdentifier,
accountLabel: accountLabel ?? this.accountLabel,
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
servers: servers ?? this.servers,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
}
@override
Map<String, Object?> toConfigJson() {
return {
'accountToken': accountToken,
'clientIdentifier': clientIdentifier,
'accountLabel': accountLabel,
'activeProfile': activeProfile?.toJson(),
'servers': servers.map((s) => s.toJson()).toList(),
};
}
factory PlexAccountConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
}) {
final profileJson = json['activeProfile'];
final activeProfile = profileJson is Map<String, dynamic> ? PlexHomeUser.fromJson(profileJson) : null;
final serversJson = json['servers'];
final servers = serversJson is List
? serversJson.whereType<Map<String, dynamic>>().map(PlexServer.fromJson).toList()
: <PlexServer>[];
return PlexAccountConnection(
id: id,
accountToken: json['accountToken'] as String? ?? '',
clientIdentifier: json['clientIdentifier'] as String? ?? '',
accountLabel: json['accountLabel'] as String? ?? 'Plex',
activeProfile: activeProfile,
servers: servers,
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
}
/// A single-server Jellyfin connection.
class JellyfinConnection extends Connection {
@override
final String id;
@override
final ConnectionStatus status;
@override
final DateTime createdAt;
@override
final DateTime? lastAuthenticatedAt;
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
final String baseUrl;
/// Candidate server URLs for this Jellyfin server, with [baseUrl] first.
/// Existing installs only have [baseUrl]; deserialization backfills this.
final List<String> baseUrls;
/// Server's reported name (System/Info).
final String serverName;
/// Server's machine identifier (System/Info `Id`).
final String serverMachineId;
/// Authenticated Jellyfin user id (UUID).
final String userId;
/// Authenticated user's display name.
final String userName;
/// Long-lived access token from `/Users/AuthenticateByName`.
final String accessToken;
/// Per-device client identifier (same value sent in the
/// `Authorization: MediaBrowser DeviceId="..."` header).
final String deviceId;
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
/// Captured at auth time so the UI can gate admin-only entries (delete,
/// match/unmatch, edit metadata) without an extra round-trip.
final bool isAdministrator;
JellyfinConnection({
required this.id,
required this.baseUrl,
List<String>? baseUrls,
required this.serverName,
required this.serverMachineId,
required this.userId,
required this.userName,
required this.accessToken,
required this.deviceId,
this.isAdministrator = false,
this.status = ConnectionStatus.unknown,
required this.createdAt,
this.lastAuthenticatedAt,
}) : baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
@override
ConnectionKind get kind => ConnectionKind.jellyfin;
@override
String get displayName => '$userName · $serverName';
@override
String get displayLabel => serverName;
@override
String? get displaySubtitle {
final extraCount = baseUrls.length - 1;
final suffix = extraCount > 0 ? ' +$extraCount' : '';
return '$userName · ${_truncateUrl(baseUrl)}$suffix';
}
static String _truncateUrl(String url) {
if (url.length <= 40) return url;
return '${url.substring(0, 37)}';
}
static List<String> _normalizeBaseUrls(String activeBaseUrl, List<String>? urls) {
final result = <String>[];
final seen = <String>{};
void add(String url) {
final trimmed = url.trim();
if (trimmed.isEmpty || !seen.add(trimmed)) return;
result.add(trimmed);
}
add(activeBaseUrl);
for (final url in urls ?? const <String>[]) {
add(url);
}
return List.unmodifiable(result);
}
JellyfinConnection copyWith({
String? id,
String? baseUrl,
List<String>? baseUrls,
String? serverName,
String? serverMachineId,
String? userId,
String? userName,
String? accessToken,
String? deviceId,
bool? isAdministrator,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
final nextBaseUrl = baseUrl ?? this.baseUrl;
return JellyfinConnection(
id: id ?? this.id,
baseUrl: nextBaseUrl,
baseUrls: baseUrls ?? this.baseUrls,
serverName: serverName ?? this.serverName,
serverMachineId: serverMachineId ?? this.serverMachineId,
userId: userId ?? this.userId,
userName: userName ?? this.userName,
accessToken: accessToken ?? this.accessToken,
deviceId: deviceId ?? this.deviceId,
isAdministrator: isAdministrator ?? this.isAdministrator,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
);
}
@override
Map<String, Object?> toConfigJson() {
return {
'baseUrl': baseUrl,
'baseUrls': baseUrls,
'serverName': serverName,
'serverMachineId': serverMachineId,
'userId': userId,
'userName': userName,
'accessToken': accessToken,
'deviceId': deviceId,
'isAdministrator': isAdministrator,
};
}
factory JellyfinConnection.fromConfigJson({
required String id,
required Map<String, Object?> json,
required ConnectionStatus status,
required DateTime createdAt,
DateTime? lastAuthenticatedAt,
}) {
final rawBaseUrls = json['baseUrls'];
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
final rawBaseUrl = json['baseUrl'] as String?;
final baseUrl = rawBaseUrl != null && rawBaseUrl.isNotEmpty
? rawBaseUrl
: (baseUrls.isNotEmpty ? baseUrls.first : '');
return JellyfinConnection(
id: id,
baseUrl: baseUrl,
baseUrls: baseUrls,
serverName: json['serverName'] as String? ?? 'Jellyfin',
serverMachineId: json['serverMachineId'] as String? ?? '',
userId: json['userId'] as String? ?? '',
userName: json['userName'] as String? ?? '',
accessToken: json['accessToken'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
isAdministrator: json['isAdministrator'] as bool? ?? false,
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
);
}
}
@@ -0,0 +1,20 @@
import 'connection.dart';
/// Backend-neutral auth service interface. Each backend's implementation
/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its
/// own UX (PIN flow vs. password) but produces the same opaque
/// [Connection] record at the end.
abstract class ConnectionAuthService {
/// Best-effort check that an existing token still works. Returns false on
/// 401/403; throws on transport failures the caller should retry.
Future<bool> validate(Connection connection);
/// Refresh whatever side-channel state belongs to a connection — for Plex
/// that's the discovered server list and Home users; for Jellyfin it's a
/// no-op once auth has succeeded. Returns the updated connection.
Future<Connection> refresh(Connection connection);
/// Revoke the token server-side and forget local credentials. The caller
/// is responsible for removing the row from [ConnectionRegistry].
Future<void> signOut(Connection connection);
}
+304
View File
@@ -0,0 +1,304 @@
import 'dart:convert';
import '../models/plex/plex_home.dart';
import '../models/plex/plex_home_user.dart';
import '../profiles/profile.dart';
import '../profiles/profile_registry.dart';
import '../services/plex_auth_service.dart';
import '../services/server_registry.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import 'connection.dart';
import 'connection_registry.dart';
/// One-shot helpers that bridge between the legacy single-Plex-account
/// SharedPreferences state (`StorageService.plexToken` +
/// `currentUserUUID` + `homeUsersCache` + `ServerRegistry.getServers()`)
/// and the new [ConnectionRegistry] world.
///
/// Plex Home users are NOT persisted here — the bootstrap copies the
/// legacy `homeUsersCache` into the per-connection
/// `plex_home_users_{connectionId}` SharedPreferences slot so
/// [PlexHomeService] picks it up on cold start.
class ConnectionBootstrap {
ConnectionBootstrap({
required this.storage,
required this.connectionRegistry,
required this.serverRegistry,
required this.profileRegistry,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage;
final ConnectionRegistry connectionRegistry;
final ServerRegistry serverRegistry;
final ProfileRegistry profileRegistry;
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
/// Run all idempotent boot-time migrations. Best-effort — errors are
/// logged but never thrown.
Future<void> run() async {
await seedFromDevTokenDefine();
final hadLegacyPlexToken = (storage.getPlexToken() ?? '').isNotEmpty;
final hadLegacyProfileState = _hasLegacyProfileState();
final migratedAccount = await migrateLegacyPlexAccount();
final account = await _firstPlexAccount(migratedAccount);
final alreadyMigrated = storage.prefs.getBool(_keyProfileMigrationV1Done) ?? false;
if (!alreadyMigrated) {
if (account == null && (hadLegacyPlexToken || hadLegacyProfileState)) {
appLogger.w('Migration: legacy profile state present but Plex account was not migrated; will retry later');
return;
}
// Drop any plex_home rows left over from the pre-refactor data
// model — Plex Home users are now fetched live, never persisted.
await profileRegistry.dropAllPlexHomeRows();
if (account != null) {
final prepared = await _preparePlexVirtualProfile(account);
if (!prepared) {
if (migratedAccount != null && hadLegacyPlexToken) {
await connectionRegistry.remove(migratedAccount.id);
}
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
return;
}
}
if (hadLegacyPlexToken) {
await storage.clearLegacyPlexToken();
}
await storage.clearServersList();
await storage.prefs.setBool(_keyProfileMigrationV1Done, true);
} else {
await storage.clearServersList();
await _recoverLegacyProfilePromotionIfNeeded(account);
await _migrateLegacyPlexHomeUsersCacheForExistingAccount(account);
}
}
bool _hasLegacyProfileState() {
return (storage.getCurrentUserUUID() ?? '').isNotEmpty ||
(storage.prefs.getString('home_users_cache') ?? '').isNotEmpty;
}
/// Screenshot automation injects a Plex token via the `PLEX_TOKEN`
/// dart-define so the app boots already-signed-in. Inserts a
/// [PlexAccountConnection] directly when the env var is non-empty AND
/// the registry doesn't already have a Plex account, fetching the user
/// info + servers like the auth screen does. No-op in normal builds.
Future<void> seedFromDevTokenDefine() async {
const devToken = String.fromEnvironment('PLEX_TOKEN');
if (devToken.isEmpty) return;
final existing = await connectionRegistry.list();
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
try {
final auth = await PlexAuthService.create();
try {
final info = await auth.getUserInfo(devToken);
final servers = await auth.fetchServers(devToken);
final clientId = await storage.getOrCreateClientIdentifier();
final conn = PlexAccountConnection(
id: 'plex.$clientId',
accountToken: devToken,
clientIdentifier: clientId,
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
);
await connectionRegistry.upsert(conn);
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
} finally {
auth.dispose();
}
} catch (e, st) {
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
}
}
/// If a legacy Plex token sits in [StorageService] without a corresponding
/// row in [ConnectionRegistry], wrap it as a [PlexAccountConnection] and
/// insert. Best-effort: errors are logged but never thrown.
Future<PlexAccountConnection?> migrateLegacyPlexAccount() async {
final token = storage.getPlexToken();
if (token == null || token.isEmpty) return null;
final existing = await connectionRegistry.list();
final alreadyMigrated = existing
.whereType<PlexAccountConnection>()
.where((c) => c.accountToken == token)
.firstOrNull;
if (alreadyMigrated != null) {
return alreadyMigrated;
}
try {
final clientId = await storage.getOrCreateClientIdentifier();
final servers = await serverRegistry.getServers();
String accountLabel = 'Plex';
String accountUuid = '';
try {
final info = await _plexUserInfoFetcher(token);
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
} catch (e) {
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
}
final conn = PlexAccountConnection(
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
accountToken: token,
clientIdentifier: clientId,
accountLabel: accountLabel,
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
);
await connectionRegistry.upsert(conn);
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
return conn;
} catch (e, st) {
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
return null;
}
}
/// Hydrate Plex Home users for [account] and select a virtual Plex Home
/// profile. Plex users are never persisted as local Plezy profiles.
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
users ??= await _fetchAndCachePlexHomeUsers(account);
final hydratedUsers = users;
if (hydratedUsers.isEmpty) return false;
final legacyActiveUuid = storage.getCurrentUserUUID();
PlexHomeUser selected;
if (legacyActiveUuid != null && legacyActiveUuid.isNotEmpty) {
selected = hydratedUsers.firstWhere(
(u) => u.uuid == legacyActiveUuid,
orElse: () => _preferredPlexHomeUser(hydratedUsers),
);
if (selected.uuid != legacyActiveUuid) {
appLogger.w('Migration: legacy Plex Home UUID $legacyActiveUuid not found; using ${selected.uuid} instead');
}
} else {
selected = _preferredPlexHomeUser(hydratedUsers);
}
final activeProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: selected.uuid);
await storage.setActiveProfileId(activeProfileId);
await storage.clearCurrentUserUUID();
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
appLogger.i('Migration: selected Plex Home profile ${selected.displayName}$activeProfileId');
return true;
}
PlexHomeUser _preferredPlexHomeUser(List<PlexHomeUser> users) {
return users.firstWhere((u) => u.admin, orElse: () => users.first);
}
Future<bool> _migrateLegacyPlexHomeUsersCache(String connectionId) async {
final raw = storage.prefs.getString('home_users_cache');
if (raw == null || raw.isEmpty) return false;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return false;
final home = PlexHome.fromJson(decoded);
if (home.users.isEmpty) return false;
await storage.savePlexHomeUsersCache(connectionId, home.users.map((u) => u.toJson()).toList());
appLogger.i('Migration: copied ${home.users.length} Plex Home users into cache for $connectionId');
return true;
} catch (e, st) {
appLogger.w('Plex Home cache migration failed', error: e, stackTrace: st);
return false;
}
}
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return null;
return decoded.whereType<Map<String, dynamic>>().map(PlexHomeUser.fromJson).toList();
} catch (e, st) {
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
return null;
}
}
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
try {
final users = await _plexHomeUserFetcher(account.accountToken);
if (users.isNotEmpty) {
await storage.savePlexHomeUsersCache(account.id, users.map((u) => u.toJson()).toList());
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
}
return users;
} catch (e, st) {
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
return const [];
}
}
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
if (storage.prefs.getString('home_users_cache') == null) return;
var target = account;
if (target == null) {
final connections = await connectionRegistry.list();
for (final conn in connections) {
if (conn is PlexAccountConnection) {
target = conn;
break;
}
}
}
if (target == null) return;
final copied = await _migrateLegacyPlexHomeUsersCache(target.id);
if (copied) {
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
}
}
Future<PlexAccountConnection?> _firstPlexAccount(PlexAccountConnection? preferred) async {
if (preferred != null) return preferred;
final connections = await connectionRegistry.list();
for (final conn in connections) {
if (conn is PlexAccountConnection) return conn;
}
return null;
}
Future<void> _recoverLegacyProfilePromotionIfNeeded(PlexAccountConnection? account) async {
if (!_hasLegacyProfileState()) return;
final target = await _firstPlexAccount(account);
if (target == null) return;
await _preparePlexVirtualProfile(target);
}
}
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
final auth = await PlexAuthService.create();
try {
return await auth.getUserInfo(accountToken);
} finally {
auth.dispose();
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import '../database/app_database.dart';
import '../services/credential_vault.dart';
import '../utils/app_logger.dart';
import 'connection.dart';
/// CRUD over the persisted [Connections] table. The registry is the source
/// of truth for which connections the user has added; the runtime
/// `MultiServerManager` populates per-server clients from these records.
///
/// Single-connection users get a default automatically — power users with
/// multiple connections can override it via [setDefault].
class ConnectionRegistry {
ConnectionRegistry(this._db);
final AppDatabase _db;
/// Emits the current set of connections after every mutation. Drift's
/// `watch()` provides this for free.
Stream<List<Connection>> watchConnections() {
return (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).watch().asyncMap(
(rows) async => (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList(),
);
}
/// One-shot fetch of all stored connections.
Future<List<Connection>> list() async {
final rows = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
return (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList();
}
/// Lookup a connection by id.
Future<Connection?> get(String id) async {
final row = await (_db.select(_db.connections)..where((t) => t.id.equals(id))).getSingleOrNull();
if (row == null) return null;
return _rowToConnection(row);
}
/// Returns the user's preferred connection — either the row marked
/// [Connections.isDefault], or the only row if exactly one exists, or
/// null when no connections are stored.
Future<Connection?> getDefault() async {
final rows = await _db.select(_db.connections).get();
if (rows.isEmpty) return null;
final flagged = rows.firstWhereOrNull((r) => r.isDefault);
final picked = flagged ?? (rows.length == 1 ? rows.single : null);
return picked == null ? null : _rowToConnection(picked);
}
/// Insert or replace [connection]. If this is the first stored connection
/// it is automatically marked default; re-upserting an existing row keeps
/// the row's current `isDefault` (so token/metadata refreshes don't clear
/// the default flag).
Future<void> upsert(Connection connection) async {
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
final bool isDefault;
if (existing != null) {
isDefault = existing.isDefault;
} else {
final any =
await (_db.selectOnly(_db.connections)
..addColumns([_db.connections.id])
..limit(1))
.getSingleOrNull();
isDefault = any == null;
}
final protectedConfig = await CredentialVault.protectConnectionConfig(
connection.kind.id,
connection.toConfigJson(),
);
final row = ConnectionsCompanion(
id: Value(connection.id),
kind: Value(connection.kind.id),
displayName: Value(connection.displayName),
configJson: Value(jsonEncode(protectedConfig)),
isDefault: Value(isDefault),
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
);
await _db.into(_db.connections).insertOnConflictUpdate(row);
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
}
/// Remove a stored connection. If the removed row was the default, the
/// oldest remaining connection (if any) becomes default.
Future<void> remove(String id) async {
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
}
appLogger.d('ConnectionRegistry: removed $id');
}
/// Set [id] as the default connection. Clears the flag on all others.
Future<void> setDefault(String id) async {
await _db.transaction(() async {
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
await (_db.update(
_db.connections,
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
});
}
/// Update only the auth-related metadata on an existing row (token,
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
/// silent refresh without touching the rest of the config.
Future<void> recordAuthSuccess(String id, DateTime at) async {
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
);
}
Future<void> clear() async {
await _db.delete(_db.connections).go();
}
/// All Plex accounts in insertion order. Convenience over
/// `(await list()).whereType<PlexAccountConnection>()` — cuts ~3 lines from
/// every caller that needs to filter by backend.
Future<List<PlexAccountConnection>> listPlexAccounts() async {
final all = await list();
return all.whereType<PlexAccountConnection>().toList();
}
/// All Jellyfin connections in insertion order. Symmetric helper to
/// [listPlexAccounts].
Future<List<JellyfinConnection>> listJellyfin() async {
final all = await list();
return all.whereType<JellyfinConnection>().toList();
}
/// Lookup a [PlexAccountConnection] by id. Returns `null` if no row
/// matches OR the row exists but isn't a Plex account.
Future<PlexAccountConnection?> getPlexAccount(String id) async {
final c = await get(id);
return c is PlexAccountConnection ? c : null;
}
/// Lookup a [JellyfinConnection] by id. Returns `null` if no row matches
/// OR the row exists but isn't a Jellyfin connection.
Future<JellyfinConnection?> getJellyfin(String id) async {
final c = await get(id);
return c is JellyfinConnection ? c : null;
}
Future<Connection?> _rowToConnection(ConnectionRow row) async {
try {
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
final kind = ConnectionKind.fromId(row.kind);
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
final lastAuth = row.lastAuthenticatedAt == null
? null
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
final connection = switch (kind) {
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
id: row.id,
json: revealed.config,
status: ConnectionStatus.unknown,
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
),
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
id: row.id,
json: revealed.config,
status: ConnectionStatus.unknown,
createdAt: createdAt,
lastAuthenticatedAt: lastAuth,
),
};
if (revealed.migrated) {
await upsert(connection);
}
return connection;
} catch (e, st) {
appLogger.e('ConnectionRegistry: failed to decode connection ${row.id}', error: e, stackTrace: st);
return null;
}
}
}
File diff suppressed because one or more lines are too long
+335
View File
@@ -0,0 +1,335 @@
// Generated from USB HID Usage Tables — do not edit by hand.
/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.
const hidKeyLabels = <int, String>{
// Special
0x00000010: 'Hyper',
0x00000011: 'Super',
0x00000012: 'Fn',
0x00000013: 'Fn Lock',
0x00000014: 'Suspend',
0x00000015: 'Resume',
0x00000016: 'Turbo',
0x00000017: 'Privacy Screen Toggle',
0x00000018: 'Microphone Mute',
// Generic Desktop
0x00010082: 'Sleep',
0x00010083: 'Wake Up',
0x000100b5: 'Display Toggle',
// Game Buttons
0x0005ff01: 'Game Button 1',
0x0005ff02: 'Game Button 2',
0x0005ff03: 'Game Button 3',
0x0005ff04: 'Game Button 4',
0x0005ff05: 'Game Button 5',
0x0005ff06: 'Game Button 6',
0x0005ff07: 'Game Button 7',
0x0005ff08: 'Game Button 8',
0x0005ff09: 'Game Button 9',
0x0005ff0a: 'Game Button 10',
0x0005ff0b: 'Game Button 11',
0x0005ff0c: 'Game Button 12',
0x0005ff0d: 'Game Button 13',
0x0005ff0e: 'Game Button 14',
0x0005ff0f: 'Game Button 15',
0x0005ff10: 'Game Button 16',
0x0005ff11: 'Game Button A',
0x0005ff12: 'Game Button B',
0x0005ff13: 'Game Button C',
0x0005ff14: 'Game Button Left 1',
0x0005ff15: 'Game Button Left 2',
0x0005ff16: 'Game Button Mode',
0x0005ff17: 'Game Button Right 1',
0x0005ff18: 'Game Button Right 2',
0x0005ff19: 'Game Button Select',
0x0005ff1a: 'Game Button Start',
0x0005ff1b: 'Game Button Thumb Left',
0x0005ff1c: 'Game Button Thumb Right',
0x0005ff1d: 'Game Button X',
0x0005ff1e: 'Game Button Y',
0x0005ff1f: 'Game Button Z',
// Keyboard/Keypad
0x00070004: 'A',
0x00070005: 'B',
0x00070006: 'C',
0x00070007: 'D',
0x00070008: 'E',
0x00070009: 'F',
0x0007000a: 'G',
0x0007000b: 'H',
0x0007000c: 'I',
0x0007000d: 'J',
0x0007000e: 'K',
0x0007000f: 'L',
0x00070010: 'M',
0x00070011: 'N',
0x00070012: 'O',
0x00070013: 'P',
0x00070014: 'Q',
0x00070015: 'R',
0x00070016: 'S',
0x00070017: 'T',
0x00070018: 'U',
0x00070019: 'V',
0x0007001a: 'W',
0x0007001b: 'X',
0x0007001c: 'Y',
0x0007001d: 'Z',
0x0007001e: '1',
0x0007001f: '2',
0x00070020: '3',
0x00070021: '4',
0x00070022: '5',
0x00070023: '6',
0x00070024: '7',
0x00070025: '8',
0x00070026: '9',
0x00070027: '0',
0x00070028: 'Enter',
0x00070029: 'Escape',
0x0007002a: 'Backspace',
0x0007002b: 'Tab',
0x0007002c: 'Space',
0x0007002d: '-',
0x0007002e: '=',
0x0007002f: '[',
0x00070030: ']',
0x00070031: r'\',
0x00070033: ';',
0x00070034: "'",
0x00070035: '`',
0x00070036: ',',
0x00070037: '.',
0x00070038: '/',
0x00070039: 'Caps Lock',
0x0007003a: 'F1',
0x0007003b: 'F2',
0x0007003c: 'F3',
0x0007003d: 'F4',
0x0007003e: 'F5',
0x0007003f: 'F6',
0x00070040: 'F7',
0x00070041: 'F8',
0x00070042: 'F9',
0x00070043: 'F10',
0x00070044: 'F11',
0x00070045: 'F12',
0x00070046: 'Print Screen',
0x00070047: 'Scroll Lock',
0x00070048: 'Pause',
0x00070049: 'Insert',
0x0007004a: 'Home',
0x0007004b: 'Page Up',
0x0007004c: 'Delete',
0x0007004d: 'End',
0x0007004e: 'Page Down',
0x0007004f: 'Arrow Right',
0x00070050: 'Arrow Left',
0x00070051: 'Arrow Down',
0x00070052: 'Arrow Up',
0x00070053: 'Num Lock',
0x00070054: 'Numpad /',
0x00070055: 'Numpad *',
0x00070056: 'Numpad -',
0x00070057: 'Numpad +',
0x00070058: 'Numpad Enter',
0x00070059: 'Numpad 1',
0x0007005a: 'Numpad 2',
0x0007005b: 'Numpad 3',
0x0007005c: 'Numpad 4',
0x0007005d: 'Numpad 5',
0x0007005e: 'Numpad 6',
0x0007005f: 'Numpad 7',
0x00070060: 'Numpad 8',
0x00070061: 'Numpad 9',
0x00070062: 'Numpad 0',
0x00070063: 'Numpad .',
0x00070064: 'Intl Backslash',
0x00070065: 'Context Menu',
0x00070066: 'Power',
0x00070067: 'Numpad =',
0x00070068: 'F13',
0x00070069: 'F14',
0x0007006a: 'F15',
0x0007006b: 'F16',
0x0007006c: 'F17',
0x0007006d: 'F18',
0x0007006e: 'F19',
0x0007006f: 'F20',
0x00070070: 'F21',
0x00070071: 'F22',
0x00070072: 'F23',
0x00070073: 'F24',
0x00070074: 'Open',
0x00070075: 'Help',
0x00070077: 'Select',
0x00070079: 'Again',
0x0007007a: 'Undo',
0x0007007b: 'Cut',
0x0007007c: 'Copy',
0x0007007d: 'Paste',
0x0007007e: 'Find',
0x0007007f: 'Volume Mute',
0x00070080: 'Volume Up',
0x00070081: 'Volume Down',
0x00070085: 'Numpad ,',
0x00070087: 'Intl Ro',
0x00070088: 'Kana Mode',
0x00070089: 'Intl Yen',
0x0007008a: 'Convert',
0x0007008b: 'Non Convert',
0x00070090: 'Lang 1',
0x00070091: 'Lang 2',
0x00070092: 'Lang 3',
0x00070093: 'Lang 4',
0x00070094: 'Lang 5',
0x0007009b: 'Abort',
0x000700a3: 'Props',
0x000700b6: 'Numpad (',
0x000700b7: 'Numpad )',
0x000700bb: 'Numpad Backspace',
0x000700d0: 'Numpad Memory Store',
0x000700d1: 'Numpad Memory Recall',
0x000700d2: 'Numpad Memory Clear',
0x000700d3: 'Numpad Memory Add',
0x000700d4: 'Numpad Memory Subtract',
0x000700d7: 'Numpad Sign Change',
0x000700d8: 'Numpad Clear',
0x000700d9: 'Numpad Clear Entry',
0x000700e0: 'Ctrl',
0x000700e1: 'Shift',
0x000700e2: 'Alt',
0x000700e3: 'Meta',
0x000700e4: 'Ctrl',
0x000700e5: 'Shift',
0x000700e6: 'Alt',
0x000700e7: 'Meta',
// Consumer
0x000c0030: 'Power',
0x000c0032: 'Sleep',
0x000c0040: 'Menu',
0x000c0041: 'Menu Pick',
0x000c0042: 'Menu Up',
0x000c0043: 'Menu Down',
0x000c0044: 'Menu Left',
0x000c0045: 'Menu Right',
0x000c0046: 'Menu Escape',
0x000c0047: 'Menu Value Increase',
0x000c0048: 'Menu Value Decrease',
0x000c0060: 'Info',
0x000c0061: 'Closed Caption',
0x000c0062: 'Closed Caption Select',
0x000c0063: 'VCR/TV',
0x000c0064: 'Broadcast Mode',
0x000c0065: 'Snapshot',
0x000c0066: 'Still',
0x000c0067: 'PiP Toggle',
0x000c0068: 'PiP Swap',
0x000c0069: 'Red Button',
0x000c006a: 'Green Button',
0x000c006b: 'Blue Button',
0x000c006c: 'Yellow Button',
0x000c006d: 'Aspect',
0x000c006e: '3D Mode Select',
0x000c006f: 'Brightness Up',
0x000c0070: 'Brightness Down',
0x000c0072: 'Brightness Toggle',
0x000c0073: 'Brightness Min',
0x000c0074: 'Brightness Max',
0x000c0075: 'Brightness Auto',
0x000c0079: 'Kbd Illum Up',
0x000c007a: 'Kbd Illum Down',
0x000c0082: 'Mode Step',
0x000c0083: 'Recall Last',
0x000c0084: 'Enter Channel',
0x000c0085: 'Order Movie',
0x000c0086: 'Channel',
0x000c0088: 'Media Computer',
0x000c0089: 'Media TV',
0x000c008a: 'Media WWW',
0x000c008b: 'Media DVD',
0x000c008c: 'Media Telephone',
0x000c008d: 'Program Guide',
0x000c008e: 'Media Video Phone',
0x000c008f: 'Media Games',
0x000c0090: 'Media Messages',
0x000c0091: 'Media CD',
0x000c0092: 'Media VCR',
0x000c0093: 'Media Tuner',
0x000c0094: 'Quit',
0x000c0095: 'Help',
0x000c0096: 'Media Tape',
0x000c0097: 'Media Cable',
0x000c0098: 'Media Satellite',
0x000c0099: 'Media Security',
0x000c009a: 'Media Home',
0x000c009b: 'Media Call',
0x000c009c: 'Channel Up',
0x000c009d: 'Channel Down',
0x000c009e: 'Media SAP',
0x000c00b0: 'Play',
0x000c00b1: 'Pause',
0x000c00b2: 'Record',
0x000c00b3: 'Fast Forward',
0x000c00b4: 'Rewind',
0x000c00b5: 'Next Track',
0x000c00b6: 'Previous Track',
0x000c00b7: 'Stop',
0x000c00b8: 'Eject',
0x000c00b9: 'Random Play',
0x000c00ba: 'Select Disc',
0x000c00bb: 'Enter Disc',
0x000c00bc: 'Repeat',
0x000c00c0: 'Frame Forward',
0x000c00c1: 'Frame Back',
0x000c00cc: 'Stop/Eject',
0x000c00cd: 'Play/Pause',
0x000c00ce: 'Play/Skip',
0x000c00cf: 'Voice Command',
0x000c00e0: 'Volume',
0x000c00e2: 'Mute',
0x000c00e5: 'Bass Boost',
0x000c00e6: 'Surround Mode',
0x000c00e7: 'Loudness',
0x000c00e9: 'Volume Up',
0x000c00ea: 'Volume Down',
0x000c0183: 'Media Select',
0x000c0184: 'Launch Word Processor',
0x000c0186: 'Launch Spreadsheet',
0x000c018a: 'Launch Mail',
0x000c018d: 'Launch Contacts',
0x000c018e: 'Launch Calendar',
0x000c0192: 'Launch Calculator',
0x000c0194: 'Launch File Browser',
0x000c0196: 'Launch Browser',
0x000c019c: 'Log Off',
0x000c019e: 'Lock Screen',
0x000c019f: 'Launch Control Panel',
0x000c01a2: 'Select Task',
0x000c01a7: 'Launch Documents',
0x000c01ab: 'Spell Check',
0x000c01ae: 'Launch Keyboard Layout',
0x000c01b1: 'Launch Screen Saver',
0x000c01b7: 'Launch Audio Browser',
0x000c01cb: 'Launch Assistant',
0x000c0201: 'New',
0x000c0203: 'Close',
0x000c0207: 'Save',
0x000c0208: 'Print',
0x000c0221: 'Browser Search',
0x000c0223: 'Browser Home',
0x000c0224: 'Browser Back',
0x000c0225: 'Browser Forward',
0x000c0226: 'Browser Stop',
0x000c0227: 'Browser Refresh',
0x000c022a: 'Browser Favorites',
0x000c022d: 'Zoom In',
0x000c022e: 'Zoom Out',
0x000c0232: 'Zoom Toggle',
0x000c0279: 'Redo',
0x000c0289: 'Mail Reply',
0x000c028b: 'Mail Forward',
0x000c028c: 'Mail Send',
0x000c029d: 'Keyboard Layout Select',
0x000c029f: 'Show All Windows',
};
File diff suppressed because one or more lines are too long
+406
View File
@@ -0,0 +1,406 @@
// Generated from iso_639_codes.json — do not edit by hand.
class LanguageEntry {
final String code1;
final String code2;
final String? code2B;
final String name;
const LanguageEntry(this.code1, this.code2, this.code2B, this.name);
}
const languageEntries = <String, LanguageEntry>{
'aa': LanguageEntry('aa', 'aar', null, 'Afar'),
'ab': LanguageEntry('ab', 'abk', null, 'Abkhaz'),
'ae': LanguageEntry('ae', 'ave', null, 'Avestan'),
'af': LanguageEntry('af', 'afr', null, 'Afrikaans'),
'ak': LanguageEntry('ak', 'aka', null, 'Akan'),
'am': LanguageEntry('am', 'amh', null, 'Amharic'),
'an': LanguageEntry('an', 'arg', null, 'Aragonese'),
'ar': LanguageEntry('ar', 'ara', null, 'Arabic'),
'as': LanguageEntry('as', 'asm', null, 'Assamese'),
'av': LanguageEntry('av', 'ava', null, 'Avaric'),
'ay': LanguageEntry('ay', 'aym', null, 'Aymara'),
'az': LanguageEntry('az', 'aze', null, 'Azerbaijani'),
'ba': LanguageEntry('ba', 'bak', null, 'Bashkir'),
'be': LanguageEntry('be', 'bel', null, 'Belarusian'),
'bg': LanguageEntry('bg', 'bul', null, 'Bulgarian'),
'bh': LanguageEntry('bh', 'bih', null, 'Bihari'),
'bi': LanguageEntry('bi', 'bis', null, 'Bislama'),
'bm': LanguageEntry('bm', 'bam', null, 'Bambara'),
'bn': LanguageEntry('bn', 'ben', null, 'Bengali, Bangla'),
'bo': LanguageEntry('bo', 'bod', 'tib', 'Tibetan Standard, Tibetan, Central'),
'br': LanguageEntry('br', 'bre', null, 'Breton'),
'bs': LanguageEntry('bs', 'bos', null, 'Bosnian'),
'ca': LanguageEntry('ca', 'cat', null, 'Catalan'),
'ce': LanguageEntry('ce', 'che', null, 'Chechen'),
'ch': LanguageEntry('ch', 'cha', null, 'Chamorro'),
'co': LanguageEntry('co', 'cos', null, 'Corsican'),
'cr': LanguageEntry('cr', 'cre', null, 'Cree'),
'cs': LanguageEntry('cs', 'ces', 'cze', 'Czech'),
'cu': LanguageEntry('cu', 'chu', null, 'Old Church Slavonic, Church Slavonic, Old Bulgarian'),
'cv': LanguageEntry('cv', 'chv', null, 'Chuvash'),
'cy': LanguageEntry('cy', 'cym', 'wel', 'Welsh'),
'da': LanguageEntry('da', 'dan', null, 'Danish'),
'de': LanguageEntry('de', 'deu', 'ger', 'German'),
'dv': LanguageEntry('dv', 'div', null, 'Divehi, Dhivehi, Maldivian'),
'dz': LanguageEntry('dz', 'dzo', null, 'Dzongkha'),
'ee': LanguageEntry('ee', 'ewe', null, 'Ewe'),
'el': LanguageEntry('el', 'ell', 'gre', 'Greek (modern)'),
'en': LanguageEntry('en', 'eng', null, 'English'),
'eo': LanguageEntry('eo', 'epo', null, 'Esperanto'),
'es': LanguageEntry('es', 'spa', null, 'Spanish'),
'et': LanguageEntry('et', 'est', null, 'Estonian'),
'eu': LanguageEntry('eu', 'eus', 'baq', 'Basque'),
'fa': LanguageEntry('fa', 'fas', 'per', 'Persian (Farsi)'),
'ff': LanguageEntry('ff', 'ful', null, 'Fula, Fulah, Pulaar, Pular'),
'fi': LanguageEntry('fi', 'fin', null, 'Finnish'),
'fj': LanguageEntry('fj', 'fij', null, 'Fijian'),
'fo': LanguageEntry('fo', 'fao', null, 'Faroese'),
'fr': LanguageEntry('fr', 'fra', 'fre', 'French'),
'fy': LanguageEntry('fy', 'fry', null, 'Western Frisian'),
'ga': LanguageEntry('ga', 'gle', null, 'Irish'),
'gd': LanguageEntry('gd', 'gla', null, 'Scottish Gaelic, Gaelic'),
'gl': LanguageEntry('gl', 'glg', null, 'Galician'),
'gn': LanguageEntry('gn', 'grn', null, 'Guaraní'),
'gu': LanguageEntry('gu', 'guj', null, 'Gujarati'),
'gv': LanguageEntry('gv', 'glv', null, 'Manx'),
'ha': LanguageEntry('ha', 'hau', null, 'Hausa'),
'he': LanguageEntry('he', 'heb', null, 'Hebrew (modern)'),
'hi': LanguageEntry('hi', 'hin', null, 'Hindi'),
'ho': LanguageEntry('ho', 'hmo', null, 'Hiri Motu'),
'hr': LanguageEntry('hr', 'hrv', null, 'Croatian'),
'ht': LanguageEntry('ht', 'hat', null, 'Haitian, Haitian Creole'),
'hu': LanguageEntry('hu', 'hun', null, 'Hungarian'),
'hy': LanguageEntry('hy', 'hye', 'arm', 'Armenian'),
'hz': LanguageEntry('hz', 'her', null, 'Herero'),
'ia': LanguageEntry('ia', 'ina', null, 'Interlingua'),
'id': LanguageEntry('id', 'ind', null, 'Indonesian'),
'ie': LanguageEntry('ie', 'ile', null, 'Interlingue'),
'ig': LanguageEntry('ig', 'ibo', null, 'Igbo'),
'ii': LanguageEntry('ii', 'iii', null, 'Nuosu'),
'ik': LanguageEntry('ik', 'ipk', null, 'Inupiaq'),
'io': LanguageEntry('io', 'ido', null, 'Ido'),
'is': LanguageEntry('is', 'isl', 'ice', 'Icelandic'),
'it': LanguageEntry('it', 'ita', null, 'Italian'),
'iu': LanguageEntry('iu', 'iku', null, 'Inuktitut'),
'ja': LanguageEntry('ja', 'jpn', null, 'Japanese'),
'jv': LanguageEntry('jv', 'jav', null, 'Javanese'),
'ka': LanguageEntry('ka', 'kat', 'geo', 'Georgian'),
'kg': LanguageEntry('kg', 'kon', null, 'Kongo'),
'ki': LanguageEntry('ki', 'kik', null, 'Kikuyu, Gikuyu'),
'kj': LanguageEntry('kj', 'kua', null, 'Kwanyama, Kuanyama'),
'kk': LanguageEntry('kk', 'kaz', null, 'Kazakh'),
'kl': LanguageEntry('kl', 'kal', null, 'Kalaallisut, Greenlandic'),
'km': LanguageEntry('km', 'khm', null, 'Khmer'),
'kn': LanguageEntry('kn', 'kan', null, 'Kannada'),
'ko': LanguageEntry('ko', 'kor', null, 'Korean'),
'kr': LanguageEntry('kr', 'kau', null, 'Kanuri'),
'ks': LanguageEntry('ks', 'kas', null, 'Kashmiri'),
'ku': LanguageEntry('ku', 'kur', null, 'Kurdish'),
'kv': LanguageEntry('kv', 'kom', null, 'Komi'),
'kw': LanguageEntry('kw', 'cor', null, 'Cornish'),
'ky': LanguageEntry('ky', 'kir', null, 'Kyrgyz'),
'la': LanguageEntry('la', 'lat', null, 'Latin'),
'lb': LanguageEntry('lb', 'ltz', null, 'Luxembourgish, Letzeburgesch'),
'lg': LanguageEntry('lg', 'lug', null, 'Ganda'),
'li': LanguageEntry('li', 'lim', null, 'Limburgish, Limburgan, Limburger'),
'ln': LanguageEntry('ln', 'lin', null, 'Lingala'),
'lo': LanguageEntry('lo', 'lao', null, 'Lao'),
'lt': LanguageEntry('lt', 'lit', null, 'Lithuanian'),
'lu': LanguageEntry('lu', 'lub', null, 'Luba-Katanga'),
'lv': LanguageEntry('lv', 'lav', null, 'Latvian'),
'mg': LanguageEntry('mg', 'mlg', null, 'Malagasy'),
'mh': LanguageEntry('mh', 'mah', null, 'Marshallese'),
'mi': LanguageEntry('mi', 'mri', 'mao', 'Māori'),
'mk': LanguageEntry('mk', 'mkd', 'mac', 'Macedonian'),
'ml': LanguageEntry('ml', 'mal', null, 'Malayalam'),
'mn': LanguageEntry('mn', 'mon', null, 'Mongolian'),
'mr': LanguageEntry('mr', 'mar', null, 'Marathi (Marāṭhī)'),
'ms': LanguageEntry('ms', 'msa', 'may', 'Malay'),
'mt': LanguageEntry('mt', 'mlt', null, 'Maltese'),
'my': LanguageEntry('my', 'mya', 'bur', 'Burmese'),
'na': LanguageEntry('na', 'nau', null, 'Nauruan'),
'nb': LanguageEntry('nb', 'nob', null, 'Norwegian Bokmål'),
'nd': LanguageEntry('nd', 'nde', null, 'Northern Ndebele'),
'ne': LanguageEntry('ne', 'nep', null, 'Nepali'),
'ng': LanguageEntry('ng', 'ndo', null, 'Ndonga'),
'nl': LanguageEntry('nl', 'nld', 'dut', 'Dutch'),
'nn': LanguageEntry('nn', 'nno', null, 'Norwegian Nynorsk'),
'no': LanguageEntry('no', 'nor', null, 'Norwegian'),
'nr': LanguageEntry('nr', 'nbl', null, 'Southern Ndebele'),
'nv': LanguageEntry('nv', 'nav', null, 'Navajo, Navaho'),
'ny': LanguageEntry('ny', 'nya', null, 'Chichewa, Chewa, Nyanja'),
'oc': LanguageEntry('oc', 'oci', null, 'Occitan'),
'oj': LanguageEntry('oj', 'oji', null, 'Ojibwe, Ojibwa'),
'om': LanguageEntry('om', 'orm', null, 'Oromo'),
'or': LanguageEntry('or', 'ori', null, 'Oriya'),
'os': LanguageEntry('os', 'oss', null, 'Ossetian, Ossetic'),
'pa': LanguageEntry('pa', 'pan', null, '(Eastern) Punjabi'),
'pi': LanguageEntry('pi', 'pli', null, 'Pāli'),
'pl': LanguageEntry('pl', 'pol', null, 'Polish'),
'ps': LanguageEntry('ps', 'pus', null, 'Pashto, Pushto'),
'pt': LanguageEntry('pt', 'por', null, 'Portuguese'),
'qu': LanguageEntry('qu', 'que', null, 'Quechua'),
'rm': LanguageEntry('rm', 'roh', null, 'Romansh'),
'rn': LanguageEntry('rn', 'run', null, 'Kirundi'),
'ro': LanguageEntry('ro', 'ron', 'rum', 'Romanian'),
'ru': LanguageEntry('ru', 'rus', null, 'Russian'),
'rw': LanguageEntry('rw', 'kin', null, 'Kinyarwanda'),
'sa': LanguageEntry('sa', 'san', null, 'Sanskrit (Saṁskṛta)'),
'sc': LanguageEntry('sc', 'srd', null, 'Sardinian'),
'sd': LanguageEntry('sd', 'snd', null, 'Sindhi'),
'se': LanguageEntry('se', 'sme', null, 'Northern Sami'),
'sg': LanguageEntry('sg', 'sag', null, 'Sango'),
'si': LanguageEntry('si', 'sin', null, 'Sinhalese, Sinhala'),
'sk': LanguageEntry('sk', 'slk', 'slo', 'Slovak'),
'sl': LanguageEntry('sl', 'slv', null, 'Slovene'),
'sm': LanguageEntry('sm', 'smo', null, 'Samoan'),
'sn': LanguageEntry('sn', 'sna', null, 'Shona'),
'so': LanguageEntry('so', 'som', null, 'Somali'),
'sq': LanguageEntry('sq', 'sqi', 'alb', 'Albanian'),
'sr': LanguageEntry('sr', 'srp', null, 'Serbian'),
'ss': LanguageEntry('ss', 'ssw', null, 'Swati'),
'st': LanguageEntry('st', 'sot', null, 'Southern Sotho'),
'su': LanguageEntry('su', 'sun', null, 'Sundanese'),
'sv': LanguageEntry('sv', 'swe', null, 'Swedish'),
'sw': LanguageEntry('sw', 'swa', null, 'Swahili'),
'ta': LanguageEntry('ta', 'tam', null, 'Tamil'),
'te': LanguageEntry('te', 'tel', null, 'Telugu'),
'tg': LanguageEntry('tg', 'tgk', null, 'Tajik'),
'th': LanguageEntry('th', 'tha', null, 'Thai'),
'ti': LanguageEntry('ti', 'tir', null, 'Tigrinya'),
'tk': LanguageEntry('tk', 'tuk', null, 'Turkmen'),
'tl': LanguageEntry('tl', 'tgl', null, 'Tagalog'),
'tn': LanguageEntry('tn', 'tsn', null, 'Tswana'),
'to': LanguageEntry('to', 'ton', null, 'Tonga (Tonga Islands)'),
'tr': LanguageEntry('tr', 'tur', null, 'Turkish'),
'ts': LanguageEntry('ts', 'tso', null, 'Tsonga'),
'tt': LanguageEntry('tt', 'tat', null, 'Tatar'),
'tw': LanguageEntry('tw', 'twi', null, 'Twi'),
'ty': LanguageEntry('ty', 'tah', null, 'Tahitian'),
'ug': LanguageEntry('ug', 'uig', null, 'Uyghur'),
'uk': LanguageEntry('uk', 'ukr', null, 'Ukrainian'),
'ur': LanguageEntry('ur', 'urd', null, 'Urdu'),
'uz': LanguageEntry('uz', 'uzb', null, 'Uzbek'),
've': LanguageEntry('ve', 'ven', null, 'Venda'),
'vi': LanguageEntry('vi', 'vie', null, 'Vietnamese'),
'vo': LanguageEntry('vo', 'vol', null, 'Volapük'),
'wa': LanguageEntry('wa', 'wln', null, 'Walloon'),
'wo': LanguageEntry('wo', 'wol', null, 'Wolof'),
'xh': LanguageEntry('xh', 'xho', null, 'Xhosa'),
'yi': LanguageEntry('yi', 'yid', null, 'Yiddish'),
'yo': LanguageEntry('yo', 'yor', null, 'Yoruba'),
'za': LanguageEntry('za', 'zha', null, 'Zhuang, Chuang'),
'zh': LanguageEntry('zh', 'zho', 'chi', 'Chinese'),
'zu': LanguageEntry('zu', 'zul', null, 'Zulu'),
};
const code2ToCode1 = <String, String>{
'aar': 'aa',
'abk': 'ab',
'ave': 'ae',
'afr': 'af',
'aka': 'ak',
'amh': 'am',
'arg': 'an',
'ara': 'ar',
'asm': 'as',
'ava': 'av',
'aym': 'ay',
'aze': 'az',
'bak': 'ba',
'bel': 'be',
'bul': 'bg',
'bih': 'bh',
'bis': 'bi',
'bam': 'bm',
'ben': 'bn',
'bod': 'bo',
'bre': 'br',
'bos': 'bs',
'cat': 'ca',
'che': 'ce',
'cha': 'ch',
'cos': 'co',
'cre': 'cr',
'ces': 'cs',
'chu': 'cu',
'chv': 'cv',
'cym': 'cy',
'dan': 'da',
'deu': 'de',
'div': 'dv',
'dzo': 'dz',
'ewe': 'ee',
'ell': 'el',
'eng': 'en',
'epo': 'eo',
'spa': 'es',
'est': 'et',
'eus': 'eu',
'fas': 'fa',
'ful': 'ff',
'fin': 'fi',
'fij': 'fj',
'fao': 'fo',
'fra': 'fr',
'fry': 'fy',
'gle': 'ga',
'gla': 'gd',
'glg': 'gl',
'grn': 'gn',
'guj': 'gu',
'glv': 'gv',
'hau': 'ha',
'heb': 'he',
'hin': 'hi',
'hmo': 'ho',
'hrv': 'hr',
'hat': 'ht',
'hun': 'hu',
'hye': 'hy',
'her': 'hz',
'ina': 'ia',
'ind': 'id',
'ile': 'ie',
'ibo': 'ig',
'iii': 'ii',
'ipk': 'ik',
'ido': 'io',
'isl': 'is',
'ita': 'it',
'iku': 'iu',
'jpn': 'ja',
'jav': 'jv',
'kat': 'ka',
'kon': 'kg',
'kik': 'ki',
'kua': 'kj',
'kaz': 'kk',
'kal': 'kl',
'khm': 'km',
'kan': 'kn',
'kor': 'ko',
'kau': 'kr',
'kas': 'ks',
'kur': 'ku',
'kom': 'kv',
'cor': 'kw',
'kir': 'ky',
'lat': 'la',
'ltz': 'lb',
'lug': 'lg',
'lim': 'li',
'lin': 'ln',
'lao': 'lo',
'lit': 'lt',
'lub': 'lu',
'lav': 'lv',
'mlg': 'mg',
'mah': 'mh',
'mri': 'mi',
'mkd': 'mk',
'mal': 'ml',
'mon': 'mn',
'mar': 'mr',
'msa': 'ms',
'mlt': 'mt',
'mya': 'my',
'nau': 'na',
'nob': 'nb',
'nde': 'nd',
'nep': 'ne',
'ndo': 'ng',
'nld': 'nl',
'nno': 'nn',
'nor': 'no',
'nbl': 'nr',
'nav': 'nv',
'nya': 'ny',
'oci': 'oc',
'oji': 'oj',
'orm': 'om',
'ori': 'or',
'oss': 'os',
'pan': 'pa',
'pli': 'pi',
'pol': 'pl',
'pus': 'ps',
'por': 'pt',
'que': 'qu',
'roh': 'rm',
'run': 'rn',
'ron': 'ro',
'rus': 'ru',
'kin': 'rw',
'san': 'sa',
'srd': 'sc',
'snd': 'sd',
'sme': 'se',
'sag': 'sg',
'sin': 'si',
'slk': 'sk',
'slv': 'sl',
'smo': 'sm',
'sna': 'sn',
'som': 'so',
'sqi': 'sq',
'srp': 'sr',
'ssw': 'ss',
'sot': 'st',
'sun': 'su',
'swe': 'sv',
'swa': 'sw',
'tam': 'ta',
'tel': 'te',
'tgk': 'tg',
'tha': 'th',
'tir': 'ti',
'tuk': 'tk',
'tgl': 'tl',
'tsn': 'tn',
'ton': 'to',
'tur': 'tr',
'tso': 'ts',
'tat': 'tt',
'twi': 'tw',
'tah': 'ty',
'uig': 'ug',
'ukr': 'uk',
'urd': 'ur',
'uzb': 'uz',
'ven': 've',
'vie': 'vi',
'vol': 'vo',
'wln': 'wa',
'wol': 'wo',
'xho': 'xh',
'yid': 'yi',
'yor': 'yo',
'zha': 'za',
'zho': 'zh',
'zul': 'zu',
};
const code2BToCode1 = <String, String>{
'tib': 'bo',
'cze': 'cs',
'wel': 'cy',
'ger': 'de',
'gre': 'el',
'baq': 'eu',
'per': 'fa',
'fre': 'fr',
'arm': 'hy',
'ice': 'is',
'geo': 'ka',
'mao': 'mi',
'mac': 'mk',
'may': 'ms',
'bur': 'my',
'dut': 'nl',
'rum': 'ro',
'slo': 'sk',
'alb': 'sq',
'chi': 'zh',
};
+554 -86
View File
@@ -1,6 +1,8 @@
import 'dart:io';
import '../media/ids.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
@@ -11,17 +13,68 @@ import '../utils/global_key_utils.dart';
part 'app_database.g.dart';
// Simplified database with API cache for offline support
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress])
/// Action queued in the offline watch-progress sync table. The serialized
/// form ([id]) is what gets persisted in [OfflineWatchProgress.actionType];
/// keep these strings stable across renames so existing rows resolve.
enum OfflineActionType {
progress,
watched,
unwatched;
/// Stable string id used for persistence. Survives an enum-name rename
/// (e.g. `progress` → `inProgress`) — `.name` would corrupt every row.
String get id => switch (this) {
OfflineActionType.progress => 'progress',
OfflineActionType.watched => 'watched',
OfflineActionType.unwatched => 'unwatched',
};
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
/// silently fall back to the wrong action.
static OfflineActionType fromId(String id) => switch (id) {
'progress' => OfflineActionType.progress,
'watched' => OfflineActionType.watched,
'unwatched' => OfflineActionType.unwatched,
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
};
}
@DriftDatabase(
tables: [
DownloadedMedia,
DownloadOwners,
DownloadQueue,
ApiCache,
OfflineWatchProgress,
SyncRules,
Connections,
Profiles,
ProfileConnections,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
/// Test-only constructor — inject an in-memory [QueryExecutor]
/// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk.
@visibleForTesting
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 8; // Added bgTaskId column to DownloadedMedia
int get schemaVersion => 15;
@override
MigrationStrategy get migration {
return MigrationStrategy(
// Enforce ProfileConnections → Profiles/Connections cascades.
// Drift turns FKs *off* during migrations, so the per-connection
// pragma we set in `_openConnection` is wiped on first open. This
// hook runs after migrations and re-enables it for subsequent
// queries — also applies to in-memory test databases that don't go
// through `_openConnection`.
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
onCreate: (Migrator m) async {
await m.createAll();
},
@@ -32,59 +85,287 @@ class AppDatabase extends _$AppDatabase {
}
if (from < 8) {
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
try {
await m.addColumn(downloadedMedia, downloadedMedia.bgTaskId);
} catch (e) {
appLogger.w('bgTaskId column may already exist: $e');
await _ignoreAlreadyExists(
'DownloadedMedia.bgTaskId column',
() => m.addColumn(downloadedMedia, downloadedMedia.bgTaskId),
);
}
if (from < 9) {
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.mediaIndex column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex),
);
}
if (from < 10) {
appLogger.i('Adding SyncRules table (v10 migration)');
await m.createTable(syncRules);
}
if (from < 11) {
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled));
}
if (from < 12) {
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
await _ignoreAlreadyExists(
'SyncRules.downloadFilter column',
() => m.addColumn(syncRules, syncRules.downloadFilter),
);
}
if (from < 13) {
appLogger.i('Adding indexes on DownloadedMedia hot-queried columns (v13 migration)');
final indexes = {
'idx_downloaded_media_status': idxDownloadedMediaStatus,
'idx_downloaded_media_server': idxDownloadedMediaServer,
'idx_downloaded_media_parent': idxDownloadedMediaParent,
'idx_downloaded_media_grandparent': idxDownloadedMediaGrandparent,
};
for (final entry in indexes.entries) {
await _ignoreAlreadyExists('Index ${entry.key}', () => m.create(entry.value));
}
}
if (from < 14) {
appLogger.i(
'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)',
);
await m.createTable(connections);
await _ignoreAlreadyExists('Index idx_connections_kind', () => m.create(idxConnectionsKind));
await m.createTable(profiles);
await _ignoreAlreadyExists('Index idx_profiles_kind', () => m.create(idxProfilesKind));
await m.createTable(profileConnections);
await _ignoreAlreadyExists(
'Index idx_profile_connections_connection_id',
() => m.create(idxProfileConnectionsConnectionId),
);
await _ignoreAlreadyExists(
'Index idx_profile_connections_profile_id',
() => m.create(idxProfileConnectionsProfileId),
);
await _ignoreAlreadyExists('DownloadOwners table', () => m.createTable(downloadOwners));
await _ignoreAlreadyExists('Index idx_download_owners_profile', () => m.create(idxDownloadOwnersProfile));
await _ignoreAlreadyExists(
'Index idx_download_owners_global_key',
() => m.create(idxDownloadOwnersGlobalKey),
);
await _ignoreAlreadyExists(
'DownloadedMedia.clientScopeId column',
() => m.addColumn(downloadedMedia, downloadedMedia.clientScopeId),
);
await _ignoreAlreadyExists(
'OfflineWatchProgress.clientScopeId column',
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.clientScopeId),
);
await _ignoreAlreadyExists('SyncRules.profileId column', () => m.addColumn(syncRules, syncRules.profileId));
await _ignoreAlreadyExists(
'OfflineWatchProgress.profileId column',
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.profileId),
);
await customStatement('''
UPDATE downloaded_media
SET client_scope_id = (
SELECT id FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
LIMIT 1
)
WHERE client_scope_id IS NULL
AND EXISTS (
SELECT 1 FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
)
''');
await customStatement('''
UPDATE offline_watch_progress
SET client_scope_id = (
SELECT id FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
LIMIT 1
)
WHERE client_scope_id IS NULL
AND EXISTS (
SELECT 1 FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
)
''');
await _ignoreAlreadyExists(
'Index idx_offline_watch_progress_server',
() => m.create(idxOfflineWatchProgressServer),
);
await _ignoreAlreadyExists('Index idx_sync_rules_profile', () => m.create(idxSyncRulesProfile));
await _ignoreAlreadyExists(
'Index idx_offline_watch_progress_profile',
() => m.create(idxOfflineWatchProgressProfile),
);
}
if (from < 15) {
appLogger.i('Adding mediaSourceId column to DownloadedMedia (v15 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.mediaSourceId column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId),
);
}
},
);
}
// ============================================================
// Offline Watch Progress Operations
// ============================================================
Future<void> _ignoreAlreadyExists(String label, Future<void> Function() operation) async {
try {
await operation();
} catch (e) {
final message = e.toString().toLowerCase();
if (message.contains('already exists') || message.contains('duplicate column name')) {
appLogger.w('$label already exists during migration: $e');
return;
}
rethrow;
}
}
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
}
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
return value == null ? column.isNull() : column.equals(value);
}
/// Get all pending offline watch actions for sync
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
Future<List<OfflineWatchProgressItem>> getPendingWatchActions({String? profileId}) {
final query = select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)]);
if (profileId != null) {
query.where((t) => t.profileId.equals(profileId));
}
return query.get();
}
/// Claim pre-v18 offline watch actions for [profileId]. Those rows predate
/// profile ownership and have `NULL profile_id`; the first active profile
/// inherits them so already-watched offline progress is not stranded.
Future<void> adoptLegacyOfflineWatchActionsForProfile(String profileId) async {
if (profileId.isEmpty) return;
await (update(
offlineWatchProgress,
)..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId)));
}
/// Get pending watch actions for a specific server
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId) {
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
return (select(offlineWatchProgress)
..where((t) => t.serverId.equals(serverId))
..where(
(t) =>
t.serverId.equals(serverId) &
(profileId == null ? const Constant(true) : t.profileId.equals(profileId)),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
/// Get the latest action for a specific item
Future<OfflineWatchProgressItem?> getLatestWatchAction(String globalKey) {
Future<OfflineWatchProgressItem?> getLatestWatchAction(
String globalKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
..where(
(t) =>
t.globalKey.equals(globalKey) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
}
Future<List<OfflineWatchProgressItem>> getWatchActionsForKey(
String globalKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
}
Future<Map<String, List<OfflineWatchProgressItem>>> getWatchActionsForKeys(
Set<String> globalKeys, {
String? profileId,
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return const {};
final rows =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
final result = <String, List<OfflineWatchProgressItem>>{};
for (final action in rows) {
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
}
result.putIfAbsent(action.globalKey, () => <OfflineWatchProgressItem>[]).add(action);
}
return result;
}
/// Get the latest actions for multiple items in a single query
///
/// Returns a map of globalKey -> latest action for each key.
/// Keys with no actions will not be present in the returned map.
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(Set<String> globalKeys) async {
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
Set<String> globalKeys, {
String? profileId,
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return {};
// Query all actions for the given keys
final allActions =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.isIn(globalKeys))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
.get();
// Group by globalKey and take the latest (first due to ordering)
final result = <String, OfflineWatchProgressItem>{};
for (final action in allActions) {
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
}
// Only keep the first (latest) action for each key
result.putIfAbsent(action.globalKey, () => action);
}
@@ -92,69 +373,101 @@ class AppDatabase extends _$AppDatabase {
return result;
}
/// Insert or update a progress action (merges with existing)
Future<void> upsertProgressAction({
required String serverId,
required String ratingKey,
required int viewOffset,
required int duration,
required bool shouldMarkWatched,
}) async {
final globalKey = buildGlobalKey(serverId, ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
// Check for existing progress entry
final existing =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress'))
..limit(1))
.getSingleOrNull();
if (existing != null) {
// Update existing progress entry
await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
updatedAt: Value(now),
),
);
} else {
// Insert new progress entry
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
actionType: 'progress',
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
bool _clientScopeValuesMatch(String? actual, String? expected) {
final normalizedActual = actual == null || actual.isEmpty ? null : actual;
final normalizedExpected = expected == null || expected.isEmpty ? null : expected;
return normalizedActual == normalizedExpected;
}
/// Insert a manual watch action (watched or unwatched)
/// Removes conflicting actions for the same item
/// Insert or update a progress action (merges with existing).
Future<void> upsertProgressAction({
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required int viewOffset,
required int? duration,
required bool shouldMarkWatched,
}) async {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
final existing =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
final keep = existing.isEmpty ? null : existing.first;
if (keep != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
),
);
final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false);
if (duplicateIds.isNotEmpty) {
await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go();
}
} else {
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
});
}
/// Insert a manual watch action (watched or unwatched).
/// Removes conflicting actions for the same item.
Future<void> insertWatchAction({
required String serverId,
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String actionType, // 'watched' or 'unwatched'
}) async {
final globalKey = buildGlobalKey(serverId, ratingKey);
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
// Remove conflicting actions (opposite action type and progress)
await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(offlineWatchProgress)..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId),
))
.go();
// Insert the new action
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: actionType,
@@ -181,10 +494,15 @@ class AppDatabase extends _$AppDatabase {
}
/// Get count of pending sync items
Future<int> getPendingSyncCount() async {
final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]))
.map((row) => row.read(offlineWatchProgress.id.count()))
.getSingle();
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
if (profileId != null) {
query.where(offlineWatchProgress.profileId.equals(profileId));
}
if (maxSyncAttempts != null) {
query.where(offlineWatchProgress.syncAttempts.isSmallerThanValue(maxSyncAttempts));
}
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
return count ?? 0;
}
@@ -193,9 +511,105 @@ class AppDatabase extends _$AppDatabase {
return delete(offlineWatchProgress).go();
}
// ============================================================
// Downloaded Media Queries for Watch State Sync
// ============================================================
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
final query = select(syncRules);
if (profileId != null) {
query.where((t) => t.profileId.equals(profileId));
}
return query.get();
}
Future<SyncRuleItem?> getSyncRule(String globalKey) {
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> insertSyncRule({
String profileId = '',
required ServerId serverId,
required String ratingKey,
required String globalKey,
required String targetType,
required int episodeCount,
int mediaIndex = 0,
String downloadFilter = 'unwatched',
}) async {
// [insertOnConflictUpdate] defaults the conflict target to the primary
// key (`id`), which is auto-incremented — the conflict never triggers
// and the row's UNIQUE [globalKey] constraint blows up instead. Drive
// the upsert off the public [globalKey] so re-creating a rule for the same
// shared target updates the existing row.
await into(syncRules).insert(
SyncRulesCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
ratingKey: ratingKey,
globalKey: globalKey,
targetType: targetType,
episodeCount: episodeCount,
createdAt: DateTime.now().millisecondsSinceEpoch,
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
),
onConflict: DoUpdate(
(_) => SyncRulesCompanion(
serverId: Value(serverId),
profileId: Value(profileId),
ratingKey: Value(ratingKey),
targetType: Value(targetType),
episodeCount: Value(episodeCount),
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
),
target: [syncRules.globalKey],
),
);
}
/// Claim pre-v16 public sync rules for [profileId]. Rules created before
/// profile ownership have an empty profile id and a public global key.
Future<void> adoptLegacySyncRulesForProfile(String profileId) async {
if (profileId.isEmpty) return;
final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get();
for (final rule in legacyRules) {
final scopedKey = buildProfileScopedGlobalKey(profileId, ServerId(rule.serverId), rule.ratingKey);
final duplicate = await getSyncRule(scopedKey);
if (duplicate != null) {
await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go();
continue;
}
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)),
);
}
}
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
}
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
}
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
}
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
);
}
Future<void> deleteSyncRule(String globalKey) async {
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
}
/// Get all downloaded media items (for syncing watch states)
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
@@ -211,23 +625,77 @@ LazyDatabase _openConnection() {
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
// Ensure directory exists
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
// Migrate from old location on desktop (was in Documents subfolder)
if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
final oldFolder = await getApplicationDocumentsDirectory();
final oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
if (await oldFile.exists()) {
await oldFile.rename(file.path);
}
await migrateLegacyDesktopDatabase(target: file);
}
return NativeDatabase.createInBackground(file, setup: (db) {
db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL');
});
return NativeDatabase.createInBackground(
file,
setup: (db) {
db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL');
// Enforce ProfileConnections → Profiles/Connections cascades.
// SQLite requires this on every connection — it's not persisted.
db.execute('PRAGMA foreign_keys = ON');
},
);
});
}
/// Move the legacy desktop DB from `Documents/` to `ApplicationSupport/`.
/// `File.rename` only works within a single volume — Windows users with
/// OneDrive-redirected Documents (or any cross-drive setup) hit
/// `ERROR_NOT_SAME_DEVICE` (errno 17), and the uncaught throw used to
/// strand the splash on "Loading servers..." forever (#1022). Falls back
/// to copy + delete on any [FileSystemException] and swallows all errors
/// so a failed migration never propagates fatally.
///
/// [sourceOverride] and [renameOverride] are test seams — production
/// callers leave them null.
Future<void> migrateLegacyDesktopDatabase({
required File target,
File? sourceOverride,
Future<void> Function(File source, String targetPath)? renameOverride,
}) async {
final File oldFile;
if (sourceOverride != null) {
oldFile = sourceOverride;
} else {
final oldFolder = await getApplicationDocumentsDirectory();
oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
}
if (!await oldFile.exists()) return;
try {
if (renameOverride != null) {
await renameOverride(oldFile, target.path);
} else {
await oldFile.rename(target.path);
}
appLogger.i('Moved legacy DB from ${oldFile.path}${target.path}');
return;
} on FileSystemException catch (e) {
appLogger.w('Legacy DB rename failed (osError=${e.osError?.errorCode}); falling back to copy', error: e);
}
try {
await oldFile.copy(target.path);
try {
await oldFile.delete();
} catch (e) {
// Leaving the source behind is non-fatal — the new file is canonical.
appLogger.w('Legacy DB copied but old file delete failed: $e');
}
appLogger.i('Copied legacy DB from ${oldFile.path}${target.path}');
} catch (e, st) {
// Copy itself failed (disk full, source locked by OneDrive sync,
// permissions). Leave both files alone — drift will create a fresh
// empty DB at the new location, and a future relaunch can retry.
appLogger.e('Legacy DB migration failed entirely', error: e, stackTrace: st);
}
}
File diff suppressed because it is too large Load Diff
+131 -25
View File
@@ -1,35 +1,110 @@
import 'package:drift/drift.dart';
import '../media/ids.dart';
import 'app_database.dart';
import '../models/download_models.dart';
import '../profiles/profile.dart';
/// Extension methods on AppDatabase for download operations
extension DownloadDatabaseOperations on AppDatabase {
/// Insert a new download into the database
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
if (profileId.isEmpty) return;
await into(downloadOwners).insert(
DownloadOwnersCompanion.insert(
profileId: profileId,
globalKey: globalKey,
createdAt: DateTime.now().millisecondsSinceEpoch,
),
mode: InsertMode.insertOrIgnore,
);
}
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async {
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
}
Future<void> removeDownloadOwnersForProfile(String profileId) async {
if (profileId.isEmpty) return;
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go();
}
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
if (profileId.isEmpty) return const {};
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
return rows.map((row) => row.globalKey).toSet();
}
Future<int> getDownloadOwnerCount(String globalKey) async {
return (await _validDownloadOwnerRows(globalKey)).length;
}
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
return rows.isNotEmpty;
}
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
if (rows.isEmpty) return const [];
final candidates = rows
.where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId)
.toList(growable: false);
if (candidates.isEmpty) return const [];
final localProfileRows = await select(profiles).get();
final localProfileIds = localProfileRows.map((row) => row.id).toSet();
final connectionRows = await select(connections).get();
final connectionIds = connectionRows.map((row) => row.id).toSet();
return candidates
.where((row) {
if (localProfileIds.contains(row.profileId)) return true;
final plexHome = parsePlexHomeProfileId(row.profileId);
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
return localProfileIds.isEmpty;
})
.toList(growable: false);
}
/// Claim pre-v17 shared download rows for [profileId]. Rows that already
/// have any owner are left untouched so later profiles do not inherit them.
Future<void> adoptLegacyDownloadsForProfile(String profileId) async {
if (profileId.isEmpty) return;
final rows = await select(downloadedMedia).get();
for (final row in rows) {
if (await getDownloadOwnerCount(row.globalKey) == 0) {
await addDownloadOwner(profileId: profileId, globalKey: row.globalKey);
}
}
}
Future<void> insertDownload({
required String serverId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String globalKey,
required String type,
String? parentRatingKey,
String? grandparentRatingKey,
required int status,
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
type: type,
parentRatingKey: Value(parentRatingKey),
grandparentRatingKey: Value(grandparentRatingKey),
status: status,
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
mode: InsertMode.insertOrReplace,
);
}
/// Add item to download queue
Future<void> addToQueue({
required String mediaGlobalKey,
int priority = 0,
@@ -51,7 +126,6 @@ extension DownloadDatabaseOperations on AppDatabase {
/// Get next item from queue (highest priority, oldest first)
/// Only returns items that are not paused
Future<DownloadQueueItem?> getNextQueueItem() async {
// Join with downloadedMedia to check status and filter out paused items
final query = select(
downloadQueue,
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
@@ -68,14 +142,18 @@ extension DownloadDatabaseOperations on AppDatabase {
return result?.readTable(downloadQueue);
}
/// Update download status
Future<void> updateDownloadStatus(String globalKey, int status) async {
await (update(
downloadedMedia,
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
}
/// Update download progress
Future<void> updateDownloadMediaSource(String globalKey, String? mediaSourceId) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)),
);
}
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(
@@ -86,7 +164,6 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
/// Update video file path
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(
@@ -96,16 +173,13 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
/// Update artwork paths
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
await (update(
downloadedMedia,
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
}
/// Update download error and increment retry count
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
// Get current retry count to increment it
final existing = await getDownloadedMedia(globalKey);
final currentCount = existing?.retryCount ?? 0;
@@ -114,52 +188,84 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
/// Clear download error and reset retry count (for retry)
Future<void> clearDownloadError(String globalKey) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
);
}
/// Remove item from queue
Future<void> removeFromQueue(String mediaGlobalKey) async {
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
}
/// Get downloaded media item
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
/// Delete a download
Future<void> deleteDownload(String globalKey) async {
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
}
/// Get all downloaded episodes for a season
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
String seasonKey, {
ServerId? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.parentRatingKey.equals(seasonKey) &
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
))
.get();
}
/// Get all downloaded episodes for a show
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
Future<List<DownloadedMediaItem>> getEpisodesByShow(
String showKey, {
ServerId? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.grandparentRatingKey.equals(showKey) &
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
))
.get();
}
/// Get all downloaded items for a specific server
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
}
/// Update the background_downloader task ID for a download
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, ServerId? serverId) {
return serverId == null ? const Constant(true) : column.equals(serverId);
}
Expression<bool> _optionalClientScopePredicate(
GeneratedColumn<String> column,
String? clientScopeId, {
required bool filterClientScope,
}) {
if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) {
return const Constant(true);
}
if (clientScopeId == null || clientScopeId.isEmpty) {
return column.isNull() | column.equals('');
}
return column.equals(clientScopeId);
}
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
await (update(
downloadedMedia,
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
}
/// Get the background_downloader task ID for a download
Future<String?> getBgTaskId(String globalKey) async {
final item = await getDownloadedMedia(globalKey);
return item?.bgTaskId;
+170 -4
View File
@@ -1,12 +1,12 @@
import 'package:drift/drift.dart';
/// Key-value cache table for Plex API responses.
/// Key-value cache table for media-server API responses (Plex, Jellyfin).
/// Used for offline support - stores raw JSON responses.
class ApiCache extends Table {
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345")
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345"
/// for Plex, "abc123:/Users/.../Items/..." for Jellyfin)
TextColumn get cacheKey => text()();
/// JSON response data
TextColumn get data => text()();
/// Whether this item is pinned for offline access
@@ -30,9 +30,19 @@ class DownloadQueue extends Table {
}
@DataClassName('DownloadedMediaItem')
@TableIndex(name: 'idx_downloaded_media_status', columns: {#status})
@TableIndex(name: 'idx_downloaded_media_server', columns: {#serverId})
@TableIndex(name: 'idx_downloaded_media_parent', columns: {#parentRatingKey})
@TableIndex(name: 'idx_downloaded_media_grandparent', columns: {#grandparentRatingKey})
class DownloadedMedia extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text()();
// Downloads are intentionally app-wide/shared, keyed by the public
// serverId:ratingKey globalKey below. For Jellyfin, clientScopeId records
// which scoped client produced the cached metadata/download request; it is
// not part of download ownership and must not be used to hide or duplicate
// the physical downloaded item per user/profile.
TextColumn get clientScopeId => text().nullable()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get type => text()();
@@ -48,20 +58,176 @@ class DownloadedMedia extends Table {
TextColumn get errorMessage => text().nullable()();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
TextColumn get bgTaskId => text().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get mediaSourceId => text().nullable()();
}
/// Profile ownership for shared physical downloads.
///
/// [DownloadedMedia] stores one physical row per public serverId:ratingKey so
/// files are deduped across profiles. This table controls which active profile
/// can see/use that shared row.
@DataClassName('DownloadOwnerItem')
@TableIndex(name: 'idx_download_owners_profile', columns: {#profileId})
@TableIndex(name: 'idx_download_owners_global_key', columns: {#globalKey})
class DownloadOwners extends Table {
TextColumn get profileId => text()();
TextColumn get globalKey => text()();
IntColumn get createdAt => integer()();
@override
Set<Column> get primaryKey => {profileId, globalKey};
}
/// Persistent sync rules for auto-downloading unwatched episodes.
///
/// Each rule keeps a rolling window of N unwatched episodes for a show/season,
/// or mirrors the current contents of a collection/playlist.
/// Rules are owned by the active top-level profile. Downloads remain app-wide
/// and shared by public serverId:ratingKey identity, but rule ownership must not
/// cross users because Jellyfin permissions and watch state are user-scoped.
@DataClassName('SyncRuleItem')
@TableIndex(name: 'idx_sync_rules_profile', columns: {#profileId})
class SyncRules extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get profileId => text().withDefault(const Constant(''))();
TextColumn get serverId => text()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get targetType => text()(); // 'show', 'season', 'collection', 'playlist'
IntColumn get episodeCount => integer()();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
IntColumn get createdAt => integer()();
IntColumn get lastExecutedAt => integer().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
}
/// Persisted media-server connections.
///
/// One row per "connection" the user has added — a Plex account (with its
/// discovered servers and active Home profile) or a single Jellyfin server.
/// The [configJson] payload is backend-specific and parsed by the
/// [Connection] sealed class.
@DataClassName('ConnectionRow')
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
class Connections extends Table {
/// Stable identifier for the connection. For Plex it's a generated UUID
/// (one per account); for Jellyfin it's the server's machineId.
TextColumn get id => text()();
/// Backend kind: `'plex'` or `'jellyfin'`.
TextColumn get kind => text()();
/// User-visible label (account email, server name).
TextColumn get displayName => text()();
/// Backend-specific config payload (token, baseUrl, profile id, …).
TextColumn get configJson => text()();
/// Whether this is the default connection used at app launch when only
/// one connection is present.
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
/// Timestamp this connection was added (milliseconds since epoch).
IntColumn get createdAt => integer()();
/// Timestamp of the most-recent successful auth refresh (milliseconds
/// since epoch). Null until the first successful auth.
IntColumn get lastAuthenticatedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
/// Top-level profiles — see [Profile].
///
/// A profile is the user-facing identity. Plex Home users auto-surface as
/// `kind='plex_home'` rows when their parent Plex account is added; users
/// can also create `kind='local'` rows manually. Each profile owns one or
/// more connections via [ProfileConnections].
@DataClassName('ProfileRow')
@TableIndex(name: 'idx_profiles_kind', columns: {#kind})
class Profiles extends Table {
/// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}`
/// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`.
TextColumn get id => text()();
/// `'local'` | `'plex_home'`.
TextColumn get kind => text()();
TextColumn get displayName => text()();
/// Plex Home users have a thumb URL; locals fall back to initials/colour.
TextColumn get avatarThumbUrl => text().nullable()();
/// Per-kind config:
/// - `local`: `{ "pinHash": "..." }`
/// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }`
TextColumn get configJson => text()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
IntColumn get createdAt => integer()();
IntColumn get lastUsedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
/// Many-to-many join between [Profiles] and [Connections], carrying the
/// per-profile user-level token used when the profile is active.
///
/// For Plex: `userToken` is a Home-user token from `/home/users/{uuid}/switch`,
/// `userIdentifier` is the Plex Home user UUID. An empty `userToken` is a
/// lazy-fetch sentinel — the binder calls `/switch` on first activation.
/// The Dart-side [ProfileConnection] model surfaces empty as `null`; the
/// column stays non-nullable here to avoid a schema migration.
///
/// For Jellyfin: `userToken` mirrors the Connection's accessToken (one user
/// per Jellyfin connection) and `userIdentifier` is the Jellyfin user id.
@DataClassName('ProfileConnectionRow')
@TableIndex(name: 'idx_profile_connections_connection_id', columns: {#connectionId})
@TableIndex(name: 'idx_profile_connections_profile_id', columns: {#profileId})
class ProfileConnections extends Table {
// No FK on profile_id: Plex Home profiles are virtual (built by
// Profile.virtualPlexHome from PlexHomeService's live cache, never
// persisted in `profiles`), so an FK here would reject every join row
// they need. The two profile-delete sites clean up join rows manually
// via ProfileConnectionRegistry.removeAllForProfile before calling
// ProfileRegistry.remove.
TextColumn get profileId => text()();
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
TextColumn get userToken => text().withDefault(const Constant(''))();
TextColumn get userIdentifier => text()();
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
IntColumn get tokenAcquiredAt => integer().nullable()();
IntColumn get lastUsedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {profileId, connectionId};
}
/// Queue for offline watch progress and manual watch actions.
///
/// Stores watch progress updates and manual watch/unwatch actions
/// that need to be synced to the Plex server when back online.
/// that need to be synced to the originating media server when back online.
@DataClassName('OfflineWatchProgressItem')
@TableIndex(name: 'idx_offline_watch_progress_server', columns: {#serverId})
@TableIndex(name: 'idx_offline_watch_progress_profile', columns: {#profileId})
class OfflineWatchProgress extends Table {
/// Auto-incrementing primary key
IntColumn get id => integer().autoIncrement()();
/// Active Plezy profile that owns this queued action.
TextColumn get profileId => text().nullable()();
/// Server ID this media belongs to
TextColumn get serverId => text()();
/// Optional user-scoped client/cache id for backends where [serverId] is
/// shared by multiple users on the same server.
TextColumn get clientScopeId => text().nullable()();
/// Rating key of the media item
TextColumn get ratingKey => text()();
+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart';
/// Sealed base for backend-agnostic media-server exceptions. Both Plex and
/// Jellyfin auth/HTTP layers throw subtypes from this hierarchy so consumers
/// can catch with one filter and match exhaustively when they care which
/// failure mode it is.
sealed class MediaServerException implements Exception {
final String message;
const MediaServerException(this.message);
@override
String toString() => '$runtimeType: $message';
}
/// The supplied base URL is unreachable, returns the wrong shape, or doesn't
/// look like the expected backend at all. Surfaces in onboarding probes
/// (Jellyfin `/System/Info/Public`, Plex resource discovery).
class MediaServerUrlException extends MediaServerException {
const MediaServerUrlException(super.message);
}
/// Authentication failed — bad password, expired token, disabled user,
/// rate-limit. [statusCode] is the HTTP status when the failure was a 4xx
/// response; null for transport-layer auth signals (e.g. token rejected
/// during refresh).
class MediaServerAuthException extends MediaServerException {
final int? statusCode;
const MediaServerAuthException(super.message, {this.statusCode});
}
/// Auth polling reached a terminal server-side expiry/rejection state before
/// the user completed the external sign-in flow.
class MediaServerPinExpiredException extends MediaServerAuthException {
const MediaServerPinExpiredException() : super('PIN expired before sign-in');
}
/// HTTP transport / non-2xx errors. Carries the status code (when known),
/// the parsed response body, and the originating URI so callers can log
/// useful diagnostics. Both Plex and Jellyfin route their HTTP failures
/// through this type — it's the canonical backend-agnostic transport
/// exception.
enum MediaServerHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown }
class MediaServerHttpException extends MediaServerException {
final MediaServerHttpErrorType type;
final int? statusCode;
final dynamic responseData;
final Uri? requestUri;
MediaServerHttpException({required this.type, String? message, this.statusCode, this.responseData, this.requestUri})
: super(message ?? '');
/// Map a caught exception to a [MediaServerHttpException].
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
return switch (error) {
MediaServerHttpException() => error,
RequestAbortedException(:final message, uri: final errorUri) => MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: message,
requestUri: errorUri ?? uri,
),
TimeoutException(:final message) => MediaServerHttpException(
type: MediaServerHttpErrorType.connectionTimeout,
message: message,
requestUri: uri,
),
SocketException(:final message) => MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError,
message: message,
requestUri: uri,
),
HttpException(:final message) => MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError,
message: message,
requestUri: uri,
),
ClientException(:final message, uri: final errorUri) => MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError,
message: message,
requestUri: errorUri ?? uri,
),
_ => MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri),
};
}
/// Whether the error looks transient (network/timeout) and worth retrying.
bool get isTransient =>
type == MediaServerHttpErrorType.connectionTimeout ||
type == MediaServerHttpErrorType.connectionError ||
type == MediaServerHttpErrorType.receiveTimeout;
@override
String toString() {
final parts = <String>[type.name];
if (statusCode != null) parts.add('HTTP $statusCode');
if (message.isNotEmpty) parts.add(message);
final uri = requestUri;
if (uri != null) parts.add('${uri.host}${uri.path}');
return 'MediaServerHttpException(${parts.join(': ')})';
}
}
+20 -21
View File
@@ -1,13 +1,29 @@
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
/// Extension on KeyEvent for common event type checks.
extension KeyEventActionable on KeyEvent {
/// Whether this event should trigger an action (KeyDownEvent or KeyRepeatEvent).
/// Use this to filter out KeyUpEvents early in key handlers.
bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent;
bool get isPhysicalKeyboardEvent => deviceType == ui.KeyEventDeviceType.keyboard;
// Only true keyboard submit keys belong here. LogicalKeyboardKey.select is
// a TV-remote / dpad-center key (Android DPAD_CENTER, tvOS UIPressTypeSelect)
// — USB keyboards never emit it. The custom Flutter tvOS engine reports its
// synthesized Siri Remote presses with deviceType=keyboard, so classifying
// select-with-keyboard-deviceType as a "keyboard enter" would route center
// dpad through TextField submit and skip the TV virtual keyboard.
bool get isPhysicalKeyboardEnter =>
deviceType == ui.KeyEventDeviceType.keyboard &&
(logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter);
bool get isTvSelectEvent {
// Dpad-center / gamepad-A are TV-remote-only — always treat as TV select,
// regardless of the deviceType claim from the engine.
if (logicalKey == LogicalKeyboardKey.select || logicalKey == LogicalKeyboardKey.gameButtonA) return true;
if (isPhysicalKeyboardEvent) return false;
return logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter;
}
}
/// Shared sets for keyboard key categories.
final _dpadDirectionKeys = {
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
@@ -31,35 +47,18 @@ final _backKeys = {
final _contextMenuKeys = {LogicalKeyboardKey.contextMenu, LogicalKeyboardKey.gameButtonX};
/// Extension methods for checking D-pad related keys.
extension DpadKeyExtension on LogicalKeyboardKey {
/// Whether this key is a D-pad directional key.
bool get isDpadDirection => _dpadDirectionKeys.contains(this);
/// Whether this key is a select/activate key.
bool get isSelectKey => _selectKeys.contains(this);
/// Whether this key is a back/cancel key.
bool get isBackKey => _backKeys.contains(this);
/// Whether this key is a context menu key.
bool get isContextMenuKey => _contextMenuKeys.contains(this);
/// Whether this key is a navigation key (dpad, select, back, context menu, tab).
/// Use this to distinguish navigation keys from typing/volume/media keys.
bool get isNavigationKey =>
isDpadDirection || isSelectKey || isBackKey || isContextMenuKey || this == LogicalKeyboardKey.tab;
/// Whether this key moves focus left.
bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft;
/// Whether this key moves focus right.
bool get isRightKey => this == LogicalKeyboardKey.arrowRight;
/// Whether this key moves focus up.
bool get isUpKey => this == LogicalKeyboardKey.arrowUp;
/// Whether this key moves focus down.
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'focus_theme.dart';
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
/// ABOVE sibling cards on all four sides.
///
/// The glow is an outward blur ([FocusTheme.focusGlowShadows]). When drawn
/// in-tree behind a packed rail/grid it is occluded by later-painted neighbours
/// on the trailing edges and only escapes on the leading (left) edge — producing
/// a one-sided halo (issue #1231). Lifting it into an [OverlayPortal] that
/// follows the card via [LayerLink] makes it render above every sibling, so the
/// glow is symmetric on all sides. The crisp focus border stays in-card; only
/// the glow moves to the overlay.
///
/// Only mounts the overlay/leader while the card is focused (or fading out), so
/// there is at most one [LeaderLayer] on screen regardless of how many cards a
/// grid builds.
class FocusGlowOverlay extends StatefulWidget {
const FocusGlowOverlay({
super.key,
required this.isFocused,
required this.borderRadius,
required this.color,
required this.child,
this.glowSize,
});
/// Whether the wrapped card currently shows focus. Drives the glow.
final bool isFocused;
/// Border radius of the card, used for the glow's rounded rect.
final double borderRadius;
/// Glow colour, resolved from the card's theme at the call site (the overlay
/// builds in the root Overlay's context, which may not carry a nested theme).
final Color color;
/// Explicit card size. When null, falls back to [LayerLink.leaderSize] (one
/// frame late on first show, hidden under the opacity-0 fade-in).
final Size? glowSize;
final Widget child;
@override
State<FocusGlowOverlay> createState() => _FocusGlowOverlayState();
}
class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
final OverlayPortalController _controller = OverlayPortalController();
final LayerLink _link = LayerLink();
/// Drives the [AnimatedOpacity] target. Set false on focus loss so the glow
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
@override
void initState() {
super.initState();
if (widget.isFocused) {
_visible = true;
_controller.show();
}
}
@override
void didUpdateWidget(FocusGlowOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_controller.show();
// Start hidden, then fade in next frame.
_visible = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isFocused) setState(() => _visible = true);
});
} else {
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
setState(() => _visible = false);
}
}
void _handleFadeEnd() {
if (!_visible && mounted && _controller.isShowing) {
_controller.hide();
setState(() {}); // drop the OverlayPortal/leader from the tree
}
}
@override
Widget build(BuildContext context) {
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
if (!widget.isFocused && !_controller.isShowing) {
return widget.child;
}
final duration = FocusTheme.getAnimationDuration(context);
return OverlayPortal(
controller: _controller,
overlayChildBuilder: (overlayContext) {
final size = widget.glowSize ?? _link.leaderSize;
final extent = FocusTheme.focusGlowExtent;
return CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.topLeft,
followerAnchor: Alignment.topLeft,
offset: Offset(-extent, -extent),
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: duration,
curve: Curves.easeOutCubic,
onEnd: _handleFadeEnd,
child: size == null
? const SizedBox.shrink()
: CustomPaint(
size: Size(size.width + extent * 2, size.height + extent * 2),
painter: _FocusGlowPainter(
rect: Offset(extent, extent) & size,
borderRadius: widget.borderRadius,
shadows: FocusTheme.focusGlowShadows(widget.color),
),
),
),
),
);
},
child: CompositedTransformTarget(link: _link, child: widget.child),
);
}
}
/// Paints [shadows] around [rect] with the rect interior clipped out, so only
/// the outer glow shows — matching the original look where the opaque card hid
/// the inner part of the shadow.
class _FocusGlowPainter extends CustomPainter {
const _FocusGlowPainter({required this.rect, required this.borderRadius, required this.shadows});
final Rect rect;
final double borderRadius;
final List<BoxShadow> shadows;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius));
final clip = Path.combine(PathOperation.difference, Path()..addRect(Offset.zero & size), Path()..addRRect(rrect));
canvas.save();
canvas.clipPath(clip);
for (final shadow in shadows) {
canvas.drawRRect(rrect.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint());
}
canvas.restore();
}
@override
bool shouldRepaint(_FocusGlowPainter oldDelegate) {
return oldDelegate.rect != rect ||
oldDelegate.borderRadius != borderRadius ||
!listEquals(oldDelegate.shadows, shadows);
}
}
+1 -3
View File
@@ -9,9 +9,7 @@ class FocusMemoryTracker {
final String _debugLabelPrefix;
String? _lastFocusedKey;
FocusMemoryTracker({VoidCallback? onFocusChanged, String debugLabelPrefix = 'focus'})
: _onFocusChanged = onFocusChanged,
_debugLabelPrefix = debugLabelPrefix;
FocusMemoryTracker({this._onFocusChanged, this._debugLabelPrefix = 'focus'});
/// Get or create a focus node for the given key
FocusNode get(String key, {String? debugLabel}) {
+30 -10
View File
@@ -1,44 +1,64 @@
import 'package:flutter/material.dart';
import '../theme/mono_tokens.dart';
/// Focus styling constants for D-pad navigation.
class FocusTheme {
FocusTheme._();
/// Scale factor when an item is focused.
static const double focusScale = 1.02;
/// Border width for the focus indicator.
static const double fullCardFocusScale = 1.03;
static const double focusBorderWidth = 2.5;
/// Default border radius (matches MonoTokens.radiusSm).
static const double defaultBorderRadius = 8.0;
static const double focusGlowInnerBlurRadius = 18;
static const double focusGlowOuterBlurRadius = 34;
static const double focusGlowSpreadRadius = 1.5;
/// Get the focus border color from the theme.
static Color getFocusBorderColor(BuildContext context) {
return Theme.of(context).colorScheme.primary;
}
/// Get the animation duration from MonoTokens.
static Duration getAnimationDuration(BuildContext context) {
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
}
/// Build the focus border decoration.
static BoxDecoration focusDecoration(
BuildContext context, {
required bool isFocused,
double borderRadius = defaultBorderRadius,
double borderStrokeAlign = BorderSide.strokeAlignInside,
Color? color,
}) {
final focusColor = color ?? getFocusBorderColor(context);
return BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth),
border: Border.all(
color: isFocused ? focusColor : Colors.transparent,
width: focusBorderWidth,
strokeAlign: borderStrokeAlign,
),
);
}
/// The focus glow as a list of [BoxShadow]s.
///
/// Rendered by [FocusGlowOverlay] in the root overlay so the glow paints
/// above sibling cards on all four sides (an in-tree background shadow is
/// occluded by later-painted neighbours, which produced the one-sided halo).
static List<BoxShadow> focusGlowShadows(Color color) {
return [
BoxShadow(
color: color.withValues(alpha: 0.34),
blurRadius: focusGlowInnerBlurRadius,
spreadRadius: focusGlowSpreadRadius,
),
BoxShadow(color: color.withValues(alpha: 0.2), blurRadius: focusGlowOuterBlurRadius),
];
}
/// How far the focus glow visibly reaches beyond the card edge. Used to size
/// the overlay paint area so the blur is not clipped.
static double get focusGlowExtent => focusGlowOuterBlurRadius * 2 + focusGlowSpreadRadius;
/// Build focus decoration with background color instead of border.
/// Useful for video controls where it should match the native hover style.
static BoxDecoration focusBackgroundDecoration({required bool isFocused, double borderRadius = defaultBorderRadius}) {

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