Compare commits

..
772 Commits
Author SHA1 Message Date
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
github-actions[bot] db50cb4837 chore: bump version to 1.30.0 2026-03-26 09:05:04 +00:00
edde746 b4b90eec63 fix: use forked material_symbols_icons for IconData compat 2026-03-26 08:32:16 +01:00
edde746 beff313814 fix: use Flutter 3.41.5 for arm64 builds 2026-03-26 07:12:09 +01:00
edde746 0dc9cd38af fix: pin arm64 Flutter version before IconData final 2026-03-26 00:45:18 +01:00
edde746 8ff7676e60 fix: use continuous play queue for aired date episode order
close #756
2026-03-25 23:28:57 +01:00
edde746 bfe35220aa fix: skip SAF picker on Android TV 2026-03-25 20:56:32 +01:00
edde746 843faee4c5 fix: run sqlite on background isolate 2026-03-25 20:50:35 +01:00
edde746 f8bfeb0ec6 fix: use stable Flutter channel for arm64 builds 2026-03-25 20:34:08 +01:00
edde746 dd441fee66 fix: ignore EOF completion events for live TV streams 2026-03-25 19:51:11 +01:00
edde746 2204b05d6c fix: remove reconnect_at_eof from live TV stream options 2026-03-25 19:10:35 +01:00
edde746 a1841804b6 fix: non-primary CustomScrollViews in IndexedStack tabs 2026-03-25 19:10:35 +01:00
Micah Morrison 25e3c4a543 Show server tasks 2026-03-25 11:33:01 -04:00
edde746andGitHub 5e31ad9c67 Merge pull request #753 from micahmo/fix/single-season
Fix missing season tabs
2026-03-25 15:45:11 +01:00
Micah Morrison 477b92291b Fix missing season tabs 2026-03-25 09:20:49 -04:00
edde746 fcc6186796 fix: dedupe live TV timeline heartbeat, delay initial send 2026-03-25 12:25:19 +01:00
edde746 23179f6d17 perf: ValueNotifier detail scroll, skip redundant arrow rebuilds 2026-03-25 08:12:18 +01:00
edde746 996e8621fa perf: ValueNotifier scroll state, static skeletons, scoped selects 2026-03-25 08:03:47 +01:00
edde746 8ad1cb988f fix: detach mpv surface before view removal in dispose 2026-03-25 07:25:32 +01:00
edde746 4cef99dda2 perf: coalesce watch together rebuilds, static TV overlays 2026-03-25 06:40:17 +01:00
edde746 4beeedd318 fix: library grid stuck skeletons on fast scroll 2026-03-25 06:35:01 +01:00
edde746 23043c61f4 fix: Tensor SoC detection for internal model IDs 2026-03-25 04:15:34 +01:00
edde746 8d56171bd2 perf: library grid scroll and idle frame rendering
- Window _loadedItems and focus nodes to bound memory growth
- Eager prefetch and image precaching near viewport
- Static skeleton placeholders during active scroll
- Throttle alpha bar updates, dynamic initial fetch size
- Fix discover timer scheduling frames when tab hidden
- Disable tickers for offscreen IndexedStack children
- Static image placeholder with fade-in transition
2026-03-24 17:19:28 +01:00
edde746 ff0fafe938 fix: speed step 0.1 -> 0.25 for keyboard shortcuts
close #741
2026-03-23 15:02:13 +01:00
edde746 8b7f369c16 fix: keyboard shortcuts for unmapped keys
close #740
2026-03-23 14:32:54 +01:00
github-actions[bot] 5c0e40bddb chore: update cask to 1.29.0 2026-03-23 05:22:26 +00:00
github-actions[bot] 58b0bf5306 chore: bump version to 1.29.0 2026-03-23 02:51:31 +00:00
edde746 9bd2774820 fix: race conditions in mpv dispose and track manager, add debug fallback trigger 2026-03-23 03:48:48 +01:00
edde746 cc7da5bbc9 fix: live tv channels for non-admin users 2026-03-22 15:58:25 +01:00
edde746 bd4e30e653 fix: external subtitles on android mpv 2026-03-22 15:34:49 +01:00
edde746 e759dafa87 fix: disable impeller on Tensor/NVIDIA GPUs 2026-03-22 14:35:42 +01:00
edde746 3ae2488adf feat: screen lock
close #286
2026-03-22 11:48:34 +01:00
edde746 2f5b596fff fix: disable pip on android tv
close #698
2026-03-22 11:17:45 +01:00
edde746 472ac90fea feat: rewind on resume setting
close #735
2026-03-22 09:24:22 +01:00
edde746 e9dfee6870 fix: profile select back button bypass
close #737
2026-03-22 09:03:14 +01:00
edde746 48e57fd375 fix: match content frame rate
close #736
2026-03-22 08:12:16 +01:00
edde746 4b05ed79ea fix: bump libmpv-android to v1.0.6 2026-03-22 04:59:31 +01:00
edde746 4365e70a78 fix(android): libmpv failed init reinit 2026-03-21 16:51:25 +01:00
edde746 e8c02d04ae fix: linux titlebar height on non-GNOME DEs 2026-03-21 15:57:03 +01:00
edde746 0539c1d869 fix: overlay sheet position, scroll-to-selected in bottom sheets 2026-03-21 11:49:08 +01:00
edde746 1980ce92d5 fix: pip aspect ratio after exoplayer→mpv fallback 2026-03-21 09:19:21 +01:00
edde746 b5576fa6de fix: reduce hub section vertical spacing 2026-03-21 09:05:21 +01:00
edde746 d355e07e10 refactor: switch to libmpv-android fork, fix ANR and track selection 2026-03-21 08:53:08 +01:00
edde746 09ff47e19f fix: reduce CPU when paused and backgrounded on macOS 2026-03-21 08:36:25 +01:00
edde746 4ee4a766d6 fix: pip background thread layout crash 2026-03-21 07:54:04 +01:00
edde746 7ebbf75cb9 fix: handle background isolate shutdown on iOS 2026-03-21 01:43:34 +01:00
edde746 e95b768350 chore: update appcast.xml url 2026-03-21 00:04:10 +01:00
edde746 82f0d46acc fix: update libass 2026-03-20 09:07:17 +01:00
edde746 bafba0b151 fix: guard cache put against non-Map response data 2026-03-20 08:29:36 +01:00
edde746 7a68218439 fix: guard setState with mounted in pairing screen 2026-03-20 08:07:29 +01:00
edde746 2d37d37596 refactor: extract TrackManager, fix subtitle bugs
- Extract track lifecycle logic from VideoPlayerScreen (3248→2928 lines) into TrackManager
- Parse isDefault/isForced for subtitle tracks in parseTrackList
- Emit forced flag from ExoPlayer's emitTrackList
- Fix detectSubtitleMimeType failing on URLs with query params
- Add missing external_ ID prefix in ExoPlayer addSubtitleTrack
- Replace polling loop with stream in selectAndApplyTracks
- Handle play() failure in resumeAfterSubtitleLoad
- Handle addExternalSubtitles failure in onBackendSwitched
2026-03-20 03:32:32 +01:00
edde746 d64c72f40e fix: await external subs before track selection in backend fallback 2026-03-20 00:05:18 +01:00
edde746 efe375f817 fix: check track state before subscribing to broadcast stream 2026-03-19 23:38:50 +01:00
edde746 1f8dfd3587 fix: regenerate macos Podfile.lock 2026-03-19 21:48:01 +01:00
edde746 60eae5d14f fix: reapply track selection and external subs after MPV fallback 2026-03-19 21:37:37 +01:00
edde746 5123a5ec48 chore: bump dependencies 2026-03-19 20:53:35 +01:00
edde746 dbb3ae38b3 chore: bump sentry_flutter to 9.15.0 2026-03-19 19:56:36 +01:00
edde746 fbf6fc988b chore: update libass-android 2026-03-19 19:52:05 +01:00
edde746 17da465e4e chore: bump mobile_scanner to 7.2.0 2026-03-19 19:49:57 +01:00
edde746 e096a4ccb8 fix: use direct TrackGroup refs for track selection, clear stale overrides 2026-03-19 18:58:07 +01:00
github-actions[bot] 079417ad3e chore: update cask to 1.28.0 2026-03-19 05:06:01 +00:00
github-actions[bot] edb2120f98 chore: bump version to 1.28.0 2026-03-19 04:40:54 +00:00
edde746 cc0305d84a fix: set leafCount for offline season metadata 2026-03-19 05:14:17 +01:00
edde746 3f78686661 fix: download deletion using DB file paths
close #721
2026-03-19 05:07:27 +01:00
edde746 13604cca01 fix: defer nativeSurfaceChanged out of layout pass 2026-03-18 21:13:57 +01:00
edde746andGitHub eae4eacf00 Merge pull request #719 from micahmo/fix/delete-propagation-flat
Fix delete propagation when episodes are displayed in flat list
2026-03-18 17:00:55 +01:00
Micah Morrison 4119824048 Fix delete propagation when episodes are displayed in flat list 2026-03-18 11:58:46 -04:00
edde746 8bdf2b53d3 fix: reduce bottom sheet spacing 2026-03-18 09:46:48 +01:00
edde746 fb4f17a898 feat: support MKV ContentCompAlgo 0 (zlib) in ExoPlayer 2026-03-18 08:52:14 +01:00
edde746 ad0623334c feat: approximate seeking for MKV files without Cues 2026-03-18 08:52:14 +01:00
edde746 13dcfd7551 fix: load downloaded subtitles for offline playback
close #718
2026-03-18 08:52:14 +01:00
edde746 053884a147 fix: use gpu-api=auto for mpv
close #715
2026-03-18 02:36:29 +01:00
edde746 4f893cb826 fix: incorrect queue sheet item sizing 2026-03-17 10:45:04 +01:00
edde746 94aa966f46 fix: enable WAL mode for sqlite 2026-03-17 10:31:39 +01:00
edde746 0fcdcf3c1f fix: reduce image cache budgets and add memory pressure handling 2026-03-17 09:48:18 +01:00
edde746 4147bd0d86 feat: overlay timestamp inside timeline thumbnail preview 2026-03-16 08:03:28 +01:00
edde746 3ea3455772 fix: prevent SurfaceControl use-after-free
Synchronously null surface fields in dispose() before deferred view
removal, add session generation counter to cancel stale MPV fallbacks,
and use postAtFrontOfQueue for defense-in-depth message ordering.
2026-03-16 06:15:12 +01:00
edde746 a82c3d2d2d feat: add grouping and type filtering for shared libraries
Show grouping chip for shared (mixed-type) libraries with options:
All, Movies, TV Shows, Seasons, Episodes. Filter with type=1,2,3,4
to exclude library section directory entries from /library/shared/all.
2026-03-16 05:58:36 +01:00
edde746 04ef1b8ad2 fix: guard against missing library in libraries screen
Use firstOrNull instead of firstWhere to prevent StateError crash
when selected library is temporarily absent from provider during
server re-probe after LOW_MEMORY events.
2026-03-16 02:45:01 +01:00
edde746 b009dd9c80 chore: remove unused workmanager and android_intent_plus dependencies 2026-03-15 22:55:18 +01:00
edde746 e9f1979e6c refactor: remove dead getters from model classes 2026-03-15 22:55:18 +01:00
edde746 23906dd65d refactor: remove dead code from provider files 2026-03-15 22:55:18 +01:00
edde746 630755cf7b refactor: remove dead utility methods
Remove filterOutMusic, watchUserProfile, and watchHiddenLibraries
2026-03-15 22:55:18 +01:00
edde746 3c2f72d15f refactor: remove dead methods from services 2026-03-15 22:55:18 +01:00
edde746 ff5cf06e60 chore: remove HttpTimingTransferListener debug logging 2026-03-15 22:47:51 +01:00
edde746 9798486724 chore: bump cronet 2026-03-15 22:38:06 +01:00
edde746 db6161d84e refactor: replace numeric tab indexes with NavigationTabId 2026-03-15 22:31:54 +01:00
edde746 af0de5c0a9 fix: switch from OkHttp to Cronet 2026-03-15 22:00:51 +01:00
edde746 e0d9b4ee93 refactor: unify focus restoration for main screen tabs
Replace per-tab if-chains in _focusContent() and _selectTab() with
generic FocusableTab/TabVisibilityAware dispatch. Fixes search screen
focus on Android TV (sidebar → Search now works).
2026-03-15 21:55:22 +01:00
edde746 551be5bbef feat: dynamic library tabs and shared library navigation
- Replace hardcoded 4-tab system with data-driven visible tab list
- Shared libraries show only Browse + Playlists tabs
- Fix dpad navigation for shared libraries (no hidden tab targets)
- Switch to TickerProviderStateMixin to support tab controller recreation
- Navigate to library screen when tapping shared library sections
- Persist tab selection by name instead of index
2026-03-15 21:36:17 +01:00
edde746 bbc7f0f665 fix: focus search input on search tab 2026-03-15 19:21:34 +01:00
edde746 8433d59432 feat: add Cmd/Ctrl+F shortcut to open search
close #705
2026-03-15 19:02:50 +01:00
edde746 59b3e6ce81 fix: eliminate duplicate API requests on startup
- Skip redundant notifyListeners in HiddenLibrariesProvider init when
  set is empty (empty→empty is a no-op for consumers)
- Track last-seen hidden keys in DiscoverScreen to avoid reloading
  content when the listener fires without an actual change
- Track previous online server set in MultiServerProvider; only re-check
  DVR availability when a new server comes online
- Remove dead content-loading pipeline from libraries_screen (sorts,
  pagination, filters, items) that was never rendered
- Move /media/providers fetch into PlexClient.create() init, replacing
  lazy EPG cache and providing libraries including shared items
2026-03-15 18:23:32 +01:00
edde746 a5629c54f6 feat: mask unfocused digits in TV PIN input 2026-03-15 14:18:25 +01:00
edde746 45726a57ce feat: log git commit hash 2026-03-15 13:51:09 +01:00
edde746 5f597ef567 fix: prefer exact language code match in track selection
Fixes #703
2026-03-15 11:40:45 +01:00
edde746 bf464b257d fix: log full exception chain on ExoPlayer errors 2026-03-15 11:38:07 +01:00
edde746 0100b2a2ea fix: preserve start position and subtitles in ExoPlayer→MPV fallback 2026-03-15 11:32:25 +01:00
edde746 360c3356cf chore: remove 27 unused translation strings 2026-03-15 07:57:45 +01:00
edde746 5f18efcfb3 feat: live TV channel favorites
close #663
2026-03-15 07:44:44 +01:00
edde746 79888168ae fix: add timeout diagnostics 2026-03-15 05:06:16 +01:00
edde746 2427a55901 fix: dispatch EventChannel calls to main thread 2026-03-15 03:11:23 +01:00
edde746 ac397f32a3 feat: redesign TV player controls
- BACK key dismisses layers (content strip → controls → exit) with
  early BackKeyCoordinator marking to prevent PopScope race condition
- Hide volume control and PiP button on TV
- Fix focus chain with mounted-node-aware navigation for live/non-live
- Fix focus reclaim on controls hide (hasPrimaryFocus vs hasFocus)
  to prevent screen-level self-heal from stealing timeline focus
- LEFT/RIGHT shows controls with timeline focused for seeking

Close #701
2026-03-15 03:10:51 +01:00
edde746 d236e7fec6 fix: tighten and align media detail screen spacing 2026-03-15 01:31:18 +01:00
edde746 6d7cdc4ef4 fix: preserve chapters/markers in cache during watch state sync 2026-03-15 01:04:13 +01:00
edde746 9a296b3da8 fix: guard scrollListToIndex against multiple scroll positions 2026-03-14 22:11:48 +01:00
edde746 f27861748c ci: push appcast.xml to dedicated branch on release 2026-03-14 19:00:11 +01:00
edde746 c9668af2ae feat: sort home screen hubs by user's library order
Closes #696
2026-03-14 19:00:11 +01:00
github-actions[bot] 9c2253616f chore: update cask to 1.27.2 2026-03-14 15:05:21 +00:00
edde746 cfb8bf04d6 fix: reduce remote playback startup latency
- Lower OkHttp readTimeout from 30s to 10s so stalled Range requests retry faster
- Reduce bufferForPlaybackMs from 2500 to 1000 for quicker playback start
- Add HTTP transfer timing logs (TTFB, bytes, duration) to diagnose startup delays
2026-03-14 12:15:53 +01:00
github-actions[bot] 9efdd5a17d chore: bump version to 1.27.2 2026-03-14 10:21:54 +00:00
edde746 dd11e8d3b2 fix: scroll list tiles into view on dpad focus 2026-03-14 11:20:31 +01:00
edde746 cde6e99a17 fix: dpad nav skipping episodes on single-season shows
close #691
2026-03-14 11:09:01 +01:00
edde746 045e68ca10 chore: update dart_discord_presence to 1.1.1 2026-03-14 05:41:02 +01:00
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00
github-actions[bot] 642c49b1ea chore: bump version to 1.27.1 2026-03-14 04:03:34 +00:00
edde746 e13b16f13c fix(android): mpv boolean NPE on dispose 2026-03-14 05:01:39 +01:00
github-actions[bot] 1f9fa9ba4f chore: update cask to 1.27.0 2026-03-14 01:17:01 +00:00
github-actions[bot] 849ef6efe5 chore: bump version to 1.27.0 2026-03-14 00:53:01 +00:00
edde746 e968a20f4c fix(livetv): soften EPG grid program styling 2026-03-13 18:49:40 +01:00
edde746 7a6b866c28 fix(livetv): fetch boundary-spanning programs
close #686
2026-03-13 18:45:00 +01:00
edde746 6ccbaed3fd fix(livetv): always bold EPG program titles in guide grid
close #687
2026-03-13 18:20:47 +01:00
edde746 d01628a262 fix(android): use OkHttp connection pooling to prevent high-bitrate stutter
Replace DefaultHttpDataSource with OkHttpDataSource for TCP connection
reuse on buffer refill. Increase buffer sizes for devices with >=1GB RAM.
2026-03-13 17:23:06 +01:00
edde746 675ac84486 fix: use settings-style bottom sheet for browse grouping picker 2026-03-13 12:05:51 +01:00
edde746 e84dc93910 fix: prevent tab revert and overflow bleed on mobile tab switch
Skip auto-focus in touch mode to prevent ensureVisible() from fighting
TabBarView animations, and clip each tab with ClipRect to stop hub row
overflow bleeding into adjacent tabs during swipe transitions.
2026-03-13 00:09:24 +01:00
edde746 3d30ea1342 fix(macos): reduce idle CPU when video player is paused
- Remove vulkan-swap-mode mailbox (continuous GPU rendering even when paused)
- End playback activity assertion on pause, restore on play
- Fix PerformanceStatsService persistent frame callback leak
2026-03-12 23:22:58 +01:00
edde746 efd28d3ca3 docs: add AUR package to readme 2026-03-12 13:20:16 +01:00
edde746 8a28a2c398 fix: only autofocus bottom sheets in keyboard/dpad mode 2026-03-12 05:02:52 +01:00
edde746 c6fa6afa9b fix: android thread safety
- ExoPlayerPlugin: move all MPV JNI calls off main thread via runOnExecutor
- WatchNextPlugin: run ContentProvider ops on IO executor with error handling
- MpvPlayerCore: guard executor methods against dispose race (RejectedExecutionException)
- MainActivity: fix PiP null engine NPE with safe flutterEngine?.let
2026-03-12 03:34:32 +01:00
edde746 912ebc2f90 fix: cooldown resume health probes, debounce connectivity, coalesce concurrent checks
Add per-platform cooldown on resume health probes (10s mobile, 2min
desktop). Debounce connectivity listener by 2s. Coalesce concurrent
checkServerHealth and reconnectOfflineServers into single in-flight
futures.
2026-03-12 02:52:14 +01:00
edde746 f04b49e260 fix: guard mpv event handler during disposal 2026-03-12 02:44:50 +01:00
edde746 a17458ff9b fix: handle DBusServiceUnknownException on Linux without NetworkManager 2026-03-12 02:24:00 +01:00
edde746 c062dd944e feat: inline season tabs and d-pad focus improvements 2026-03-12 00:57:51 +01:00
edde746 1a4516bdd1 Merge branch 'feature/respect-season-mode' 2026-03-11 23:09:23 +01:00
edde746 5538e0627b refactor: remove SeasonDetailScreen, unify into MediaDetailScreen
Extract EpisodeCard to its own widget file. MediaDetailScreen now handles
isSeason by showing episodes directly, with proper watch state tracking,
deletion handling, and D-pad focus navigation. Hide empty overview sections.
2026-03-11 23:08:39 +01:00
edde746 7febfa9771 fix: deduplicate episode fetching and use lazy list for flattened seasons 2026-03-11 22:35:30 +01:00
Micah Morrison 160be52f02 Fix parsing setting value 2026-03-11 17:13:11 -04:00
edde746 828d57af24 fix: auto-reconnect stream after network loss during playback 2026-03-11 22:08:11 +01:00
edde746 1f7fb2201c fix: handle null ratingKey in folder browsing 2026-03-11 22:08:11 +01:00
Micah Morrison 32ae51142f Respect the Plex server library season view mode 2026-03-11 16:42:49 -04:00
edde746andGitHub 7014df7a0e Merge pull request #680 from micahmo/fix/always-on-top
Fix issue with always on top getting stuck
2026-03-11 20:50:49 +01:00
Micah Morrison cb0ed15c0c Fix issue with always on top getting stuck 2026-03-11 15:00:17 -04:00
edde746 d4e9364d29 fix: handle TogglePlayPauseEvent for macOS media key
close #679
2026-03-11 18:28:07 +01:00
edde746 4681d60850 fix: hide admin actions from non-admin users
close #677
2026-03-11 18:18:27 +01:00
edde746 78052dc07f fix: player stuck on skip credits with no next episode
close #678
2026-03-11 18:02:33 +01:00
edde746 67c66ff0c1 fix: prevent layout shift for image placeholders 2026-03-11 15:02:38 +01:00
edde746 d1bb99635a feat: configurable intro/credits marker regex patterns
Add settings to customize regex patterns for matching intro and credits
markers in chapter titles, with the current hardcoded values as defaults.
Also reclassifies real markers with non-standard type strings against the
configured patterns.
2026-03-11 15:00:24 +01:00
github-actions[bot] 69092e3656 chore: update cask to 1.26.1 2026-03-11 13:32:42 +00:00
1123 changed files with 214008 additions and 48377 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
+11
View File
@@ -68,10 +68,21 @@ 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
- type: input
id: device
attributes:
@@ -54,9 +54,21 @@ body:
- Windows
- macOS
- Android TV
- tvOS
- Linux
- All platforms
- type: dropdown
id: media-server
attributes:
label: Media Server
description: Is this for Jellyfin, Plex, or both?
multiple: true
options:
- Jellyfin
- Plex
- Both
- type: textarea
id: additional
attributes:
+191 -41
View File
@@ -6,6 +6,8 @@ on:
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
jobs:
build-android:
@@ -27,16 +29,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 +51,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 +64,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()
@@ -104,16 +113,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 +136,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: |
@@ -162,16 +178,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 +201,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:
@@ -324,22 +347,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
@@ -354,29 +391,47 @@ jobs:
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
@@ -480,16 +535,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 APT packages
uses: awalsh128/cache-apt-pkgs-action@latest
@@ -499,8 +554,27 @@ jobs:
- 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
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
)
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 +591,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
@@ -588,16 +669,16 @@ 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
@@ -607,8 +688,27 @@ jobs:
- 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
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
)
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 +725,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: |
@@ -704,10 +811,53 @@ jobs:
sparse-checkout: pubspec.yaml
sparse-checkout-cone-mode: false
- name: Download all artifacts
- name: Download Android artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
name: android-apk
path: artifacts/android-apk
- name: Download iOS artifact
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: artifacts/ios-ipa
- name: Download macOS artifact
uses: actions/download-artifact@v4
with:
name: macos-dmg
path: artifacts/macos-dmg
- name: Download Windows x64 artifact
uses: actions/download-artifact@v4
with:
name: windows-x64-portable
path: artifacts/windows-x64-portable
- name: Download Windows arm64 artifact
uses: actions/download-artifact@v4
with:
name: windows-arm64-portable
path: artifacts/windows-arm64-portable
- name: Download Windows installer artifact
uses: actions/download-artifact@v4
with:
name: windows-installer
path: artifacts/windows-installer
- name: Download Linux x64 artifacts
uses: actions/download-artifact@v4
with:
name: linux-x64
path: artifacts/linux-x64
- name: Download Linux arm64 artifacts
uses: actions/download-artifact@v4
with:
name: linux-arm64
path: artifacts/linux-arm64
- name: Display structure of downloaded files
run: ls -R artifacts
+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 }}
+24
View File
@@ -52,3 +52,27 @@ jobs:
identifier: edde746.Plezy
installers-regex: 'plezy-windows-installer\.exe$'
token: ${{ secrets.WINGET_TOKEN }}
update-appcast-branch:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download appcast.xml from release
run: |
gh release download "${{ github.event.release.tag_name }}" \
--pattern "appcast.xml"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Push to appcast branch
run: |
VERSION="${{ github.event.release.tag_name }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BLOB=$(git hash-object -w appcast.xml)
TREE=$(printf "100644 blob %s\tappcast.xml\n" "$BLOB" | git mktree)
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $VERSION")
git push origin "$COMMIT:refs/heads/appcast" --force
+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.26.0"
sha256 "4a231ebe702f8c50042d5b13251432d0a1170fb13e66aecf9fb3bf0a5573dc7d"
version "2.1.0"
sha256 "9219831cab16f33b3b7a1223f3a120aa8aed44a28352839f3ec5fb569be77cd6"
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
+107 -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,59 +23,95 @@ 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
brew install --cask plezy
```
- [AUR](https://aur.archlinux.org/packages/plezy-bin) (Arch Linux) - Community maintained by [@jianglai](https://github.com/jianglai):
```bash
yay -S plezy-bin
```
- **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
@@ -77,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
+149 -106
View File
@@ -1,148 +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.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 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 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)
}
dependencies {
implementation("dev.jdtech.mpv:libmpv:0.5.1")
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")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// 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")
// libass-android for ASS/SSA subtitle rendering
implementation("io.github.peerless2012:ass-media:0.4.0")
// ass-kt core library (needed for AssRender.setFontScale)
implementation("io.github.peerless2012:ass-kt:0.4.0")
// 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))) }
}
+2 -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 -->
+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);
// 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 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);
}
@@ -1,313 +1,608 @@
package com.edde746.plezy
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.app.ActivityManager
import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.SurfaceTexture
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.ThemeHelper
import com.edde746.plezy.watchnext.WatchNextPlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterTextureView
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.ThemeHelper
import com.edde746.plezy.watchnext.WatchNextPlugin
import java.io.File
class MainActivity : FlutterActivity() {
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private var watchNextPlugin: WatchNextPlugin? = null
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
var usingSkia = false
}
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private val DEVICE_CHANNEL = "com.plezy/device"
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
private var watchNextPlugin: WatchNextPlugin? = null
private var nativeTextInputFocused = false
override fun onCreate(savedInstanceState: Bundle?) {
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
// screen and Flutter's first frame for non-default themes (e.g. OLED).
val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
val savedTheme = prefs.getString("splash_theme", null)
ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) }
private inline fun logTextInputDiag(message: () -> String) {
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
Log.i(TAG, "TextInputDiag ${message()}")
}
}
super.onCreate(savedInstanceState)
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
// Disable the Android splash screen fade-out animation to avoid
// a flicker before Flutter draws its first frame.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() }
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
private fun isImeVisible(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
}
private fun keyActionName(action: Int): String = when (action) {
KeyEvent.ACTION_DOWN -> "down"
KeyEvent.ACTION_UP -> "up"
KeyEvent.ACTION_MULTIPLE -> "multiple"
else -> "unknown($action)"
}
private fun sourceNames(source: Int): String {
val names = mutableListOf<String>()
if ((source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD) names.add("keyboard")
if ((source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) names.add("dpad")
if ((source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) names.add("gamepad")
if ((source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) names.add("joystick")
if ((source and InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN) names.add("touchscreen")
if ((source and InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) names.add("mouse")
return names.ifEmpty { listOf("unknown") }.joinToString("+")
}
private fun isDpadKeyCode(keyCode: Int): Boolean = when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER -> true
else -> false
}
private fun describeDevice(event: KeyEvent): String {
val device = event.device ?: return "device=null deviceId=${event.deviceId}"
return "deviceId=${event.deviceId} name=${device.name} vendor=${device.vendorId} product=${device.productId} " +
"keyboardType=${device.keyboardType} sources=0x${Integer.toHexString(device.sources)}[${sourceNames(device.sources)}]"
}
private fun describeKeyEvent(event: KeyEvent): String = "action=${keyActionName(event.action)} key=${KeyEvent.keyCodeToString(event.keyCode)}(${event.keyCode}) " +
"scan=${event.scanCode} repeat=${event.repeatCount} source=0x${Integer.toHexString(event.source)}[${sourceNames(event.source)}] " +
"flags=0x${Integer.toHexString(event.flags)} meta=0x${Integer.toHexString(event.metaState)} ${describeDevice(event)}"
private fun describeImeState(): String {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val focus = currentFocus
return "nativeTextInputFocused=$nativeTextInputFocused imeVisible=${isImeVisible()} " +
"acceptingText=${imm.isAcceptingText} activeDecor=${imm.isActive(window.decorView)} " +
"decorHasFocus=${window.decorView.hasFocus()} currentFocus=${focus?.javaClass?.name} " +
"currentFocusHasFocus=${focus?.hasFocus()} currentFocusFocused=${focus?.isFocused}"
}
private fun shouldForwardDpadBeforeIme(): Boolean {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val forward = !nativeTextInputFocused && !isImeVisible() && !imm.isAcceptingText
logTextInputDiag { "shouldForwardDpadBeforeIme=$forward ${describeImeState()}" }
return forward
}
private fun getAndroidTvDetection(): Map<String, Any> {
val pm = packageManager
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
val isTelevisionUiMode = uiModeType == Configuration.UI_MODE_TYPE_TELEVISION
@Suppress("DEPRECATION")
val hasTelevisionFeature = pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
val hasLeanback = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv")
val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH)
val reasons = mutableListOf<String>()
if (isTelevisionUiMode) reasons.add("ui_mode_television")
if (hasTelevisionFeature) reasons.add("television_feature")
if (hasLeanback) reasons.add("leanback")
if (hasFireTvFeature) reasons.add("fire_tv")
if (!hasTouchscreen) reasons.add("no_touchscreen")
return mapOf(
"isTv" to reasons.isNotEmpty(),
"reasons" to reasons,
"isTelevisionUiMode" to isTelevisionUiMode,
"hasTelevisionFeature" to hasTelevisionFeature,
"hasLeanback" to hasLeanback,
"hasFireTvFeature" to hasFireTvFeature,
"hasTouchscreen" to hasTouchscreen,
"hasFakeTouch" to hasFakeTouch,
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL
)
}
override fun onCreate(savedInstanceState: Bundle?) {
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
// screen and Flutter's first frame for non-default themes (e.g. OLED).
val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
val savedTheme = prefs.getString("splash_theme", null)
ThemeHelper.themeColor(savedTheme)?.let { window.decorView.setBackgroundColor(it) }
super.onCreate(savedInstanceState)
// Disable the Android splash screen fade-out animation to avoid
// a flicker before Flutter draws its first frame.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() }
}
// Disable Android's default focus highlight ring that appears when using
// D-pad navigation so the Flutter UI can render its own focus state.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.decorView.defaultFocusHighlightEnabled = false
}
// Wrap the content view in a layout that intercepts DPAD key events
// before the IME input stage, which can consume DPAD direction events
// from virtual remotes before they reach Flutter's key handler.
val content = findViewById<ViewGroup>(android.R.id.content)
val wrapper = object : FrameLayout(this) {
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "preIme received ${describeKeyEvent(event)} ${describeImeState()}" }
}
// Disable Android's default focus highlight ring that appears when using
// D-pad navigation so the Flutter UI can render its own focus state.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.decorView.defaultFocusHighlightEnabled = false
}
// Wrap the content view in a layout that intercepts DPAD key events
// before the IME input stage, which can consume DPAD direction events
// from virtual remotes before they reach Flutter's key handler.
val content = findViewById<ViewGroup>(android.R.id.content)
val wrapper = object : FrameLayout(this) {
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (!imm.isAcceptingText) {
super.dispatchKeyEvent(event)
return true
}
}
}
return super.dispatchKeyEventPreIme(event)
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> {
if (shouldForwardDpadBeforeIme()) {
logTextInputDiag { "preIme forwarding-to-Flutter-and-consuming ${describeKeyEvent(event)}" }
super.dispatchKeyEvent(event)
return true
}
logTextInputDiag { "preIme letting-IME-handle ${describeKeyEvent(event)}" }
}
}
while (content.childCount > 0) {
val child = content.getChildAt(0)
content.removeViewAt(0)
wrapper.addView(child)
val handled = super.dispatchKeyEventPreIme(event)
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "preIme superResult=$handled ${describeKeyEvent(event)} ${describeImeState()}" }
}
content.addView(wrapper, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT))
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
return handled
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle Watch Next deep link when app is already running
handleWatchNextIntent(intent)
while (content.childCount > 0) {
val child = content.getChildAt(0)
content.removeViewAt(0)
wrapper.addView(child)
}
content.addView(
wrapper,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
private fun handleWatchNextIntent(intent: Intent?) {
val contentId = WatchNextPlugin.handleIntent(intent)
if (contentId != null) {
// Notify the plugin to send event to Flutter
watchNextPlugin?.notifyDeepLink(contentId)
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle Watch Next deep link when app is already running
handleWatchNextIntent(intent)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
}
val handled = super.dispatchKeyEvent(event)
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag {
"activity.dispatchKeyEvent after handled=$handled ${describeKeyEvent(event)} ${describeImeState()}"
}
}
return handled
}
private fun handleWatchNextIntent(intent: Intent?) {
val contentId = WatchNextPlugin.handleIntent(intent)
if (contentId != null) {
// Notify the plugin to send event to Flutter
watchNextPlugin?.notifyDeepLink(contentId)
}
}
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
usingSkia = shouldDisableImpeller()
if (usingSkia) args.add("--enable-impeller=false")
return args
}
private fun shouldDisableImpeller(): Boolean {
// Android TV devices — weaker GPUs, less Impeller testing
if (isAndroidTvDevice()) return true
if (DeviceQuirks.isEWaste) return true
// NVIDIA Tegra (Shield TV)
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
// Huawei/HONOR Kirin SoCs use Mali GPUs
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
) {
return true
}
return false
}
override fun getRenderMode(): RenderMode {
// Keep Flutter in the normal View hierarchy so video/subtitle SurfaceViews
// remain the only native composition layers. This restores the pre-1.35.0
// behavior and avoids compositor regressions with Dolby Vision playback.
return RenderMode.texture
}
override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below.
return TransparencyMode.transparent
}
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
val original = flutterTextureView.surfaceTextureListener ?: return
val handler = Handler(Looper.getMainLooper())
var pendingResize: Runnable? = null
var lastWidth = 0
var lastHeight = 0
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
original.onSurfaceTextureAvailable(surface, width, height)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
if (width == lastWidth && height == lastHeight) return
lastWidth = width
lastHeight = height
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, width, height)
}
}
handler.postDelayed(pendingResize!!, 100)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
original.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = null
lastWidth = 0
lastHeight = 0
return original.onSurfaceTextureDestroyed(surface)
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getTvDetection" -> result.success(getAndroidTvDetection())
else -> result.notImplemented()
}
}
override fun getRenderMode(): RenderMode {
// Use TextureView so Flutter doesn't occupy a SurfaceView layer.
// This allows the libass subtitle SurfaceView to sit between video and Flutter UI.
return RenderMode.texture
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setNativeTextInputFocused" -> {
val oldValue = nativeTextInputFocused
nativeTextInputFocused = call.arguments as? Boolean ?: false
logTextInputDiag {
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
}
result.success(null)
}
else -> result.notImplemented()
}
}
override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below.
return TransparencyMode.transparent
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_EXIT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"requestExit" -> {
result.success(true)
window.decorView.post {
finishAndRemoveTask()
}
}
else -> result.notImplemented()
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"requestForeground" -> result.success(requestForeground())
else -> result.notImplemented()
}
}
// External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"openVideo" -> {
val filePath = call.argument<String>("filePath")
val packageName = call.argument<String>("package")
// External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"openVideo" -> {
val filePath = call.argument<String>("filePath")
val packageName = call.argument<String>("package")
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
try {
val uri: Uri
val grantRead: Boolean
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (packageName != null) {
setPackage(packageName)
}
}
startActivity(intent)
result.success(true)
} catch (e: android.content.ActivityNotFoundException) {
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
} catch (e: Exception) {
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
}
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
// Persist for next cold start & update window background now
getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
.edit().putString("splash_theme", mode).apply()
ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val themeId = when (mode) {
"dark" -> R.style.SplashTheme_Dark
"oled" -> R.style.SplashTheme_Oled
"light" -> R.style.SplashTheme_Light
"system" -> android.content.res.Resources.ID_NULL
else -> android.content.res.Resources.ID_NULL
}
splashScreen.setSplashScreenTheme(themeId)
}
result.success(true)
}
else -> result.notImplemented()
}
}
// Register Watch Next plugin and keep reference for deep link handling
watchNextPlugin = WatchNextPlugin()
flutterEngine.plugins.add(watchNextPlugin!!)
MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
}
"enter" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
result.success(mapOf("success" to false, "errorCode" to "android_version"))
return@setMethodCallHandler
}
if (!isPipPermissionGranted()) {
result.success(mapOf("success" to false, "errorCode" to "permission_disabled"))
return@setMethodCallHandler
}
try {
val width = call.argument<Int>("width") ?: 16
val height = call.argument<Int>("height") ?: 9
val clamped = clampAspectRatio(width, height)
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(clamped.first, clamped.second))
.build()
val success = enterPictureInPictureMode(params)
if (success) {
result.success(mapOf("success" to true))
} else {
result.success(mapOf("success" to false, "errorCode" to "failed"))
}
} catch (e: IllegalStateException) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
} catch (e: Exception) {
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
}
}
"setAutoPipReady" -> {
autoPipReady = call.argument<Boolean>("ready") ?: false
autoPipWidth = call.argument<Int>("width") ?: 16
autoPipHeight = call.argument<Int>("height") ?: 9
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val clamped = clampAspectRatio(autoPipWidth, autoPipHeight)
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(clamped.first, clamped.second))
.setAutoEnterEnabled(autoPipReady)
.build()
setPictureInPictureParams(params)
} catch (_: Exception) {}
}
result.success(true)
}
else -> result.notImplemented()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (packageName != null) {
setPackage(packageName)
}
}
startActivity(intent)
result.success(true)
} catch (e: android.content.ActivityNotFoundException) {
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
} catch (e: Exception) {
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
MethodChannel( flutterEngine!!.dartExecutor.binaryMessenger, PIP_CHANNEL ).invokeMethod( "onPipChanged" , isInPictureInPictureMode)
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
// Notify ExoPlayer plugin to resize video surface for PiP
flutterEngine?.plugins?.get(ExoPlayerPlugin::class.java)?.let { plugin ->
(plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode)
// Persist for next cold start & update window background now
getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
.edit().putString("splash_theme", mode).apply()
ThemeHelper.themeColor(mode)?.let { window.decorView.setBackgroundColor(it) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val themeId = when (mode) {
"dark" -> R.style.SplashTheme_Dark
"oled" -> R.style.SplashTheme_Oled
"light" -> R.style.SplashTheme_Light
"system" -> android.content.res.Resources.ID_NULL
else -> android.content.res.Resources.ID_NULL
}
splashScreen.setSplashScreenTheme(themeId)
}
result.success(true)
}
else -> result.notImplemented()
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
autoPipReady && isPipPermissionGranted()) {
// Register Watch Next plugin and keep reference for deep link handling
watchNextPlugin = WatchNextPlugin()
flutterEngine.plugins.add(watchNextPlugin!!)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
}
"enter" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
result.success(mapOf("success" to false, "errorCode" to "android_version"))
return@setMethodCallHandler
}
if (isAndroidTvDevice()) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
return@setMethodCallHandler
}
if (!isPipPermissionGranted()) {
result.success(mapOf("success" to false, "errorCode" to "permission_disabled"))
return@setMethodCallHandler
}
try {
val width = call.argument<Int>("width") ?: 16
val height = call.argument<Int>("height") ?: 9
val params = buildPipParams(width, height)
val success = enterPictureInPictureMode(params)
if (success) {
result.success(mapOf("success" to true))
} else {
result.success(mapOf("success" to false, "errorCode" to "failed"))
}
} catch (e: IllegalStateException) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
} catch (e: Exception) {
result.success(mapOf("success" to false, "errorCode" to "unknown", "errorMessage" to (e.message ?: "Unknown error")))
}
}
"setAutoPipReady" -> {
if (isAndroidTvDevice()) {
autoPipReady = false
result.success(true)
return@setMethodCallHandler
}
autoPipReady = call.argument<Boolean>("ready") ?: false
autoPipWidth = call.argument<Int>("width") ?: 16
autoPipHeight = call.argument<Int>("height") ?: 9
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
// Notify Flutter to prepare video filter before PiP
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null)
}
val clamped = clampAspectRatio(autoPipWidth, autoPipHeight)
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(clamped.first, clamped.second))
.build()
enterPictureInPictureMode(params)
} catch (_: Exception) {}
val params = buildPipParams(autoPipWidth, autoPipHeight, autoEnterEnabled = autoPipReady)
setPictureInPictureParams(params)
} catch (e: Exception) {
Log.w(TAG, "Failed to set auto-PiP params", e)
}
}
result.success(true)
}
else -> result.notImplemented()
}
}
}
private fun isPipPermissionGranted(): Boolean {
val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOpsManager.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
applicationInfo.uid,
packageName
) == AppOpsManager.MODE_ALLOWED
private fun requestForeground(): Boolean = try {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.moveTaskToFront(taskId, 0)
true
} catch (e: Exception) {
Log.w(TAG, "Failed to move task to foreground", e)
try {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (launchIntent != null) {
startActivity(launchIntent)
true
} else {
false
}
} catch (launchError: Exception) {
Log.w(TAG, "Failed to start foreground activity", launchError)
false
}
}
private fun clampAspectRatio(width: Int, height: Int): Pair<Int, Int> {
val ratio = width.toFloat() / height.toFloat()
return when {
ratio < 0.42f -> Pair(5, 12)
ratio > 2.39f -> Pair(12, 5)
else -> Pair(width, height)
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
flutterEngine?.let { engine ->
MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode)
engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin ->
(plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode)
}
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
if (!isAndroidTvDevice() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
autoPipReady &&
isPipPermissionGranted()
) {
try {
// Notify Flutter to prepare video filter before PiP
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger ->
MethodChannel(messenger, PIP_CHANNEL).invokeMethod("onAutoPipEntering", null)
}
val params = buildPipParams(autoPipWidth, autoPipHeight)
enterPictureInPictureMode(params)
} catch (e: Exception) {
Log.w(TAG, "Failed to enter auto-PiP", e)
}
}
}
private fun isPipPermissionGranted(): Boolean {
val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOpsManager.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
applicationInfo.uid,
packageName
) == AppOpsManager.MODE_ALLOWED
}
private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams {
val (w, h) = if (width <= 0 || height <= 0) {
Pair(16, 9)
} else {
val ratio = width.toFloat() / height.toFloat()
when {
ratio < 1f / 2.39f -> Pair(100, 239)
ratio > 2.39f -> Pair(239, 100)
else -> Pair(width, height)
}
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(w, h))
if (autoEnterEnabled != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(autoEnterEnabled)
}
return builder.build()
}
}
@@ -0,0 +1,169 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackOutput
/**
* Extractor wrapper that enables approximate seeking for MKV files without Cues.
*
* When the underlying extractor reports an [SeekMap.Unseekable] seek map (i.e. the MKV
* has no Cues element), this wrapper replaces it with a proportional byte-position
* estimate and scans for the nearest Cluster boundary after seeking.
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
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
}
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
}
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
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
}
}
// 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))
}
}
}
@@ -8,78 +8,103 @@ import android.util.Log
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
}
const val CONVERT_FAILED = -1
const val DESTINATION_TOO_SMALL = -2
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
true
} catch (_: UnsatisfiedLinkError) {
Log.w(TAG, "Native lib not found")
false
}
}
fun isAvailable(): Boolean = nativeLoaded &&
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
private val conversionPathReady: Boolean by lazy {
nativeLoaded && runCatching { nativeIsConversionPathReady() }.getOrDefault(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
}
fun isAvailable(): Boolean = conversionPathReady
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 deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
val deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
}
fun getConversionMode(): DvConversionMode = when {
!isAvailable() -> DvConversionMode.DISABLED
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
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)
}
val deviceSupportsDvProfile8: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
}
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
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
}
@JvmStatic
private external fun nativeConvertDv7RpuToDv81(
payload: ByteArray,
payloadOffset: Int,
payloadLength: Int,
output: ByteArray,
outputOffset: Int,
outputCapacity: Int,
mode: Int
): Int
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
@JvmStatic
private external fun nativeIsConversionPathReady(): Boolean
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()
}
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
@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
}
@@ -1,12 +1,12 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
/**
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
@@ -24,339 +24,693 @@ import java.io.ByteArrayOutputStream
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
* - nuh_layer_id > 0: Enhancement layer NAL → strip
* - All retained NALs: normalize nuh_layer_id to 0
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : TrackOutput {
companion object {
private const val TAG = "DoviConvertTrack"
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63
private const val LIBDOVI_MODE_TO_81 = 2
private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1)
companion object {
private const val TAG = "DoviConvertTrack"
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63
private const val LIBDOVI_MODE_TO_81 = 2
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val MAX_CONVERTED_RPU_SIZE = 16 * 1024
private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1)
}
var conversionActive = false
private set
var strippedNalCount = 0L
private set
var strippedInitNalCount = 0L
private set
var convertedRpuCount = 0L
private set
var rpuConversionFailureCount = 0L
private set
var rpuOutputTooSmallCount = 0L
private set
val averageRpuConversionTimeUs: Long
get() = if (rpuConversionCallCount > 0) totalRpuConversionTimeUs / rpuConversionCallCount else 0L
val averageSampleProcessingTimeUs: Long
get() = if (sampleCount > 0) totalSampleProcessingTimeUs / sampleCount else 0L
// Reusable buffers — grown as needed, never shrunk
private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var sampleLen = 0
private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var outputLen = 0
private val outputParsable = ParsableByteArray()
private var buffering = false
// Sample counter for periodic logging
private var sampleCount = 0L
private var rpuConversionCallCount = 0L
private var totalRpuConversionTimeUs = 0L
private var totalSampleProcessingTimeUs = 0L
private var loggedSupplementalWrapper = false
private var loggedEncryptedSupplementalPassthrough = false
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) {
conversionActive = true
logInfo("DV Profile 7 detected ($codecs), mode=$dvMode")
logInfo(
"Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})"
)
val strippedInitializationData = stripInitializationData(format.initializationData)
val newFormat = when (dvMode) {
DvConversionMode.DV81 -> {
// Parse DV level from codec string: "dvhe.07.06" → 6
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level)
logInfo("DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(newCodecs)
.setInitializationData(strippedInitializationData + dvConfigRecord)
.build()
}
else -> {
// HEVC_STRIP: present as plain HEVC
logInfo("HEVC_STRIP: rewriting to video/hevc")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs(null)
.setInitializationData(strippedInitializationData)
.build()
}
}
logInfo(
"Rewritten format: mime=${newFormat.sampleMimeType}, " +
"codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries"
)
delegate.format(newFormat)
return
}
}
delegate.format(format)
}
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
var conversionActive = false
private set
var strippedNalCount = 0L
private set
var convertedRpuCount = 0L
private set
buffering = true
ensureSampleCapacity(sampleLen + length)
val bytesRead = input.read(sampleBuf, sampleLen, length)
if (bytesRead > 0) {
sampleLen += bytesRead
}
return bytesRead
}
// Sample buffering between sampleData() and sampleMetadata()
private val sampleBuffer = ByteArrayOutputStream(256 * 1024)
private var buffering = false
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) {
conversionActive = true
Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode")
Log.i(TAG, "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})")
val newFormat = when (dvMode) {
DvConversionMode.DV81 -> {
// Parse DV level from codec string: "dvhe.07.06" → 6
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level)
Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(newCodecs)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0], dvConfigRecord)
else
listOf(ByteArray(0), dvConfigRecord)
)
.build()
}
else -> {
// HEVC_STRIP: present as plain HEVC
Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs(null)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0])
else
emptyList()
)
.build()
}
}
Log.i(TAG, "Rewritten format: mime=${newFormat.sampleMimeType}, " +
"codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries")
delegate.format(newFormat)
return
}
}
delegate.format(format)
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
buffering = true
ensureSampleCapacity(sampleLen + length)
data.readBytes(sampleBuf, sampleLen, length)
sampleLen += length
}
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val buf = ByteArray(length)
val bytesRead = input.read(buf, 0, length)
if (bytesRead > 0) {
sampleBuffer.write(buf, 0, bytesRead)
}
return bytesRead
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = false
val srcLen = sampleLen
sampleLen = 0
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val bytes = ByteArray(length)
data.readBytes(bytes, 0, length)
sampleBuffer.write(bytes, 0, length)
val processStartNs = System.nanoTime()
val success = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
loggedEncryptedSupplementalPassthrough = true
logWarn("Encrypted supplemental sample encountered, passing raw sample")
}
false
} else {
try {
processSampleData(flags, srcLen)
true
} catch (e: Exception) {
logError("NAL processing failed, passing raw sample", e)
false
}
}
val outLen = if (success) outputLen else srcLen
val outBuf = if (success) outputBuf else sampleBuf
if (success) {
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (outLen == 0) return
buffering = false
val rawSample = sampleBuffer.toByteArray()
sampleBuffer.reset()
outputParsable.reset(outBuf, outLen)
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
}
val processed = try {
processNalUnits(rawSample)
} catch (e: Exception) {
Log.e(TAG, "NAL processing failed, passing raw sample", e)
rawSample
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (processed.isEmpty()) return
// Write processed data to delegate. Offset must be 0 since we write exactly
// the processed amount (no trailing data from next sample in the buffer).
val parsable = ParsableByteArray(processed, processed.size)
delegate.sampleData(parsable, processed.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, processed.size, 0, cryptoData)
/**
* Process a sample, unwrapping Media3 supplemental-data framing when present.
*
* Samples with C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA are stored as:
* [4-byte big-endian main sample size][main sample][supplemental data].
* Only the main sample contains video NALs and should be rewritten.
*/
private fun processSampleData(flags: Int, dataLen: Int) {
if ((flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) == 0) {
processNalUnits(0, dataLen)
return
}
// Sample counter for periodic logging
private var sampleCount = 0L
/**
* Process a single NAL: convert RPU (DV81), strip DV layers, or keep.
* Returns processed NAL data to write, or null if stripped.
*/
private fun processNal(nalData: ByteArray): ByteArray? {
if (nalData.size < 2) return nalData
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
convertedRpuCount++
converted
} else {
strippedNalCount++
null
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
null
}
else -> {
normalizeLayerId(nalData)
nalData
}
}
if (dataLen < 4) {
logWarn("Bad supplemental sample: ${dataLen}B is too small")
copyRawSample(dataLen)
return
}
/**
* Process NAL units in the sample data. Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*/
private fun processNalUnits(data: ByteArray): ByteArray {
if (data.size < 4) return data
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (data.size >= 4 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 0.toByte() && data[3] == 1.toByte()) ||
(data.size >= 3 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 1.toByte())
if (sampleCount == 0L) {
Log.d(TAG, "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${data.take(8).joinToString(" ") { "%02X".format(it) }}")
}
return if (isAnnexB) processAnnexBNals(data) else processLengthPrefixedNals(data)
val mainSampleLen = readInt32BE(sampleBuf, 0)
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
copyRawSample(dataLen)
return
}
/**
* Find all Annex B start code positions (00 00 01 or 00 00 00 01) in the data.
* Returns list of pairs: (startCodeEnd, startCodeLen) where startCodeEnd is the
* byte index right after the start code, and startCodeLen is 3 or 4.
*/
private fun findAnnexBStartCodes(data: ByteArray): List<Pair<Int, Int>> {
val positions = mutableListOf<Pair<Int, Int>>()
var i = 0
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
positions.add(Pair(i + 4, 4))
i += 4
continue
} else if (data[i + 2] == 1.toByte()) {
positions.add(Pair(i + 3, 3))
i += 3
continue
}
}
i++
}
return positions
val supplementalLen = dataLen - 4 - mainSampleLen
if (!loggedSupplementalWrapper) {
loggedSupplementalWrapper = true
logDebug(
"Supplemental wrapper detected: total=${dataLen}B, main=${mainSampleLen}B, " +
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(sampleBuf, 4, 8)}"
)
}
/** Process Annex B formatted NAL units (MKV path). */
private fun processAnnexBNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
var kept = 0
var stripped = 0
processNalUnits(4, mainSampleLen)
val startCodes = findAnnexBStartCodes(data)
if (startCodes.isEmpty()) {
sampleCount++
return data
}
for (idx in startCodes.indices) {
val nalStart = startCodes[idx].first
val nalEnd = if (idx + 1 < startCodes.size) {
startCodes[idx + 1].first - startCodes[idx + 1].second
} else {
data.size
}
if (nalEnd <= nalStart) continue
val result = processNal(data.copyOfRange(nalStart, nalEnd))
if (result != null) {
output.write(ANNEX_B_START_CODE)
output.write(result)
kept++
} else {
stripped++
}
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
val processedMainLen = outputLen
if (processedMainLen == 0) {
outputLen = 0
return
}
/** Process length-prefixed NAL units (MP4 path). */
private fun processLengthPrefixedNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
var pos = 0
var kept = 0
var stripped = 0
ensureOutputCapacity(4 + processedMainLen + supplementalLen)
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
writeInt32BE(outputBuf, 0, processedMainLen)
if (supplementalLen > 0) {
System.arraycopy(sampleBuf, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
}
outputLen = 4 + processedMainLen + supplementalLen
while (pos + 4 <= data.size) {
val nalLen = ((data[pos].toInt() and 0xFF) shl 24) or
((data[pos + 1].toInt() and 0xFF) shl 16) or
((data[pos + 2].toInt() and 0xFF) shl 8) or
(data[pos + 3].toInt() and 0xFF)
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
logDebug(
"Sample #$sampleCount (Supplemental): main=${mainSampleLen}B -> ${processedMainLen}B, " +
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
)
}
}
if (nalLen <= 0 || pos + 4 + nalLen > data.size) {
if (sampleCount < 5) {
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=${data.size})")
}
break
}
/**
* Process NAL units in sampleBuf[dataOffset..dataOffset+dataLen). Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*
* Result is written to outputBuf[0..outputLen).
*/
private fun processNalUnits(dataOffset: Int, dataLen: Int) {
outputLen = 0
if (dataLen < 4) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
return
}
val result = processNal(data.copyOfRange(pos + 4, pos + 4 + nalLen))
if (result != null) {
writeLengthPrefixedNal(output, result)
kept++
} else {
stripped++
}
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (
dataLen >= 4 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 0.toByte() &&
sampleBuf[dataOffset + 3] == 1.toByte()
) ||
(
dataLen >= 3 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 1.toByte()
)
pos += 4 + nalLen
if (sampleCount == 0L) {
logDebug(
"NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${formatBytes(sampleBuf, dataOffset, 8)}"
)
}
if (isAnnexB) processAnnexBNals(dataOffset, dataLen) else processLengthPrefixedNals(dataOffset, dataLen)
}
/** Process Annex B formatted NAL units (MKV path). Scans inline, no list allocation. */
private fun processAnnexBNals(dataOffset: Int, dataLen: Int) {
ensureOutputCapacity(dataLen)
val dataEnd = dataOffset + dataLen
var kept = 0
var stripped = 0
// Find first start code
var scEnd = -1
var i = dataOffset
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
scEnd = i + 4
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
scEnd = i + 3
break
}
}
i++
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
if (scEnd < 0) {
// No start codes found — pass through
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
sampleCount++
return
}
var nalStart = scEnd
while (nalStart < dataEnd) {
// Find next start code to determine end of current NAL
var nalEnd = dataEnd
i = nalStart
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
nalEnd = i
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
nalEnd = i
break
}
}
return output.toByteArray()
}
i++
}
private fun normalizeLayerId(nalData: ByteArray) {
if (nalData.size >= 2) {
nalData[0] = (nalData[0].toInt() and 0xFE).toByte() // Clear bit 0 of byte 0
nalData[1] = (nalData[1].toInt() and 0x07).toByte() // Clear bits 7-3 of byte 1
val nalLen = nalEnd - nalStart
if (nalLen > 0) {
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val convertedLen = convertRpuIntoOutput(nalStart, nalLen, outputLen + 4)
if (convertedLen >= 0) {
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
normalizeLayerId(outputBuf, outputLen)
outputLen += convertedLen
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
}
// Advance past the next start code
if (nalEnd >= dataEnd) break
nalStart = if (nalEnd + 3 < dataEnd && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
nalEnd + 4
} else {
nalEnd + 3
}
}
private fun writeLengthPrefixedNal(output: ByteArrayOutputStream, nalData: ByteArray) {
val len = nalData.size
output.write((len ushr 24) and 0xFF)
output.write((len ushr 16) and 0xFF)
output.write((len ushr 8) and 0xFF)
output.write(len and 0xFF)
output.write(nalData)
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
logDebug(
"Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs"
)
}
}
/** Process length-prefixed NAL units (MP4 path). */
private fun processLengthPrefixedNals(dataOffset: Int, dataLen: Int) {
ensureOutputCapacity(dataLen)
val dataEnd = dataOffset + dataLen
var pos = dataOffset
var kept = 0
var stripped = 0
while (pos + 4 <= dataEnd) {
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
(sampleBuf[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > dataEnd) {
if (sampleCount < 5) {
logWarn("Bad NAL length $nalLen at pos ${pos - dataOffset} (data.size=$dataLen)")
}
break
}
val nalStart = pos + 4
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
writeInt32BE(outputBuf, outputLen, nalLen)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val convertedLen = convertRpuIntoOutput(nalStart, nalLen, outputLen + 4)
if (convertedLen >= 0) {
writeInt32BE(outputBuf, outputLen, convertedLen)
outputLen += 4
normalizeLayerId(outputBuf, outputLen)
outputLen += convertedLen
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
pos += 4 + nalLen
}
/**
* Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1.
*
* Binary layout (from Dolby Vision spec):
* byte[0]: dv_version_major = 1
* byte[1]: dv_version_minor = 0
* byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit)
* byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1)
* byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits)
* byte[5-23]: reserved (zeros)
*/
private fun buildDv81ConfigRecord(level: Int): ByteArray {
val record = ByteArray(24)
record[0] = 0x01 // dv_version_major = 1
record[1] = 0x00 // dv_version_minor = 0
record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB
record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1
record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10)
// bytes 5-23 remain 0 (reserved)
return record
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
logDebug(
"Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs"
)
}
}
private enum class NalAction { KEEP, STRIP, CONVERT }
private fun convertRpuIntoOutput(nalStart: Int, nalLen: Int, outputOffset: Int): Int {
ensureOutputCapacity(outputOffset + MAX_CONVERTED_RPU_SIZE)
var retriedAfterResize = false
while (true) {
val startNs = System.nanoTime()
val written = DoviBridge.convertRpuNalu(
payload = sampleBuf,
payloadOffset = nalStart,
payloadLength = nalLen,
output = outputBuf,
outputOffset = outputOffset,
outputCapacity = outputBuf.size,
mode = LIBDOVI_MODE_TO_81
)
totalRpuConversionTimeUs += (System.nanoTime() - startNs) / 1_000L
rpuConversionCallCount++
if (written >= 0) return written
if (written == DoviBridge.DESTINATION_TOO_SMALL) {
rpuOutputTooSmallCount++
if (!retriedAfterResize) {
val doubled = if (outputBuf.size <= Int.MAX_VALUE / 2) outputBuf.size * 2 else Int.MAX_VALUE
ensureOutputCapacity(maxOf(doubled, outputOffset + MAX_CONVERTED_RPU_SIZE))
retriedAfterResize = true
continue
}
}
rpuConversionFailureCount++
return written
}
}
private fun recordSampleProcessing(elapsedUs: Long) {
totalSampleProcessingTimeUs += elapsedUs
if (sampleCount <= 3 || (sampleCount > 0 && sampleCount % 500 == 0L)) {
logDebug(
"Perf: avgSample=${averageSampleProcessingTimeUs}us, " +
"avgRpu=${averageRpuConversionTimeUs}us, converted=$convertedRpuCount, " +
"rpuFailures=$rpuConversionFailureCount, rpuTooSmall=$rpuOutputTooSmallCount"
)
}
}
private fun stripInitializationData(initializationData: List<ByteArray>): List<ByteArray> {
if (initializationData.isEmpty()) return emptyList()
val stripped = ArrayList<ByteArray>(initializationData.size)
var beforeBytes = 0
var afterBytes = 0
var droppedBuffers = 0
for (data in initializationData) {
beforeBytes += data.size
val processed = stripInitializationAnnexB(data)
if (processed == null) {
// Media3's HEVC init data is normally Annex B. Non-AnnexB entries in
// Dolby Vision formats are typically dvcC/dvvC records, which must not
// be forwarded when advertising plain HEVC.
droppedBuffers++
continue
}
if (processed.isEmpty()) {
droppedBuffers++
continue
}
afterBytes += processed.size
stripped.add(processed)
}
logInfo(
"Init data stripped: ${initializationData.size} entries/${beforeBytes}B -> " +
"${stripped.size} entries/${afterBytes}B, strippedNals=$strippedInitNalCount, " +
"droppedBuffers=$droppedBuffers"
)
return stripped
}
private fun stripInitializationAnnexB(data: ByteArray): ByteArray? {
if (data.isEmpty()) return data
val firstStartCodeEnd = findStartCodeEnd(data, 0) ?: return null
ensureOutputCapacity(data.size)
outputLen = 0
var kept = 0
var stripped = 0
var nalStart = firstStartCodeEnd
while (nalStart < data.size) {
val nalEnd = findNextStartCodeOffset(data, nalStart) ?: data.size
val nalLen = nalEnd - nalStart
if (nalLen > 0) {
when (classifyNal(data, nalStart, nalLen, convertRpu = false)) {
NalAction.KEEP -> {
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(data, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
}
else -> {
strippedInitNalCount++
stripped++
}
}
}
if (nalEnd >= data.size) break
nalStart = findStartCodeEnd(data, nalEnd) ?: break
}
logDebug("Init data sample: ${data.size}B -> ${outputLen}B, kept=$kept stripped=$stripped NALs")
return outputBuf.copyOf(outputLen)
}
private fun findStartCodeEnd(data: ByteArray, from: Int): Int? {
var i = from
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
return i + 4
} else if (data[i + 2] == 1.toByte()) {
return i + 3
}
}
i++
}
return null
}
private fun findNextStartCodeOffset(data: ByteArray, from: Int): Int? {
var i = from
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
return i
} else if (data[i + 2] == 1.toByte()) {
return i
}
}
i++
}
return null
}
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun classifyNal(data: ByteArray, offset: Int, len: Int, convertRpu: Boolean): NalAction {
if (len < 2) return NalAction.KEEP
val nalType = (data[offset].toInt() ushr 1) and 0x3F
val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or
((data[offset + 1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && convertRpu -> NalAction.CONVERT
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
else -> NalAction.KEEP
}
}
private fun normalizeLayerId(data: ByteArray, offset: Int) {
if (data.size - offset >= 2) {
data[offset] = (data[offset].toInt() and 0xFE).toByte()
data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte()
}
}
private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) {
buf[offset] = ((value ushr 24) and 0xFF).toByte()
buf[offset + 1] = ((value ushr 16) and 0xFF).toByte()
buf[offset + 2] = ((value ushr 8) and 0xFF).toByte()
buf[offset + 3] = (value and 0xFF).toByte()
}
private fun readInt32BE(buf: ByteArray, offset: Int): Int = ((buf[offset].toInt() and 0xFF) shl 24) or
((buf[offset + 1].toInt() and 0xFF) shl 16) or
((buf[offset + 2].toInt() and 0xFF) shl 8) or
(buf[offset + 3].toInt() and 0xFF)
private fun copyRawSample(dataLen: Int) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
}
private fun formatBytes(data: ByteArray, offset: Int, length: Int): String {
val end = minOf(data.size, offset + length)
if (offset >= end) return ""
return (offset until end).joinToString(" ") { "%02X".format(data[it]) }
}
private fun ensureSampleCapacity(needed: Int) {
if (sampleBuf.size < needed) {
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
}
}
private fun ensureOutputCapacity(needed: Int) {
if (outputBuf.size < needed) {
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
}
}
private fun logDebug(message: String) {
Log.d(TAG, message)
emitLog?.invoke("debug", "dv-convert", message)
}
private fun logInfo(message: String) {
Log.i(TAG, message)
emitLog?.invoke("info", "dv-convert", message)
}
private fun logWarn(message: String) {
Log.w(TAG, message)
emitLog?.invoke("warn", "dv-convert", message)
}
private fun logError(message: String, throwable: Throwable) {
Log.e(TAG, message, throwable)
emitLog?.invoke("error", "dv-convert", "$message: ${throwable.message}")
}
/**
* Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1.
*
* Binary layout (from Dolby Vision spec):
* byte[0]: dv_version_major = 1
* byte[1]: dv_version_minor = 0
* byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit)
* byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1)
* byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits)
* byte[5-23]: reserved (zeros)
*/
private fun buildDv81ConfigRecord(level: Int): ByteArray {
val record = ByteArray(24)
record[0] = 0x01 // dv_version_major = 1
record[1] = 0x00 // dv_version_minor = 0
record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB
record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1
record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10)
// bytes 5-23 remain 0 (reserved)
return record
}
}
@@ -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,15 @@ 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.MimeTypes
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 +21,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 TrueHD output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectTrueHd: ((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,
shouldBlockDirectTrueHd,
onAudioCapabilitiesChanged,
audioDiagnosticsLogger
)
}
}
/**
@@ -75,186 +98,327 @@ 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 shouldBlockDirectTrueHd: ((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 var loggedTrueHdDirectBlock = false
private var loggedFirstBuffer = false
override fun supportsFormat(format: Format): Boolean {
if (blocksDirectTrueHd(format)) return false
return super.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int {
if (blocksDirectTrueHd(format)) return AudioSink.SINK_FORMAT_UNSUPPORTED
return super.getFormatSupport(format)
}
private fun blocksDirectTrueHd(format: Format): Boolean {
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD || shouldBlockDirectTrueHd?.invoke(format) != true) return false
if (!loggedTrueHdDirectBlock) {
loggedTrueHdDirectBlock = true
log?.invoke(
"info",
"audio",
"Blocking direct TrueHD output; route does not report TrueHD bitstream support, 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
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 = delegate.write(buffer, size, presentationTimeUs)
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 play() = delegate.play()
override fun pause() = delegate.pause()
override fun flush() {
rawPositionUs.set(Long.MIN_VALUE)
loggedFirstWrite = false
writeCount = 0
writtenBytes = 0
delegate.flush()
}
@Throws(AudioOutput.WriteException::class)
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) =
delegate.write(buffer, size, presentationTimeUs)
override fun stop() = delegate.stop()
override fun flush() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.flush()
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (Build.VERSION.SDK_INT >= 25) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
}
}
override fun stop() = delegate.stop()
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.release()
}
override fun release() {
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)
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)
}
@@ -0,0 +1,130 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
/**
* TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0).
* Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample
* between sampleData() and sampleMetadata() calls.
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
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
}
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
}
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
}
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
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
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 appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
}
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)
}
}
@@ -0,0 +1,116 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorInput
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
/**
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
*
* Media3's MatroskaExtractor only supports ContentCompAlgo 3 (header stripping).
* This subclass intercepts the compression algorithm during track header parsing:
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
* - Skips ContentCompSettings for zlib tracks (not applicable)
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
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
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
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
File diff suppressed because it is too large Load Diff
@@ -10,290 +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>()
// 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() {
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 {
playerCore = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore?.initialize { success ->
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
playerCore?.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
} ?: result.success(false)
} 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 {
playerCore?.dispose {
playerCore = null
Log.d(TAG, "Disposed")
result.success(null)
} ?: run {
playerCore = 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
}
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
}
playerCore?.setPropertyAsync(name, value, result)
?: result.success(null)
for (r in pending) {
if (errorMessage != null) {
r.error("INIT_FAILED", errorMessage, null)
} else {
r.success(success)
}
}
}
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", 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?.getPropertyAsync(name, result)
?: result.success(null)
}
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?.observePropertyAsync(name, format, result)
?: 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
}
// Use async command to prevent ANR - command executes off UI thread
// and result is called back when complete
playerCore?.commandAsync(args.toTypedArray(), result)
?: 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)
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 handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
playerCore?.setProperty(name, value)
result.success(null)
}
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
playerCore?.setVideoFrameRate(fps, duration)
result.success(null)
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
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
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
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
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
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
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
}
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
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 contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
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()
}
// Open file descriptor off UI thread to prevent ANR on slow storage
playerCore?.runOnExecutor {
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@runOnExecutor
}
// PlayerDelegate
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) }
}
} ?: result.error("NO_PLAYER", "Player not initialized", null)
}
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
// 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,176 +6,361 @@ import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.util.Log
import android.view.Display
import android.view.Surface
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 SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
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
)
private data class RefreshRateCandidate(
val refreshRate: Float,
val match: RefreshRateMatch
)
@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, seamless fallback); or
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
// [extraDelayMs], with `switched=true`; or
// - via a watchdog with `switched=true` 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,
surface: Surface?,
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
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
log(
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
)
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
Log.d(TAG, "setVideoFrameRate: Surface not available")
onComplete(false)
return
}
setFrameRateS(fps, surface, videoDurationMs, extraDelayMs, onComplete)
}
// 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, extraDelayMs, onComplete)
else -> onComplete(false)
}
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
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
}
}
}
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
// 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 clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
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)
}
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
return null
}
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)
private fun bestRefreshRateCandidate(fps: Float, refreshRates: FloatArray): RefreshRateCandidate? = refreshRates.asSequence()
.mapNotNull { rate -> matchRefreshRate(rate, fps)?.let { RefreshRateCandidate(rate, it) } }
.minWithOrNull(
compareBy<RefreshRateCandidate> { it.match.priority }
.thenBy { it.match.error }
.thenBy { it.refreshRate }
)
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 currentDisplayMode(): Display.Mode? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display?.mode
} else {
@Suppress("DEPRECATION")
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay?.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.S)
private fun setFrameRateS(
fps: Float,
surface: Surface,
videoDurationMs: Long,
extraDelayMs: Long,
onComplete: (switched: Boolean) -> Unit
) {
log("setFrameRateS fps=$fps, duration=${videoDurationMs}ms, currentMode=${currentModeDescription()}")
// If the current display rate already satisfies the video fps, issue
// the hint for book-keeping but skip the listener — otherwise we'd
// wait for an onDisplayChanged event that never fires and end up
// burning the watchdog timeout for no reason.
val currentMatch = currentRateMatch(fps)
if (currentMatch != null) {
log("current display rate already matches ${fps}fps (${currentMatch.reason}), no switch needed")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
onComplete(false)
return
}
@RequiresApi(Build.VERSION_CODES.R)
private fun setFrameRateR(fps: Float, surface: Surface) {
Log.d(TAG, "setFrameRateR: Setting frame rate to $fps")
surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE)
registerDisplayListener()
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
log("short video (${videoDurationMs}ms), using seamless-only switching")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
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")
val alternativeRates = activity.display?.mode?.alternativeRefreshRates ?: floatArrayOf()
val seamlessMatch = bestRefreshRateCandidate(fps, alternativeRates)
log("alternative refresh rates=${alternativeRates.joinToString(prefix = "[", postfix = "]")}")
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
}
if (seamlessMatch != null) {
log(
"seamless switch available for ${fps}fps: " +
"${seamlessMatch.refreshRate}Hz (${seamlessMatch.match.reason}, error=${seamlessMatch.match.error})"
)
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
log("user preference allows non-seamless switch, requesting CHANGE_FRAME_RATE_ALWAYS")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
log("non-seamless switch not allowed (preference=$userPreference), using seamless-only")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
onComplete(false)
}
}
}
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
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
log("setFrameRateM fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
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
)
}
}
@Suppress("DEPRECATION")
val display = wm.defaultDisplay
if (display == null) {
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 == 0f) {
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()
}
}
val supportedModes = display.supportedModes
if (supportedModes == null) {
log("supported display modes unavailable")
onComplete(false)
return
}
val currentMode = display.mode
log("supported modes=${describeSupportedModes(supportedModes)}")
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
}
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
}
log(
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
)
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = modeToUse.modeId
activity.window?.attributes = attrs
}
registerDisplayListener(fps, extraDelayMs, onComplete)
}
}
@@ -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
}
}
@@ -3,158 +3,182 @@ package com.edde746.plezy.watchnext
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.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
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
}
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) }
result.success(provider.syncWatchNextPrograms(items))
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
result.success(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
}
result.success(provider.removeItem(contentId))
}
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 -197
View File
@@ -1,222 +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)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- in_app_review (2.0.0):
- os_media_controls (0.2.1):
- Flutter
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- mobile_scanner (6.0.2):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.51.1):
- sqlite3/common (= 3.51.1)
- sqlite3/common (3.51.1)
- sqlite3/dbstatvtab (3.51.1):
- sqlite3/common
- sqlite3/fts5 (3.51.1):
- sqlite3/common
- sqlite3/math (3.51.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.51.1):
- sqlite3/common
- sqlite3/rtree (3.51.1):
- sqlite3/common
- sqlite3/session (3.51.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.51.1)
- 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
- workmanager_apple (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/ios`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- 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`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- PromisesObjC
- 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/ios"
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
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"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
universal_gamepad: e10172778a8a399cce234494968f38724974919e
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
os_media_controls: 3b06fe2dc0977dfa94f9c1dd73df7a55022d202f
universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
+93 -89
View File
@@ -10,18 +10,20 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C12D873DE8572579684A82C /* Pods_Runner.framework */; };
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 */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
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 */; };
F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */; };
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 */
@@ -50,32 +52,34 @@
/* Begin PBXFileReference section */
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>"; };
1E1EC7646F3085BF9127AB2A /* 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>"; };
2C12D873DE8572579684A82C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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; };
3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; 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>"; };
5B81FA7048BFA976F49D555E /* 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>"; };
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>"; };
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; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; 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>"; };
83B9EB35C25EEF40090F6A88 /* 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>"; };
87ADF6219C65A13967DFF03C /* 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>"; };
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>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A8738BEC00467F05948734DD /* 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>"; };
D239CED757CED630C2B0A161 /* 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>"; };
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; };
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 = (
F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */,
748A259343D7FF8DFD2C3910 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -91,8 +95,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */,
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */,
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -107,11 +112,11 @@
path = RunnerTests;
sourceTree = "<group>";
};
67716449E04A72AA71A90156 /* Frameworks */ = {
3DBA2EB0C0219E797C60DE48 /* Frameworks */ = {
isa = PBXGroup;
children = (
2C12D873DE8572579684A82C /* Pods_Runner.framework */,
3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */,
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */,
75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -121,6 +126,7 @@
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 = (
A8738BEC00467F05948734DD /* Pods-Runner.debug.xcconfig */,
83B9EB35C25EEF40090F6A88 /* Pods-Runner.release.xcconfig */,
1E1EC7646F3085BF9127AB2A /* Pods-Runner.profile.xcconfig */,
5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */,
D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */,
87ADF6219C65A13967DFF03C /* 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 */,
@@ -160,8 +167,8 @@
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
875C8AEFBBA3759A1849D674 /* Pods */,
67716449E04A72AA71A90156 /* Frameworks */,
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */,
3DBA2EB0C0219E797C60DE48 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -196,7 +203,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */,
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
582EBCC1C90BEBEC365D972D /* Frameworks */,
@@ -212,18 +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 = (
01D58DD9E289D97FD8DBE66E /* [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 */,
2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */,
BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */,
898098BAEC57934A6822754B /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -264,6 +273,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -298,7 +308,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
01D58DD9E289D97FD8DBE66E /* [CP] Check Pods Manifest.lock */ = {
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
6838C6976E568CE05DBDE228 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -320,40 +346,7 @@
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;
};
2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */ = {
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -375,6 +368,23 @@
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 = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -390,23 +400,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -424,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 */,
@@ -542,7 +536,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */;
baseConfigurationReference = 28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -560,7 +554,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */;
baseConfigurationReference = 3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -576,7 +570,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */;
baseConfigurationReference = 792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -795,7 +789,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = b2179eec26c6ceda339e6c6b512e457341768761;
revision = 42dcc3123000361d0be54fbaec981043b77e1e05;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -804,9 +798,19 @@
6A8A46202EDB320D0057B88C /* MPVKit */ = {
isa = XCSwiftPackageProductDependency;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = "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" : "42dcc3123000361d0be54fbaec981043b77e1e05"
}
},
{
"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" : "42dcc3123000361d0be54fbaec981043b77e1e05"
}
},
{
"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
}
+28 -6
View File
@@ -33,12 +33,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 +70,7 @@
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
@@ -86,6 +86,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 -243
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,359 +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.autoresizingMask = [.flexibleWidth, .flexibleHeight]
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)
+588 -155
View File
@@ -1,196 +1,629 @@
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
var displayRange: DisplayDynamicRange =
sourceHasDolbyVision
? .dolbyVision
: 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 {
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
}
+479 -349
View File
@@ -5,389 +5,519 @@ 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) {
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
autoPipEnabled = ready
if ready {
let pip = ensurePipController()
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
} else {
pipController?.setAutoStart(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 {
guard let pip = self.ensurePipController() else {
result(nil)
return
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
/// 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.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?.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(
+328
View File
@@ -0,0 +1,328 @@
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;
/// Server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
final String baseUrl;
/// 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,
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,
});
@override
ConnectionKind get kind => ConnectionKind.jellyfin;
@override
String get displayName => '$userName · $serverName';
@override
String get displayLabel => serverName;
@override
String? get displaySubtitle => '$userName · ${_truncateUrl(baseUrl)}';
static String _truncateUrl(String url) {
if (url.length <= 40) return url;
return '${url.substring(0, 37)}…';
}
JellyfinConnection copyWith({
String? id,
String? baseUrl,
String? serverName,
String? serverMachineId,
String? userId,
String? userName,
String? accessToken,
String? deviceId,
bool? isAdministrator,
ConnectionStatus? status,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
return JellyfinConnection(
id: id ?? this.id,
baseUrl: baseUrl ?? this.baseUrl,
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,
'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,
}) {
return JellyfinConnection(
id: id,
baseUrl: json['baseUrl'] as String? ?? '',
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',
};
+448 -38
View File
@@ -1,6 +1,7 @@
import 'dart:io';
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 +12,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 => 14;
@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,37 +84,199 @@ 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),
);
}
},
);
}
// ============================================================
// 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(String 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))
..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)])
..limit(1))
.getSingleOrNull();
@@ -72,19 +286,32 @@ class AppDatabase extends _$AppDatabase {
///
/// 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))
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.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,9 +319,17 @@ class AppDatabase extends _$AppDatabase {
return result;
}
/// Insert or update a progress action (merges with existing)
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 or update a progress action (merges with existing).
Future<void> upsertProgressAction({
String? profileId,
required String serverId,
String? clientScopeId,
required String ratingKey,
required int viewOffset,
required int duration,
@@ -106,7 +341,13 @@ class AppDatabase extends _$AppDatabase {
// Check for existing progress entry
final existing =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress'))
..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
)
..limit(1))
.getSingleOrNull();
@@ -117,6 +358,8 @@ class AppDatabase extends _$AppDatabase {
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
),
);
@@ -125,9 +368,11 @@ class AppDatabase extends _$AppDatabase {
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: 'progress',
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
@@ -138,10 +383,12 @@ class AppDatabase extends _$AppDatabase {
}
}
/// Insert a manual watch action (watched or unwatched)
/// Removes conflicting actions for the same item
/// Insert a manual watch action (watched or unwatched).
/// Removes conflicting actions for the same item.
Future<void> insertWatchAction({
String? profileId,
required String serverId,
String? clientScopeId,
required String ratingKey,
required String actionType, // 'watched' or 'unwatched'
}) async {
@@ -149,12 +396,20 @@ class AppDatabase extends _$AppDatabase {
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 +436,12 @@ 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}) async {
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
if (profileId != null) {
query.where(offlineWatchProgress.profileId.equals(profileId));
}
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
return count ?? 0;
}
@@ -193,9 +450,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 String 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, 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,20 +564,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(file);
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
+120 -23
View File
@@ -2,34 +2,106 @@ import 'package:drift/drift.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,
String? clientScopeId,
required String ratingKey,
required String globalKey,
required String type,
String? parentRatingKey,
String? grandparentRatingKey,
required int status,
int mediaIndex = 0,
}) 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),
),
mode: InsertMode.insertOrReplace,
);
}
/// Add item to download queue
Future<void> addToQueue({
required String mediaGlobalKey,
int priority = 0,
@@ -51,7 +123,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 +139,12 @@ 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> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(
@@ -86,7 +155,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 +164,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 +179,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, {
String? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.parentRatingKey.equals(seasonKey) &
_optionalServerPredicate(t.serverId, 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, {
String? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.grandparentRatingKey.equals(showKey) &
_optionalServerPredicate(t.serverId, serverId) &
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
))
.get();
}
/// Get all downloaded items for a specific server
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String 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, String? 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;
+169 -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,175 @@ 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))();
}
/// 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;
}
+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}) {
-9
View File
@@ -1,30 +1,21 @@
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 focusBorderWidth = 2.5;
/// Default border radius (matches MonoTokens.radiusSm).
static const double defaultBorderRadius = 8.0;
/// 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,

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