Compare commits

...
412 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
1090 changed files with 188610 additions and 43488 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
+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:
+189 -41
View File
@@ -29,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
@@ -51,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: |
@@ -64,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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()
@@ -106,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
@@ -129,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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: |
@@ -164,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
@@ -187,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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:
@@ -326,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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
@@ -356,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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
@@ -482,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
@@ -501,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
@@ -519,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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
@@ -590,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
@@ -609,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
@@ -627,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 }} ${{ env.SENTRY_ENV_DART_DEFINE }} ${{ env.DONATIONS_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: |
@@ -706,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 }}
+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.34.1"
sha256 "9e230f1c7522e85c9b1ee039b92cb331e92625732f6b2059c5ea5cfe85f77869"
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
+102 -38
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,9 +23,15 @@ 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
| 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
@@ -32,48 +44,74 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
```
- **WinGet** (Windows):
```bash
winget install edde746.plezy
winget install edde746.Plezy
```
## Features
### 🔐 Authentication
- Sign in with Plex
- Automatic server discovery and smart connection selection
- Persistent sessions with auto-login
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
- Libraries, collections, and playlists
- Discover hub — Continue Watching, Next Up, trending, and recommendations
- Cross-server search
- Filtering, sorting, and alphabetical jump navigation
- Extras — trailers, deleted scenes, behind-the-scenes
### 📚 Media Browsing
- Browse libraries with rich metadata
- Advanced search across all media
- Collections and playlists
### 🎬 Playback
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision (not Linux)
- Full ASS/SSA subtitle support
- Audio and subtitle preferences synced with Plex profile
- HDR and Dolby Vision[^1]
- Full ASS/SSA subtitles with customizable styling
- Online subtitle search & download[^2]
- Audio & subtitle choices remembered per title
- Progress sync and resume
- Auto-play next episode
- Auto-play next episode with skip intro / skip credits
- Chapter navigation with thumbnail scrub previews
- Playback speed, audio sync offset, sleep timer
- Ambient lighting and GLSL shader presets[^3]
- Picture-in-Picture[^4]
- Refresh-rate matching[^5]
- External player launch (VLC, MX Player, etc.)
### 📺 Live TV & DVR
- EPG guide grid
- Channel tuning
- DVR recording rules and scheduled recordings
- Multi-server DVR support
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
- Live TV channel browsing with favorites
- DVR support with EPG guide, recording rules, and scheduled recordings[^2]
- Multi-server Live TV support where available
### 📥 Downloads
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
- Download media for offline viewing
- Background downloads with queue management
- Background queue with pause / resume
- Sync rules for automatic downloads
- Offline browsing with watch state sync-back on reconnect
### 👥 Watch Together
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
- Synchronized playback with friends
- Real-time play/pause and seek sync
- Real-time play / pause / seek sync
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
- Discord Rich Presence[^7]
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
- Plezy Remote — control desktop and TV from mobile
- Watch Next row[^6]
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
- Customizable keyboard shortcuts[^7]
- Metadata and artwork editing[^2]
- Settings import/export
- Localized in English plus 14 translations
[^1]: Not available on Linux.
[^2]: Plex only.
[^3]: Not available on iOS or tvOS.
[^4]: Android, iOS, and macOS.
[^5]: Windows, Android, and tvOS.
[^6]: Android TV only.
[^7]: Desktop only.
## Building from Source
### Prerequisites
- Flutter SDK 3.8.1+
- A Plex account with server access
- Flutter SDK 3.38.4+
- A Plex account or Jellyfin server with user credentials
### Setup
@@ -81,20 +119,46 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
git clone https://github.com/edde746/plezy.git
cd plezy
flutter pub get
dart run build_runner build
scripts/codegen.sh
flutter run
```
### Code Generation
After modifying model classes:
After modifying model classes or other generated sources:
```bash
dart run build_runner build --delete-conflicting-outputs
scripts/codegen.sh
```
After modifying translations:
```bash
dart run slang
```
### Local Checks
```bash
scripts/ci_checks.sh
```
To install the same pre-commit checks locally:
```bash
scripts/setup_hooks.sh
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
## License
Plezy is licensed under [GPL-3.0](LICENSE).
## Acknowledgments
- Built with [Flutter](https://flutter.dev)
- Designed for [Plex Media Server](https://www.plex.tv)
- Playback powered by [mpv](https://mpv.io) via [MPVKit](https://github.com/mpvkit/MPVKit) and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
+10
View File
@@ -7,6 +7,16 @@ analyzer:
plugins:
- dart_code_linter
linter:
rules:
unawaited_futures: true
cancel_subscriptions: true
close_sinks: true
use_super_parameters: true
prefer_final_locals: true
prefer_final_in_for_each: true
avoid_print: true
dart_code_linter:
extends:
- package:dart_code_linter/presets/recommended.yaml
+130 -131
View File
@@ -1,26 +1,26 @@
import java.util.Properties
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.0.6"
val mpvVersion = "v1.0.7"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val downloadLibmpv by tasks.registering {
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
doLast {
mpvDir.mkdirs()
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
stamp.writeText(mpvVersion)
}
val stamp = File(mpvDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
doLast {
mpvDir.mkdirs()
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
stamp.writeText(mpvVersion)
}
}
val assVersion = "fp-3"
@@ -28,165 +28,164 @@ val assDir = layout.buildDirectory.dir("libass").get().asFile
val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar")
val downloadLibass by tasks.registering {
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
}
stamp.writeText(assVersion)
val stamp = File(assDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
doLast {
assDir.mkdirs()
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
assAars.forEach { name ->
val dest = File(assDir, name)
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
}
stamp.writeText(assVersion)
}
}
val doviVersion = "2.3.1"
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
val doviAbis = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android",
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android"
)
val downloadLibdovi by tasks.registering {
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
doLast {
doviDir.mkdirs()
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
doviAbis.forEach { (abi, triple) ->
val archive = File(doviDir, "$triple.tar.gz")
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
val outDir = File(doviDir, "$abi/lib")
outDir.mkdirs()
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
archive.delete()
}
stamp.writeText(doviVersion)
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
doLast {
doviDir.mkdirs()
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
doviAbis.forEach { (abi, triple) ->
val archive = File(doviDir, "$triple.tar.gz")
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
val outDir = File(doviDir, "$abi/lib")
outDir.mkdirs()
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
archive.delete()
}
stamp.writeText(doviVersion)
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
defaultConfig {
applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
buildTypes {
release {
// Only use release signing if key.properties exists (not in CI/CD)
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
pickFirsts.add("lib/*/libc++_shared.so")
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Only use release signing if key.properties exists (not in CI/CD)
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
// If key.properties doesn't exist, it will use debug signing for CI builds
ndk {
debugSymbolLevel = "FULL"
}
}
}
packaging {
jniLibs {
// Resolve conflict between libass-android and libmpv native libraries
pickFirsts.add("lib/*/libc++_shared.so")
}
}
}
flutter {
source = "../.."
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
dependsOn(downloadLibdovi)
}
// Download libmpv and libass AARs before compilation
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
dependsOn(downloadLibmpv)
dependsOn(downloadLibass)
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation(files(File(mpvDir, mpvAar)))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
// Android TV Watch Next integration
implementation("androidx.tvprovider:tvprovider:1.0.0")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
// Media3 ExoPlayer for Android
implementation("androidx.media3:media3-exoplayer:1.9.2")
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// Cronet for HTTP/2 multiplexing + better connection management
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation("org.chromium.net:cronet-embedded:143.7445.0")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
// libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) }
}
+1
View File
@@ -10,6 +10,7 @@
<!-- 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"/>
+103 -83
View File
@@ -1,7 +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__)
@@ -12,115 +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;
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
if (len > 8192) {
LOGW("RPU payload too large (%d bytes), skipping", len);
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;
// Copy to native heap so libdovi never touches JVM heap memory.
// GetByteArrayElements on ART may return a direct heap pointer; any
// out-of-bounds access by libdovi would corrupt adjacent JVM objects.
auto *buf = new (std::nothrow) uint8_t[static_cast<size_t>(len)];
if (buf == nullptr) return nullptr;
// 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;
}
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte *>(buf));
if (env->ExceptionCheck()) {
delete[] buf;
return nullptr;
}
// 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;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(buf, static_cast<size_t>(len));
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) {
delete[] buf;
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(buf, static_cast<size_t>(len));
if (rpu == nullptr) {
delete[] buf;
return nullptr;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
delete[] buf;
return nullptr;
}
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
return CONVERT_FAILED;
}
}
delete[] buf;
// 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;
}
if (out->len > 16384) {
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
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,404 +1,608 @@
package com.edde746.plezy
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.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.Context
import android.content.res.Configuration
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.engine.FlutterShellArgs
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() {
companion object {
private const val TAG = "MainActivity"
var usingSkia = false
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
var usingSkia = false
}
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
private inline fun logTextInputDiag(message: () -> String) {
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
Log.i(TAG, "TextInputDiag ${message()}")
}
}
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
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() }
}
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
// Auto PiP state
private var autoPipReady = false
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
private fun isAndroidTvDevice(): Boolean {
return packageManager.hasSystemFeature("android.software.leanback")
// 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
}
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() }
// 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 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 (packageManager.hasSystemFeature("android.software.leanback")) return true
// Google Tensor SoC (Mali GPU) — Pixel 6+
// SOC_MODEL may return marketing name ("Tensor G2") or internal ID ("GS201")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val soc = Build.SOC_MODEL
if (soc.startsWith("Tensor", ignoreCase = true) ||
soc.startsWith("GS", ignoreCase = true)) return true
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)
}
// 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
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, APP_EXIT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"requestExit" -> {
result.success(true)
window.decorView.post {
finishAndRemoveTask()
}
}
else -> result.notImplemented()
}
}
override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below.
return TransparencyMode.transparent
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"requestForeground" -> result.success(requestForeground())
else -> result.notImplemented()
}
}
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
// 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")
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, w: Int, h: Int) {
original.onSurfaceTextureAvailable(surface, w, h)
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, w: Int, h: Int) {
if (w == lastWidth && h == lastHeight) return
lastWidth = w; lastHeight = h
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, w, h)
}
}
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)
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 configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
// 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")
// 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")
// 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 (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if (packageName != null) {
setPackage(packageName)
}
}
startActivity(intent)
result.success(true)
} catch (e: android.content.ActivityNotFoundException) {
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
} catch (e: Exception) {
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
}
}
// 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")
// 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 && !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 {
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()
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 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)
}
}
}
// Register Watch Next plugin and keep reference for deep link handling
watchNextPlugin = WatchNextPlugin()
flutterEngine.plugins.add(watchNextPlugin!!)
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()) {
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 params = buildPipParams(autoPipWidth, autoPipHeight)
enterPictureInPictureMode(params)
val params = buildPipParams(autoPipWidth, autoPipHeight, autoEnterEnabled = autoPipReady)
setPictureInPictureParams(params)
} catch (e: Exception) {
Log.w(TAG, "Failed to enter auto-PiP", e)
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 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()
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()
}
}
@@ -19,149 +19,151 @@ import androidx.media3.extractor.TrackOutput
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
private val delegate: Extractor,
private val delegate: Extractor
) : Extractor {
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
}
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
delegate.seek(position, timeUs)
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
override fun release() = delegate.release()
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long
) : SeekMap {
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
override fun isSeekable(): Boolean = true
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
override fun getDurationUs(): Long = durationUs
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long,
) : SeekMap {
override fun isSeekable(): Boolean = true
override fun getDurationUs(): Long = durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
}
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -14,22 +14,23 @@ import androidx.media3.extractor.TrackOutput
* Shared by DoviExtractorWrapper for both MP4 and MKV containers.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit,
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val emitLog: ((String, String, String) -> Unit)?,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode)
onVideoTrackWrapped(wrapper)
return wrapper
}
return original
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
onVideoTrackWrapped(wrapper)
return wrapper
}
return original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
@@ -38,23 +39,23 @@ class DoviExtractorOutputWrapper(
* DV Profile 7 → 8.1 conversion via inline NAL processing.
*/
class DoviExtractorWrapper(
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : Extractor {
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
delegate.read(input, seekPosition)
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun release() = delegate.release()
override fun release() = delegate.release()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,10 +4,13 @@ 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
@@ -16,7 +19,6 @@ import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider
import androidx.media3.exoplayer.audio.ForwardingAudioSink
import androidx.media3.exoplayer.Renderer
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs
@@ -24,38 +26,53 @@ import kotlin.math.abs
@OptIn(UnstableApi::class)
class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) {
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
val audioDelayUs = AtomicLong(0L)
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
val audioDelayUs = AtomicLong(0L)
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
/** Returns whether direct TrueHD output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectTrueHd: ((Format) -> Boolean)? = null
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
.build()
/** Called before Media3 reacts to an audio route/capability change. */
var onAudioCapabilitiesChanged: (() -> Unit)? = null
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.build()
var audioDiagnosticsLogger: ((String, String, String) -> 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)
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
val defaultSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
.build()
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
.build()
return PositionFixAudioSink(defaultSink, rawPositionUs, audioDelayUs)
}
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
)
}
}
/**
@@ -81,130 +98,171 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
*/
@OptIn(UnstableApi::class)
private class PositionFixAudioSink(
sink: AudioSink,
private val rawPositionUs: AtomicLong,
private val audioDelayUs: 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
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
// over recoveryDurationUs to prevent video frame drops.
private var recoveryDurationUs = 0L
// 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 handleBuffer(
buffer: ByteBuffer,
presentationTimeUs: Long,
encodedAccessUnitCount: Int
): Boolean {
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))
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()
val basePos = if (rawPos <= 0) {
delegatePos
} else {
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
if (expectedPos > delegatePos + 30_000) expectedPos else 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()
}
// 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() = 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
recoveryDurationUs = 0L
rawPositionUs.set(Long.MIN_VALUE)
super.flush()
}
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()
}
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)
}
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)
}
}
/**
@@ -215,12 +273,12 @@ private class PositionFixAudioSink(
*/
@OptIn(UnstableApi::class)
internal class SubtitleDelayRenderer(
private val delegate: Renderer,
private val delayUs: AtomicLong
private val delegate: Renderer,
private val delayUs: AtomicLong
) : Renderer by delegate {
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs)
}
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs)
}
}
// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink ---
@@ -232,117 +290,135 @@ internal class SubtitleDelayRenderer(
@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 {
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) =
delegate.getFormatSupport(config)
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) =
delegate.getOutputConfig(config)
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
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this)
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
return cached
}
cached?.forceRelease()
cachedOutput = null
fun returnToCache(output: RawPositionAudioOutput) {
val existing = cachedOutput
if (existing != null && existing !== output) {
existing.forceRelease()
}
cachedOutput = output
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
}
fun returnToCache(output: RawPositionAudioOutput) {
val existing = cachedOutput
if (existing != null && existing !== output) {
existing.forceRelease()
}
cachedOutput = output
}
override fun addListener(listener: AudioOutputProvider.Listener) =
delegate.addListener(listener)
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) =
delegate.removeListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
override fun setClock(clock: Clock) = delegate.setClock(clock)
override fun setClock(clock: Clock) = delegate.setClock(clock)
override fun release() {
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
delegate.release()
}
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 provider: RawPositionOutputProvider
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)
if (Build.VERSION.SDK_INT >= 25) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
}
}
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
delegate.release()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
override fun getSampleRate() = delegate.getSampleRate()
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) =
delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) =
delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) =
delegate.setPreferredDevice(preferredDevice)
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
override fun getSampleRate() = delegate.getSampleRate()
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = delegate.setPreferredDevice(preferredDevice)
}
@@ -16,108 +16,115 @@ import java.util.zip.Inflater
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
private val delegate: TrackOutput,
private val delegate: TrackOutput
) : TrackOutput {
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
companion object {
private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
}
var active = false
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
var active = false
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
}
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
}
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
}
}
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
}
}
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
}
}
private fun ensureInflateCapacity(needed: Int) {
if (inflateBuf.size < needed) {
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
}
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
}
private fun growInflateBuf() {
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
}
}
@@ -6,9 +6,9 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
/**
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
@@ -20,97 +20,97 @@ import androidx.media3.extractor.text.SubtitleParser
* - Skips ContentCompSettings for zlib tracks (not applicable)
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler,
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
companion object {
private const val TAG = "ZlibMkvExtractor"
companion object {
private const val TAG = "ZlibMkvExtractor"
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
}
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
zlibOutput?.activateLast()
currentTrackUsesZlib = false
Log.i(TAG, "Activated zlib inflation for track")
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput,
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
File diff suppressed because it is too large Load Diff
@@ -10,307 +10,376 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
class MpvPlayerPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler,
ActivityAware,
com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead
// of each tearing down the in-flight core and starting their own — which
// was the root cause of #930.
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
@Volatile private var isInitializing = false
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"updateFrame" -> handleUpdateFrame(result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"requestAudioFocus" -> handleRequestAudioFocus(result)
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> result.success(null)
else -> result.notImplemented()
}
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
// Coalesce concurrent inits: the second caller waits for the first
// call's outcome instead of disposing the in-flight core. The Dart
// side memoizes too, but this is defense in depth for any direct
// `invoke('initialize')` that bypasses _ensureInitialized.
synchronized(pendingInitResults) {
pendingInitResults += result
if (isInitializing) {
Log.d(TAG, "Init already in flight, queuing caller")
return
}
isInitializing = true
}
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(result)
"dispose" -> handleDispose(result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"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
currentActivity.runOnUiThread {
val gen: Int
val core: MpvPlayerCore
try {
// Caller invariant: dispose() was already called explicitly,
// OR `playerCore?.isInitialized == true` and we early-exited
// above. We never tear down a core that's mid-initialization.
if (playerCore != null && playerCore?.isInitialized != true) {
Log.w(TAG, "Discarding stale uninitialized core before re-init")
playerCore?.dispose()
playerCore = null
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
return
gen = ++sessionGeneration
core = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
completePendingInits(success = false, errorMessage = e.message)
return@runOnUiThread
}
currentActivity.runOnUiThread {
try {
// Dispose stale core idempotently
playerCore?.dispose()
playerCore = null
val gen = ++sessionGeneration
val core = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
core.initialize { success ->
if (gen != sessionGeneration || playerCore !== core) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
result.success(false)
return@initialize
}
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
result.error("INIT_FAILED", e.message, null)
}
core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core
if (stale) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
}
completePendingInits(success = !stale && success)
}
}
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
core?.dispose {
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
val pending = synchronized(pendingInitResults) {
isInitializing = false
val copy = pendingInitResults.toList()
pendingInitResults.clear()
copy
}
for (r in pending) {
if (errorMessage != null) {
r.error("INIT_FAILED", errorMessage, null)
} else {
r.success(success)
}
}
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
val core = playerCore
++sessionGeneration
playerCore = null
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
// Any in-flight init callback is cancelled with the scope, so
// close out queued callers here instead of leaking them.
completePendingInits(success = false)
playerCore?.setProperty(name, value)
core?.dispose {
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
playerCore?.setProperty(name, value)
result.success(null)
}
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", null)
return
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
if (name == null) {
result.error("INVALID_ARGS", "Missing 'name'", null)
return
}
val value = playerCore?.getProperty(name)
result.success(value)
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val format = call.argument<String>("format")
val id = call.argument<Int>("id")
if (name == null || format == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
return
}
nameToId[name] = id
playerCore?.observeProperty(name, format)
result.success(null)
}
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
val args = call.argument<List<String>>("args")
if (args == null) {
result.error("INVALID_ARGS", "Missing 'args'", null)
return
}
val core = playerCore
if (core == null) {
result.success(null)
return
}
core.command(args.toTypedArray()) {
result.success(null)
}
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
playerCore?.setVisible(visible)
result.success(null)
}
private fun handleUpdateFrame(result: MethodChannel.Result) {
playerCore?.updateFrame()
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs")
val core = playerCore
if (core == null) {
result.success(false)
return
}
core.setVideoFrameRate(fps, duration, extraDelayMs) { switched ->
result.success(switched)
}
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
// Open file descriptor off UI thread to prevent ANR on slow storage
Thread {
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val value = playerCore?.getProperty(name)
result.success(value)
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val format = call.argument<String>("format")
val id = call.argument<Int>("id")
// PlayerDelegate
if (name == null || format == null || id == null) {
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
return
}
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
nameToId[name] = id
playerCore?.observeProperty(name, format)
result.success(null)
}
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
val args = call.argument<List<String>>("args")
if (args == null) {
result.error("INVALID_ARGS", "Missing 'args'", null)
return
}
playerCore?.command(args.toTypedArray())
result.success(null)
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
val visible = call.argument<Boolean>("visible")
if (visible == null) {
result.error("INVALID_ARGS", "Missing 'visible'", null)
return
}
playerCore?.setVisible(visible)
result.success(null)
}
private fun 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
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
playerCore?.setVideoFrameRate(fps, duration)
result.success(null)
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val contentResolver = activity?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
// Open file descriptor off UI thread to prevent ANR on slow storage
Thread {
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
// PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
}
@@ -9,97 +9,97 @@ import android.os.Handler
import android.util.Log
class AudioFocusManager(
context: Context,
private val handler: Handler,
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
context: Context,
private val handler: Handler,
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
) {
companion object {
private const val TAG = "AudioFocusManager"
}
companion object {
private const val TAG = "AudioFocusManager"
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
var wasPlayingBeforeFocusLoss: Boolean = false
private set
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus: Boolean = false
var wasPlayingBeforeFocusLoss: Boolean = false
private set
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
log("Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss) {
onResume()
wasPlayingBeforeFocusLoss = false
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
log("Focus lost permanently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
log("Focus lost transiently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
log("Focus lost transiently (can duck)")
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
log("Focus gained")
hasAudioFocus = true
if (wasPlayingBeforeFocusLoss) {
onResume()
wasPlayingBeforeFocusLoss = false
}
}
fun requestAudioFocus(): Boolean {
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
.build()
audioFocusRequest = focusRequest
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
return hasAudioFocus
}
fun abandonAudioFocus() {
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
log("Focus lost permanently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
log("Focus lost transiently")
hasAudioFocus = false
wasPlayingBeforeFocusLoss = !isPaused()
onPause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
log("Focus lost transiently (can duck)")
}
}
}
fun requestAudioFocus(): Boolean {
Log.d(TAG, "Requesting audio focus")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
.build()
audioFocusRequest = focusRequest
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
fun release() {
abandonAudioFocus()
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
return hasAudioFocus
}
fun abandonAudioFocus() {
Log.d(TAG, "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
wasPlayingBeforeFocusLoss = false
}
fun release() {
abandonAudioFocus()
}
}
@@ -0,0 +1,23 @@
package com.edde746.plezy.shared
import android.os.Build
object DeviceQuirks {
val isEWaste: Boolean
get() = isGooglePixelDevice || isGoogleTensorDevice
private val isGooglePixelDevice: Boolean
get() = (
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
Build.BRAND.equals("google", ignoreCase = true)
) &&
Build.MODEL.contains("Pixel", ignoreCase = true)
private val isGoogleTensorDevice: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
val soc = Build.SOC_MODEL
return soc.startsWith("Tensor", ignoreCase = true) ||
soc.startsWith("GS", ignoreCase = true)
}
}
@@ -1,6 +1,7 @@
package com.edde746.plezy.shared
import android.graphics.PixelFormat
import android.os.Build
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
@@ -8,52 +9,84 @@ import android.view.ViewGroup
object FlutterOverlayHelper {
/**
* Find the FlutterView container in the content view hierarchy.
* First tries to match by class name (works in debug builds),
* then falls back to picking the last ViewGroup with children
* (needed for release builds where FlutterView may be obfuscated).
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
return child
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our video container and has children.
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
return child
}
}
return null
/**
* Find the top-level container holding the Flutter render surface. Returns the
* direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView
* at any depth — `bringChildToFront` only works for direct children, so this is the
* node to pass to [configureFlutterZOrder].
*
* Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch)
* and push it below the direct-child level.
*/
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i)
if (child === excludeView || child !is ViewGroup) continue
if (findRenderSurface(child) != null) return child
}
return null
}
/**
* Configure z-ordering so the Flutter UI renders above the video surface.
*
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
* false for ExoPlayer (subtitle layer sits between video and Flutter)
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) {
contentView.bringChildToFront(container)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(zOrderOnTop)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
}
}
private fun findRenderSurface(root: ViewGroup): View? {
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is SurfaceView || child is TextureView) return child
if (child is ViewGroup) findRenderSurface(child)?.let { return it }
}
return null
}
/**
* Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
* the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
* bucket settings govern Z-order instead.
*
* The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics:
* `mSubLayer >= 0` (non-negative `order`) is composited above the parent window
* and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is
* behind the parent window and the SurfaceView is made visible by punching a
* transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the
* right sign matters: any view that draws on the parent canvas (e.g. Media3's
* `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with
* non-negative orders that sit on top of the same area.
*/
fun applyCompositionOrder(view: SurfaceView, order: Int) {
if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order
}
/**
* Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces.
*
* On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps
* directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics).
* On pre-36 SurfaceView builds the value is mapped to the legacy on-top bucket
* when positive. On TextureView builds the value is unused (view hierarchy order
* handles it).
*
* Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas
* and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over.
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) {
contentView.bringChildToFront(container)
when (val surface = findRenderSurface(container)) {
is SurfaceView -> {
if (Build.VERSION.SDK_INT >= 36) {
// Clear legacy bucket hints so compositionOrder is authoritative.
// Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its
// transparent-mode constructor, which otherwise pins it to z=1.
surface.setZOrderOnTop(false)
surface.setZOrderMediaOverlay(false)
surface.compositionOrder = compositionOrder
} else {
// Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top
// bucket so it renders above the video (default) and libass subtitle
// (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites
// mSubLayer internally, so don't call it here or it cancels setZOrderOnTop.
surface.setZOrderOnTop(compositionOrder > 0)
}
surface.holder.setFormat(PixelFormat.TRANSLUCENT)
}
is TextureView -> surface.isOpaque = false
}
}
}
@@ -6,179 +6,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
log(
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
)
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (surface == null) {
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)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
// Resolve any pending setVideoFrameRate future as "not switched" so
// the Dart caller's await doesn't hang on player dispose.
firePendingCompletion("clear", switched = false)
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
}
// Release pending callbacks/listener without restoring the display mode.
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
// window-scoped preferredDisplayModeId persists across the SurfaceView swap,
// letting MPV inherit the rate without a second HDMI renegotiation.
fun releasePending() {
Log.d(TAG, "releasePending")
currentVideoFps = 0f
firePendingCompletion("release", switched = false)
}
private fun cancelPendingCallbacks() {
pendingSettleRunnable?.let { handler.removeCallbacks(it) }
watchdogRunnable?.let { handler.removeCallbacks(it) }
pendingSettleRunnable = null
watchdogRunnable = null
}
private fun firePendingCompletion(reason: String, switched: Boolean) {
cancelPendingCallbacks()
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
val cb = pendingCompletion ?: return
pendingCompletion = null
log("complete reason=$reason, switched=$switched, currentMode=${currentModeDescription()}")
cb(switched)
}
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
// Resolve any previous pending op before starting a new one.
firePendingCompletion("superseded", switched = false)
pendingCompletion = onComplete
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
// Unregister immediately so a chatty display (e.g. several
// onDisplayChanged events during HDMI renegotiation) doesn't
// queue multiple settle callbacks.
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
pendingSettleRunnable = settle
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
}
}
getDisplayManager().registerDisplayListener(displayListener, handler)
// Watchdog: if the TV never signals a display change (silently ignoring
// the mode request), still complete after a bounded wait so the caller
// doesn't hang.
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
watchdogRunnable = watchdog
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
}
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
if (refreshRate <= 0f || fps <= 0f) return null
val exactError = abs(refreshRate - fps)
if (exactError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
}
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
setFrameRateS(fps, surface, videoDurationMs)
}
// API R's Surface.setFrameRate() only supports seamless switching (no
// CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to
// preferredDisplayModeId which directly sets the display mode.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
}
val multiple = (refreshRate / fps).roundToInt()
if (multiple > 1) {
val multipleError = abs(refreshRate - (fps * multiple))
if (multipleError < RATE_TOLERANCE) {
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
return null
}
private fun 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
}
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
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)
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).let { it < 0.1f || (fps - it) < 0.1f }) {
modeToUse = mode
break
}
}
if (modeToUse != currentMode) {
Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)")
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = modeToUse.modeId
activity.window?.attributes = attrs
}
registerDisplayListener()
}
}
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
}
}
@@ -16,167 +16,169 @@ import java.util.concurrent.Executors
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class WatchNextPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private var pendingDeepLink: String? = null
/**
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
}
return null
}
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
}
}
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
executeOnIo(result) { provider.clearAll() }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
val contentId = pendingDeepLink
pendingDeepLink = null
result.success(contentId)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
seriesTitle = data["seriesTitle"] as? String,
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
)
}
private var pendingDeepLink: String? = null
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
}
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
}
return null
}
}
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
}
}
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
executeOnIo(result) { provider.clearAll() }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
val contentId = pendingDeepLink
pendingDeepLink = null
result.success(contentId)
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
seriesTitle = data["seriesTitle"] as? String,
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
)
}
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
}
}
}
@@ -14,154 +14,151 @@ import androidx.tvprovider.media.tv.WatchNextProgram
*/
class WatchNextProvider(private val context: Context) {
companion object {
private const val TAG = "WatchNextProvider"
}
companion object {
private const val TAG = "WatchNextProvider"
}
data class WatchNextItem(
val contentId: String,
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
val lastEngagementTime: Long,
val seriesTitle: String?,
val seasonNumber: Int?,
val episodeNumber: Int?
data class WatchNextItem(
val contentId: String,
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
val lastEngagementTime: Long,
val seriesTitle: String?,
val seasonNumber: Int?,
val episodeNumber: Int?
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean {
return try {
val ops = ArrayList<ContentProviderOperation>()
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
}
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
}
fun clearAll(): Boolean = try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
}
fun removeItem(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
),
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
context.contentResolver.delete(deleteUri, null, null)
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
fun clearAll(): Boolean {
return try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
}
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
}
}
fun removeItem(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
),
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
context.contentResolver.delete(deleteUri, null, null)
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
false
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0)
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
else
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
}
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
return builder.build()
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
return builder.build()
}
}
+12 -6
View File
@@ -1,5 +1,10 @@
require 'dotenv/load'
Dotenv.load("../../.env")
require 'shellwords'
PROJECT_ROOT = File.expand_path("../..", __dir__)
PROJECT_ROOT_ARG = Shellwords.escape(PROJECT_ROOT)
Dotenv.load(File.join(PROJECT_ROOT, ".env"))
default_platform(:android)
@@ -7,7 +12,7 @@ platform :android do
desc "Build and deploy to Google Play Store"
lane :release do
# Get version from pubspec.yaml
pubspec_path = "#{ENV['PWD']}/../pubspec.yaml"
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
@@ -19,11 +24,11 @@ platform :android do
UI.user_error!("Could not extract version from pubspec.yaml")
end
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
git_commit = `git rev-parse --short HEAD`.strip
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --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} --dart-define=SENTRY_ENVIRONMENT=amazon --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

+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
+3 -110
View File
@@ -1,141 +1,34 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- file_picker (0.0.1):
- Flutter
- Flutter (1.0.0)
- in_app_review (2.0.0):
- os_media_controls (0.2.1):
- Flutter
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- Sentry/HybridSDK (8.58.0)
- sentry_flutter (9.16.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.58.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.52.0):
- sqlite3/common (= 3.52.0)
- sqlite3/common (3.52.0)
- sqlite3/dbstatvtab (3.52.0):
- sqlite3/common
- sqlite3/fts5 (3.52.0):
- sqlite3/common
- sqlite3/math (3.52.0):
- sqlite3/common
- sqlite3/perf-threadsafe (3.52.0):
- sqlite3/common
- sqlite3/rtree (3.52.0):
- sqlite3/common
- sqlite3/session (3.52.0):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.52.0)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- universal_gamepad (0.1.0):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- Sentry
- sqlite3
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
universal_gamepad:
:path: ".symlinks/plugins/universal_gamepad/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
universal_gamepad: e10172778a8a399cce234494968f38724974919e
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
os_media_controls: 3b06fe2dc0977dfa94f9c1dd73df7a55022d202f
universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
+23 -1
View File
@@ -22,6 +22,8 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -73,9 +75,11 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
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 */
@@ -91,6 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */,
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */,
);
@@ -121,6 +126,7 @@
children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */,
@@ -144,6 +150,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -212,6 +219,9 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -263,6 +273,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -406,6 +417,7 @@
files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
@@ -777,7 +789,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = b2179eec26c6ceda339e6c6b512e457341768761;
revision = 42dcc3123000361d0be54fbaec981043b77e1e05;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -788,7 +800,17 @@
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = MPVKit;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,14 +1,84 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "csqlite",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simolus3/CSQLite.git",
"state" : {
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
"revision" : "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 -242
View File
@@ -1,8 +1,44 @@
import AVFoundation
import AVKit
import UIKit
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
#if os(tvOS)
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
// and is not supported by the Plezy flow. Provide a no-op shell so callers
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
// never attempted at runtime.
protocol MpvPipDelegate: AnyObject {
func pipWillStart()
func pipDidStart()
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
func pipSetPlaying(_ playing: Bool)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
var isPipPlaying: Bool { get }
var pipDuration: Double { get }
}
class MpvPipController: NSObject {
static var isSupported: Bool { false }
weak var delegate: MpvPipDelegate?
var isPipActive: Bool { false }
var autoStartEnabled: Bool { false }
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {}
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
completion(false)
}
func stopPip() {}
func invalidatePlaybackState() {}
func syncTimebase(currentTime: Double, isPlaying: Bool) {}
func teardown() {}
}
#else
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
/// Called when PiP is about to start (system or app-initiated)
func pipWillStart()
func pipDidStart()
@@ -12,358 +48,258 @@ protocol MpvPipDelegate: AnyObject {
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
/// Get total duration in seconds
var pipDuration: Double { get }
}
}
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
weak var delegate: MpvPipDelegate?
/// Pointer to the sample buffer layer for passing to mpv as `wid`
var layerPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
}
// MARK: - Initialization
override init() {
super.init()
setup()
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
self.sampleBufferLayer = sampleBufferDisplayLayer
super.init()
setup()
}
private func setup() {
guard #available(iOS 15.0, *) else { return }
guard #available(iOS 15.0, *) else { return }
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
let view = UIView(frame: window.bounds)
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
createPipController()
createPipController()
}
/// Helper that conforms to the iOS 15+ delegate protocols
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *) else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = helper
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = helper
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
func setAutoStart(_ enabled: Bool) {
guard #available(iOS 14.2, *) else { return }
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
guard #available(iOS 14.2, *) else { return }
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
}
/// Push a black frame to the sample buffer layer so PiP has content
/// to display immediately (before vo_pip decodes the first real frame).
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault, Int(width), Int(height),
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
)
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
// Fill with black
CVPixelBufferLockBaseAddress(pb, [])
if let base = CVPixelBufferGetBaseAddress(pb) {
memset(base, 0, CVPixelBufferGetDataSize(pb))
}
CVPixelBufferUnlockBaseAddress(pb, [])
// Use current timebase time for PTS (if available) so the frame isn't stale
let pts: CMTime
if let tb = sampleBufferLayer.controlTimebase {
pts = CMTimebaseGetTime(tb)
} else {
pts = CMTime(value: 0, timescale: 30)
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
// Create format description and sample buffer
var formatDesc: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescriptionOut: &formatDesc
)
guard let fmt = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescription: fmt,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard let sb = sampleBuffer else { return }
// Set DisplayImmediately so it shows regardless of timebase timing
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true) as? [NSMutableDictionary],
let dict = attachments.first {
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
}
sampleBufferLayer.enqueue(sb)
}
/// Sync the layer's controlTimebase with the actual playback position.
/// This makes the PiP progress bar show the correct time.
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
func syncTimebase(currentTime: Double, isPlaying: Bool) {
guard let timebase = sampleBufferLayer.controlTimebase else { return }
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
CMTimebaseSetTime(timebase, time: cmTime)
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
}
/// Ensure the layer has a timebase and blank frame so the system considers
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
/// Ensure the layer has MPVKit's renderer-owned timebase before PiP starts.
func warmLayer(currentTime: Double, isPlaying: Bool) {
if sampleBufferLayer.controlTimebase == nil {
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
allocator: kCFAllocatorDefault,
sourceClock: CMClockGetHostTimeClock(),
timebaseOut: &timebase
)
if let tb = timebase {
sampleBufferLayer.controlTimebase = tb
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
if sampleBufferLayer?.controlTimebase == nil {
print("[MpvPipController] Waiting for MPVKit renderer timebase before PiP")
}
}
// MARK: - Public API
static var isSupported: Bool {
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
}
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
/// readiness check since the scene is about to deactivate.
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
guard let pipController = pipController else {
completion(false)
return
guard let pipController = pipController else {
completion(false)
return
}
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
} else {
hasFrame = true
}
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = sampleBufferLayer.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = sampleBufferLayer.isReadyForDisplay
} else {
hasFrame = true
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
attempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
} else {
print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))")
completion(false)
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
attempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
} else {
print(
"[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))"
)
completion(false)
}
tryStart()
}
tryStart()
}
func stopPip() {
pipController?.stopPictureInPicture()
pipController?.stopPictureInPicture()
}
/// Invalidate the playback state so PiP updates its UI (play/pause button)
func invalidatePlaybackState() {
pipController?.invalidatePlaybackState()
guard #available(iOS 15.0, *) else { return }
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP removes the container view from the window and
/// destroys the AVPictureInPictureController so the system can no longer
/// trigger auto-PiP after the player is disposed.
/// Fully tear down PiP without touching the shared inline display layer.
func teardown() {
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = nil
delegateHelper = nil
sampleBufferLayer.flushAndRemoveImage()
sampleBufferLayer.controlTimebase = nil
sampleBufferLayer.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = nil
delegateHelper = nil
}
/// Flush enqueued sample buffers from the layer to free video frame memory
func flushLayer() {
sampleBufferLayer.flushAndRemoveImage()
}
}
}
// MARK: - PiP Delegate Helper (iOS 15+)
// MARK: - PiP Delegate Helper (iOS 15+)
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
AVPictureInPictureSampleBufferPlaybackDelegate
{
{
weak var controller: MpvPipController?
private var isRestoring = false
init(controller: MpvPipController) {
self.controller = controller
super.init()
self.controller = controller
super.init()
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
print("[MpvPipController] PiP will start")
controller?.delegate?.pipWillStart()
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
print("[MpvPipController] PiP did start")
controller?.delegate?.pipDidStart()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
let restored = isRestoring
isRestoring = false
print("[MpvPipController] PiP did stop (restored: \(restored))")
controller?.delegate?.pipDidStop(restored: restored)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
print("[MpvPipController] PiP failed to start: \(error)")
controller?.delegate?.pipDidFailToStart(error: error)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:
@escaping (Bool) -> Void
) {
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
print("[MpvPipController] PiP restore user interface")
isRestoring = true
completionHandler(true)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will stop")
print("[MpvPipController] PiP will stop")
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
print("[MpvPipController] PiP setPlaying: \(playing)")
controller?.delegate?.pipSetPlaying(playing)
}
func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
let duration = controller?.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
let duration = controller?.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
return !(controller?.delegate?.isPipPlaying ?? false)
return !(controller?.delegate?.isPipPlaying ?? false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
) {
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
controller?.delegate?.pipSkip(byInterval: seconds)
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
guard let delegate = controller?.delegate else {
completionHandler()
return
}
delegate.pipSkip(byInterval: seconds, completion: completionHandler)
}
}
}
#endif // !os(tvOS)
+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 -352
View File
@@ -5,392 +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) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
self.enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
let pip = self.ensurePipController()
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
} else {
self.pipController?.setAutoStart(false)
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
switch call.method {
case "isSupported":
result(MpvPipController.isSupported)
case "enter":
self.enterPip(manual: true, result: result)
case "setAutoPipReady":
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
guard let pip = self.ensurePipController() else {
result(nil)
return
}
}
}
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func switchToPipAndPrepare() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
let pip = ensurePipController()
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.isPipStarting = true
pip.pushBlankFrame()
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
}
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard MpvPipController.isSupported else {
result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"])
return
}
guard playerCore != nil else {
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
return
}
guard let pip = switchToPipAndPrepare() else {
result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"])
return
}
isManualPipRequest = manual
pip.startPip(waitForFrame: manual) { [weak self] started in
if started {
result?(["success": true])
} else {
self?.cleanupPip(notify: false)
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"])
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
} else {
self.pipController?.setAutoStart(false)
}
}
}
/// Unified cleanup for all PiP exit paths
private func cleanupPip(notify: Bool, pause: Bool = false) {
playerCore?.isPipStarting = false
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
pipController?.flushLayer()
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
if pause { playerCore?.setProperty("pause", value: "yes") }
pendingInlineRestoreAfterPip = restoredInlineVO
if pendingInlineRestoreAfterPip {
if isSceneActive {
restoreInlinePlayerAfterPip()
} else {
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
}
}
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
}
/// Scene became active restore inline playback if needed and re-warm the
/// sample-buffer layer so future auto-PiP remains possible.
@objc private func sceneDidActivate() {
restoreInlinePlayerAfterPip()
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
}
}
// MARK: - Timebase Sync
private func syncPipTimebase() {
guard let playerCore = playerCore, let pipController = pipController else { return }
pipController.syncTimebase(
currentTime: playerCore.timePos,
isPlaying: !playerCore.isPaused
)
}
private func startPipTimebaseSync() {
stopPipTimebaseSync()
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.syncPipTimebase()
}
}
private func stopPipTimebaseSync() {
pipTimebaseSyncTimer?.invalidate()
pipTimebaseSyncTimer = nil
}
// MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
if self.playerCore?.isInitialized == true {
self.registerSceneActivationObserver()
result(true)
return
}
guard let window = self.findKeyWindow() else {
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
return
}
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
self.registerSceneActivationObserver()
core.setVisible(false)
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { result(nil); return }
self.pipController?.teardown()
self.pipController = nil
self.autoPipEnabled = false
self.pendingInlineRestoreAfterPip = false
self.unregisterSceneActivationObserver()
self.stopPipTimebaseSync()
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
return
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
pipController?.invalidatePlaybackState()
if playerCore?.isPipActive == true { syncPipTimebase() }
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
/// Prepare the shared AVFoundation display layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func preparePip() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
guard let pip = ensurePipController() else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.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} --dart-define=SENTRY_ENVIRONMENT=app-store")
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;
}
}
}
+373 -60
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, SyncRules])
/// 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 => 12;
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,19 +84,17 @@ 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)');
try {
await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex);
} catch (e) {
appLogger.w('mediaIndex column may already exist: $e');
}
await _ignoreAlreadyExists(
'DownloadedMedia.mediaIndex column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex),
);
}
if (from < 10) {
appLogger.i('Adding SyncRules table (v10 migration)');
@@ -52,45 +102,181 @@ class AppDatabase extends _$AppDatabase {
}
if (from < 11) {
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
try {
await m.addColumn(syncRules, syncRules.enabled);
} catch (e) {
appLogger.w('enabled column may already exist: $e');
}
await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled));
}
if (from < 12) {
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
try {
await m.addColumn(syncRules, syncRules.downloadFilter);
} catch (e) {
appLogger.w('downloadFilter column may already exist: $e');
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();
@@ -100,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);
}
@@ -120,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,
@@ -134,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();
@@ -145,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),
),
);
@@ -153,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),
@@ -166,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 {
@@ -177,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,
@@ -209,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;
}
@@ -221,12 +450,12 @@ class AppDatabase extends _$AppDatabase {
return delete(offlineWatchProgress).go();
}
// ============================================================
// Sync Rules Operations
// ============================================================
Future<List<SyncRuleItem>> getSyncRules() {
return select(syncRules).get();
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) {
@@ -234,6 +463,7 @@ class AppDatabase extends _$AppDatabase {
}
Future<void> insertSyncRule({
String profileId = '',
required String serverId,
required String ratingKey,
required String globalKey,
@@ -242,9 +472,15 @@ class AppDatabase extends _$AppDatabase {
int mediaIndex = 0,
String downloadFilter = 'unwatched',
}) async {
await into(syncRules).insertOnConflictUpdate(
// [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,
@@ -253,9 +489,39 @@ class AppDatabase extends _$AppDatabase {
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,
@@ -284,10 +550,6 @@ class AppDatabase extends _$AppDatabase {
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
}
// ============================================================
// Downloaded Media Queries for Watch State Sync
// ============================================================
/// Get all downloaded media items (for syncing watch states)
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
@@ -302,18 +564,13 @@ LazyDatabase _openConnection() {
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
// Ensure directory exists
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
// Migrate from old location on desktop (was in Documents subfolder)
if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
final oldFolder = await getApplicationDocumentsDirectory();
final oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
if (await oldFile.exists()) {
await oldFile.rename(file.path);
}
await migrateLegacyDesktopDatabase(target: file);
}
return NativeDatabase.createInBackground(
@@ -321,7 +578,63 @@ LazyDatabase _openConnection() {
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
+118 -23
View File
@@ -2,12 +2,82 @@ 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,
@@ -19,6 +89,7 @@ extension DownloadDatabaseOperations on AppDatabase {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
type: type,
@@ -31,7 +102,6 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
/// Add item to download queue
Future<void> addToQueue({
required String mediaGlobalKey,
int priority = 0,
@@ -53,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))]);
@@ -70,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(
@@ -88,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(
@@ -98,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;
@@ -116,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;
+149 -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()();
@@ -51,13 +61,35 @@ class DownloadedMedia extends Table {
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()();
@@ -70,18 +102,131 @@ class SyncRules extends Table {
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,
+16 -40
View File
@@ -1,26 +1,18 @@
import 'package:flutter/material.dart';
import '../widgets/app_icon.dart';
import '../widgets/clickable_cursor.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
/// Describes a single action button for use in [FocusableActionBar].
class FocusableAction {
/// Icon to display. Ignored when [child] is provided.
final IconData icon;
/// Icon color. Ignored when [child] is provided.
final Color? iconColor;
/// Icon fill weight (0.01.0). Defaults to 1.0. Ignored when [child] is provided.
final double iconFill;
final String? tooltip;
final VoidCallback? onPressed;
/// Optional custom child widget placed inside the focus container.
/// Overrides the default [IconButton] built from [icon]/[tooltip]/[onPressed].
final Widget? child;
const FocusableAction({
@@ -33,25 +25,6 @@ class FocusableAction {
});
}
/// A row of focusable action buttons for app bar [actions:].
///
/// Manages focus nodes, left/right D-pad navigation between buttons,
/// and the standard white-alpha background focus indicator internally.
///
/// Returns a single [Row] widget — place it inside the `actions:` list:
/// ```dart
/// CustomAppBar(
/// title: Text('Title'),
/// actions: [
/// FocusableActionBar(
/// actions: [
/// FocusableAction(icon: Symbols.refresh_rounded, onPressed: _refresh),
/// FocusableAction(icon: Symbols.upload_rounded, onPressed: _upload),
/// ],
/// ),
/// ],
/// )
/// ```
class FocusableActionBar extends StatefulWidget {
final List<FocusableAction> actions;
@@ -169,18 +142,21 @@ class FocusableActionBarState extends State<FocusableActionBar> {
onUp: widget.onNavigateUp,
)(node, event);
},
child: AnimatedOpacity(
opacity: showFocus ? 1.0 : opacity,
duration: duration,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
child:
action.child ??
IconButton(
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
tooltip: action.tooltip,
onPressed: action.onPressed,
),
child: ClickableCursor(
enabled: action.onPressed != null || action.child != null,
child: AnimatedOpacity(
opacity: showFocus ? 1.0 : opacity,
duration: duration,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
child:
action.child ??
IconButton(
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
tooltip: action.tooltip,
onPressed: action.onPressed,
),
),
),
),
);
-17
View File
@@ -4,23 +4,6 @@ import 'focus_theme.dart';
import 'focusable_wrapper.dart';
import 'input_mode_tracker.dart';
/// A focusable button wrapper for D-pad navigation on TV.
///
/// Wraps any button widget with [FocusableWrapper] and adds a white overlay
/// + contrasting border when focused. Tracks focus state internally so callers
/// don't need manual state management.
///
/// ```dart
/// FocusableButton(
/// autofocus: true,
/// onPressed: _doSomething,
/// child: FilledButton.icon(
/// onPressed: _doSomething,
/// icon: Icon(Symbols.add_rounded),
/// label: Text('Create'),
/// ),
/// )
/// ```
class FocusableButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
-18
View File
@@ -6,27 +6,13 @@ import 'package:flutter/services.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// Callbacks for chip key event handling.
class ChipKeyCallbacks {
/// Called when SELECT key is pressed (short press when [onLongPress] is set).
final VoidCallback? onSelect;
/// Called when SELECT key is held for 500ms.
final VoidCallback? onLongPress;
/// Called when DOWN arrow is pressed.
final VoidCallback? onNavigateDown;
/// Called when UP arrow is pressed.
final VoidCallback? onNavigateUp;
/// Called when LEFT arrow is pressed.
final VoidCallback? onNavigateLeft;
/// Called when RIGHT arrow is pressed.
final VoidCallback? onNavigateRight;
/// Called when BACK key is pressed.
final VoidCallback? onBack;
const ChipKeyCallbacks({
@@ -168,7 +154,6 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.ignored;
}
// LEFT arrow - call callback if provided, otherwise propagate to parent
if (key.isLeftKey) {
if (callbacks.onNavigateLeft != null) {
callbacks.onNavigateLeft!();
@@ -178,7 +163,6 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.ignored;
}
// RIGHT arrow - call callback if provided, otherwise consume to prevent escape
if (key.isRightKey) {
if (callbacks.onNavigateRight != null) {
callbacks.onNavigateRight!();
@@ -187,13 +171,11 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.handled;
}
// DOWN arrow
if (key.isDownKey) {
callbacks.onNavigateDown?.call();
return KeyEventResult.handled;
}
// UP arrow
if (key.isUpKey && callbacks.onNavigateUp != null) {
callbacks.onNavigateUp!();
return KeyEventResult.handled;
-5
View File
@@ -4,11 +4,6 @@ import 'dpad_navigator.dart';
import 'focusable_wrapper.dart';
import 'input_mode_tracker.dart';
/// A D-pad friendly slider that uses LEFT/RIGHT to adjust value
/// and lets UP/DOWN pass through for focus traversal.
///
/// Hides the thumb in keyboard mode when not focused, matching
/// the timeline slider pattern.
class FocusableSlider extends StatefulWidget {
final double value;
final double min;
+865
View File
@@ -0,0 +1,865 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'dpad_navigator.dart';
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isAppleTV();
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
String _describeTextInputKey(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
}
void _logTvTextInput(String message) {
TextInputDiagnostics.log('FlutterTextField', message);
}
class _NativeTvTextInputFocusBridge {
static const _channel = MethodChannel('com.plezy/text_input');
static final Set<Object> _focusedTokens = <Object>{};
static bool _lastSentFocused = false;
static void setFocused(Object token, bool focused) {
_logTvTextInput(
'NativeFocusBridge.setFocused requested focused=$focused token=$token activeTokens=${_focusedTokens.length}',
);
if (focused) {
_focusedTokens.add(token);
} else {
_focusedTokens.remove(token);
}
if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) {
_logTvTextInput(
'NativeFocusBridge clearing without platform send isTv=${PlatformDetector.isTV()} '
'isAppleTV=${PlatformDetector.isAppleTV()}',
);
_focusedTokens.clear();
_lastSentFocused = false;
return;
}
final anyFocused = _focusedTokens.isNotEmpty;
if (_lastSentFocused == anyFocused) {
_logTvTextInput('NativeFocusBridge no-op anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
return;
}
_lastSentFocused = anyFocused;
_logTvTextInput('NativeFocusBridge sending anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
unawaited(GamepadService.setNativeTextInputFocused(anyFocused));
unawaited(_sendFocused(anyFocused));
}
static Future<void> _sendFocused(bool focused) async {
try {
await _channel.invokeMethod<void>('setNativeTextInputFocused', focused);
_logTvTextInput('NativeFocusBridge platform send complete focused=$focused');
} on MissingPluginException {
_logTvTextInput('NativeFocusBridge platform send missing plugin focused=$focused');
// Tests and non-Android embedders do not register this channel.
} on PlatformException {
_logTvTextInput('NativeFocusBridge platform send failed focused=$focused');
// Focus reporting is a best-effort native routing hint.
}
}
}
KeyEventResult _handleInputKey({
required TextEditingController controller,
required bool usesTvKeyboard,
required bool enabled,
required VoidCallback openKeyboard,
required KeyEvent event,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
int? maxLength,
int? maxLines,
VoidCallback? onSelect,
VoidCallback? onBack,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
VoidCallback? onNavigateDown,
}) {
final key = event.logicalKey;
KeyEventResult finish(KeyEventResult result, String reason) {
_logTvTextInput(
'result=$result reason=$reason key=(${_describeTextInputKey(event)}) '
'usesTvKeyboard=$usesTvKeyboard enabled=$enabled textLength=${controller.text.length} '
'selection=${controller.selection} onNav(up=${onNavigateUp != null},down=${onNavigateDown != null},'
'left=${onNavigateLeft != null},right=${onNavigateRight != null}) onSelect=${onSelect != null} onBack=${onBack != null}',
);
return result;
}
_logTvTextInput(
'received key=(${_describeTextInputKey(event)}) usesTvKeyboard=$usesTvKeyboard enabled=$enabled '
'textLength=${controller.text.length} selection=${controller.selection}',
);
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) {
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
}
if (usesTvKeyboard && enabled && event.isTvSelectEvent) {
if (event is KeyDownEvent) openKeyboard();
return finish(KeyEventResult.handled, 'open-custom-tv-keyboard');
}
if (usesTvKeyboard && enabled && event.isPhysicalKeyboardEvent) {
final result = _handleTvHardwareKeyboardKey(
controller: controller,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
maxLength: maxLength,
maxLines: maxLines,
event: event,
);
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
}
if (onBack != null && key.isBackKey) {
if (event is KeyDownEvent) onBack();
return finish(KeyEventResult.handled, 'onBack');
}
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
// non-text submit keys that TV remotes/gamepads may send while editing.
if (!usesTvKeyboard &&
onSelect != null &&
(key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
if (event is KeyDownEvent) onSelect();
return finish(KeyEventResult.handled, 'native-tv-non-text-select');
}
if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable');
if (key.isUpKey && onNavigateUp != null) {
onNavigateUp();
return finish(KeyEventResult.handled, 'onNavigateUp');
}
if (key.isDownKey && onNavigateDown != null) {
onNavigateDown();
return finish(KeyEventResult.handled, 'onNavigateDown');
}
final sel = controller.selection;
if (sel.isCollapsed) {
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) {
onNavigateLeft();
return finish(KeyEventResult.handled, 'onNavigateLeft-at-start');
}
if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) {
onNavigateRight();
return finish(KeyEventResult.handled, 'onNavigateRight-at-end');
}
}
return finish(KeyEventResult.ignored, 'fall-through');
}
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
_logTvTextInput(
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
);
return false;
}
// Android TV provides its own IME. Remote keys must reach the platform so
// users can move around that keyboard instead of escaping the app field.
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
// native TV navigation cannot rely on deviceType.
final key = event.logicalKey;
final shouldPass = key.isDpadDirection || key.isBackKey || event.isTvSelectEvent;
_logTvTextInput(
'native-pass=$shouldPass reason=${shouldPass ? "remote-navigation-key" : "not-navigation-key"} '
'key=(${_describeTextInputKey(event)})',
);
return shouldPass;
}
KeyEventResult _handleTvHardwareKeyboardKey({
required TextEditingController controller,
required KeyEvent event,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
int? maxLength,
int? maxLines,
}) {
final key = event.logicalKey;
if (event.isPhysicalKeyboardEnter) {
if (event is KeyDownEvent) {
if (_isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines)) {
_insertText(
controller: controller,
text: '\n',
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
} else {
_submitTextInput(
controller: controller,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
);
}
}
return KeyEventResult.handled;
}
if (!event.isActionable) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.backspace) {
_backspace(controller: controller, inputFormatters: inputFormatters, maxLength: maxLength, onChanged: onChanged);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.delete) {
_deleteForward(
controller: controller,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return KeyEventResult.handled;
}
if (key.isLeftKey || key.isRightKey) {
return _moveCaretHorizontally(controller, key.isLeftKey ? -1 : 1);
}
final character = event.character;
if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
_insertText(
controller: controller,
text: character,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
bool _isMultilineTextInput({TextInputType? keyboardType, int? maxLines}) {
return keyboardType?.index == TextInputType.multiline.index || (maxLines != null && maxLines != 1);
}
bool _isControlCharacter(String text) {
return text.runes.every((codeUnit) => codeUnit < 0x20 || codeUnit == 0x7f);
}
KeyEventResult _moveCaretHorizontally(TextEditingController controller, int delta) {
final value = controller.value;
final selection = value.selection;
if (!selection.isValid) {
controller.selection = TextSelection.collapsed(offset: value.text.length);
return KeyEventResult.handled;
}
if (!selection.isCollapsed) {
final offset = delta < 0
? (selection.start < selection.end ? selection.start : selection.end)
: (selection.start > selection.end ? selection.start : selection.end);
controller.selection = TextSelection.collapsed(offset: offset);
return KeyEventResult.handled;
}
final nextOffset = selection.extentOffset + delta;
if (nextOffset < 0 || nextOffset > value.text.length) return KeyEventResult.ignored;
controller.selection = TextSelection.collapsed(offset: nextOffset);
return KeyEventResult.handled;
}
void _submitTextInput({
required TextEditingController controller,
required TextInputAction? textInputAction,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
}) {
if (onEditingComplete != null) {
onEditingComplete();
} else {
_defaultEditingComplete(textInputAction);
}
onSubmitted?.call(controller.text);
}
void _defaultEditingComplete(TextInputAction? textInputAction) {
final focus = FocusManager.instance.primaryFocus;
switch (textInputAction) {
case TextInputAction.next:
focus?.nextFocus();
case TextInputAction.previous:
focus?.previousFocus();
default:
focus?.unfocus();
}
}
void _insertText({
required TextEditingController controller,
required String text,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final selection = value.selection;
final start = selection.isValid
? (selection.start < selection.end ? selection.start : selection.end)
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
final newText = value.text.replaceRange(start, end, text);
_replaceTextValue(
controller: controller,
nextValue: value.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: start + text.length),
composing: TextRange.empty,
),
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _backspace({
required TextEditingController controller,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final selection = value.selection;
final start = selection.isValid
? (selection.start < selection.end ? selection.start : selection.end)
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
if (start != end) {
_replaceTextRange(
controller,
start,
end,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return;
}
if (start == 0) return;
_replaceTextRange(
controller,
start - 1,
start,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _deleteForward({
required TextEditingController controller,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final selection = value.selection;
final start = selection.isValid
? (selection.start < selection.end ? selection.start : selection.end)
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
if (start != end) {
_replaceTextRange(
controller,
start,
end,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return;
}
if (start >= value.text.length) return;
_replaceTextRange(
controller,
start,
start + 1,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _replaceTextRange(
TextEditingController controller,
int start,
int end, {
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
_replaceTextValue(
controller: controller,
nextValue: value.copyWith(
text: value.text.replaceRange(start, end, ''),
selection: TextSelection.collapsed(offset: start),
composing: TextRange.empty,
),
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _replaceTextValue({
required TextEditingController controller,
required TextEditingValue nextValue,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final previousValue = controller.value;
var formattedValue = nextValue;
final formatters = [
...?inputFormatters,
if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength),
];
for (final formatter in formatters) {
formattedValue = formatter.formatEditUpdate(previousValue, formattedValue);
}
controller.value = formattedValue;
if (formattedValue.text != previousValue.text) {
onChanged?.call(formattedValue.text);
}
}
abstract class _FocusableTextInputBase extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final VoidCallback? onSelect;
final VoidCallback? onBack;
final bool autofocus;
final bool enabled;
final bool enableTvKeyboard;
final bool obscureText;
final bool autocorrect;
final bool enableSuggestions;
final bool? enableInteractiveSelection;
final int? maxLength;
final int? maxLines;
final int? minLines;
final TextAlign textAlign;
final TextCapitalization textCapitalization;
final TextStyle? style;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
const _FocusableTextInputBase({
super.key,
required this.controller,
this.focusNode,
this.decoration,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
this.onSelect,
this.onBack,
this.autofocus = false,
this.enabled = true,
this.enableTvKeyboard = true,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
this.enableInteractiveSelection,
this.maxLength,
this.maxLines = 1,
this.minLines,
this.textAlign = TextAlign.start,
this.textCapitalization = TextCapitalization.none,
this.style,
this.onNavigateLeft,
this.onNavigateRight,
this.onNavigateUp,
this.onNavigateDown,
});
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
VoidCallback? get _effectiveOnEditingComplete {
if (onEditingComplete != null) return onEditingComplete;
if (_usesNativeTvKeyboard && onSubmitted == null) return _handleTvKeyboardAction;
return null;
}
void _showTvKeyboard(BuildContext context) {
if (!enabled) return;
showTvVirtualKeyboard(
context: context,
controller: controller,
hintText: _keyboardHint(decoration),
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
onChanged: onChanged,
onSubmitted: onSubmitted,
onAction: _handleTvKeyboardAction,
);
}
void _handleTvKeyboardAction() {
if (onEditingComplete != null) {
onEditingComplete!();
} else if (onSelect != null) {
onSelect!();
} else if (onNavigateDown != null) {
onNavigateDown!();
} else {
_defaultEditingComplete(textInputAction);
}
}
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) {
return _handleInputKey(
controller: controller,
usesTvKeyboard: _hasTvKeyboard,
enabled: enabled,
openKeyboard: () => _showTvKeyboard(context),
event: event,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
maxLength: maxLength,
maxLines: maxLines,
onSelect: onSelect,
onBack: onBack,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
);
}
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder) {
return _FocusableTextInputHost(input: this, builder: builder);
}
}
class _FocusableTextInputHost extends StatefulWidget {
final _FocusableTextInputBase input;
final Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder;
const _FocusableTextInputHost({required this.input, required this.builder});
@override
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
}
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
FocusNode? _ownedFocusNode;
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
late final VoidCallback _focusListener = _syncNativeTextInputFocus;
final Object _nativeFocusToken = Object();
bool _reportedNativeTextInputFocused = false;
FocusNode get _effectiveFocusNode =>
widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput'));
@override
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.input.focusNode != widget.input.focusNode) {
_restoreInstalledHandler();
}
_syncNativeTextInputFocus();
}
@override
void dispose() {
_restoreInstalledHandler();
_ownedFocusNode?.dispose();
super.dispose();
}
void _syncNativeTextInputFocus() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
_logTvTextInput(
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
);
_setNativeTextInputFocused(focused);
}
void _setNativeTextInputFocused(bool focused) {
if (_reportedNativeTextInputFocused == focused) {
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
return;
}
_logTvTextInput('Host.setNativeTextInputFocused old=$_reportedNativeTextInputFocused new=$focused');
_reportedNativeTextInputFocused = focused;
_NativeTvTextInputFocusBridge.setFocused(_nativeFocusToken, focused);
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
final previous = _previousOnKeyEvent;
if (previous != null && !identical(previous, _keyHandler)) {
final result = previous(node, event);
_logTvTextInput(
'Host.previousOnKeyEvent node=${node.debugLabel} result=$result key=(${_describeTextInputKey(event)})',
);
if (result != KeyEventResult.ignored) return result;
}
return widget.input._handleKey(context, node, event);
}
void _installKeyHandler(FocusNode node) {
// Handle D-pad escapes on the field's own node so EditableText shortcuts
// can't consume directions before our reusable navigation callbacks run.
if (_installedFocusNode == node) {
if (identical(node.onKeyEvent, _keyHandler)) return;
_previousOnKeyEvent = node.onKeyEvent;
node.onKeyEvent = _keyHandler;
_logTvTextInput('Host.reinstalled key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
return;
}
_restoreInstalledHandler();
_installedFocusNode = node;
_previousOnKeyEvent = node.onKeyEvent;
node.onKeyEvent = _keyHandler;
node.addListener(_focusListener);
_logTvTextInput('Host.installed key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
}
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
final node = _installedFocusNode;
if (node != null) {
node.removeListener(_focusListener);
if (identical(node.onKeyEvent, _keyHandler)) {
node.onKeyEvent = _previousOnKeyEvent;
}
}
_installedFocusNode = null;
_previousOnKeyEvent = null;
}
@override
Widget build(BuildContext context) {
final focusNode = _effectiveFocusNode;
_installKeyHandler(focusNode);
_syncNativeTextInputFocus();
return widget.builder(widget.input._hasTvKeyboard, focusNode);
}
}
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
/// at the end escape to neighbouring focus targets instead of bouncing
/// against the caret boundary, while UP/DOWN always escape.
///
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
/// to the TextField's default caret movement.
class FocusableTextField extends _FocusableTextInputBase {
const FocusableTextField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
super.onSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
});
@override
Widget build(BuildContext context) {
return buildFocusableInput(
context,
(usesTvKeyboard, effectiveFocusNode) => TextField(
controller: controller,
focusNode: effectiveFocusNode,
enabled: enabled,
decoration: decoration,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: _effectiveOnEditingComplete,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
),
);
}
}
class FocusableTextFormField extends _FocusableTextInputBase {
final ValueChanged<String>? onFieldSubmitted;
final FormFieldValidator<String>? validator;
final AutovalidateMode? autovalidateMode;
final FormFieldSetter<String>? onSaved;
const FocusableTextFormField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
this.onFieldSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
this.validator,
this.autovalidateMode,
this.onSaved,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
}) : super(onSubmitted: onFieldSubmitted);
@override
Widget build(BuildContext context) {
return buildFocusableInput(
context,
(usesTvKeyboard, effectiveFocusNode) => TextFormField(
controller: controller,
focusNode: effectiveFocusNode,
enabled: enabled,
decoration: decoration,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
onEditingComplete: _effectiveOnEditingComplete,
validator: validator,
autovalidateMode: autovalidateMode,
onSaved: onSaved,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../utils/scroll_utils.dart';
/// Manages the internal/external FocusNode lifecycle for list-tile widgets and
/// auto-scrolls the tile into view when it gains focus.
mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
late FocusNode _effectiveFocusNode;
bool _ownsNode = false;
FocusNode? get widgetFocusNode;
FocusNode get effectiveFocusNode => _effectiveFocusNode;
void initFocusNode() {
if (widgetFocusNode != null) {
_effectiveFocusNode = widgetFocusNode!;
_ownsNode = false;
} else {
_effectiveFocusNode = FocusNode();
_ownsNode = true;
}
_effectiveFocusNode.addListener(_onFocusChange);
}
void updateFocusNode(FocusNode? oldFocusNode) {
if (oldFocusNode != widgetFocusNode) {
disposeFocusNode();
initFocusNode();
}
}
void disposeFocusNode() {
_effectiveFocusNode.removeListener(_onFocusChange);
if (_ownsNode) _effectiveFocusNode.dispose();
}
void _onFocusChange() {
if (_effectiveFocusNode.hasFocus) {
scrollContextToCenter(context);
}
}
}
+45 -18
View File
@@ -2,11 +2,23 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'dpad_navigator.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
String _describeFocusableKey(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
}
void _logFocusableWrapper(String message) {
TextInputDiagnostics.log('FocusableWrapper', message);
}
/// A wrapper widget that makes its child focusable with D-pad navigation support.
///
/// Provides:
@@ -70,7 +82,7 @@ class FocusableWrapper extends StatefulWidget {
/// Whether the wrapper can receive focus.
final bool canRequestFocus;
/// Custom key event handler. Return KeyEventResult.handled to consume the event.
/// Custom key event handler. Return any non-ignored result to stop default handling.
/// This is called before the default key handling.
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
@@ -136,8 +148,8 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
bool _ownsNode = false;
bool _isFocused = false;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late final AnimationController _animationController;
late final Animation<double> _scaleAnimation;
// Long-press detection for SELECT key
Timer? _longPressTimer;
@@ -315,23 +327,34 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
KeyEventResult finish(KeyEventResult result, String reason) {
_logFocusableWrapper(
'node=${node.debugLabel} result=$result reason=$reason key=(${_describeFocusableKey(event)}) '
'onNav(up=${widget.onNavigateUp != null},down=${widget.onNavigateDown != null},'
'left=${widget.onNavigateLeft != null},right=${widget.onNavigateRight != null}) '
'onSelect=${widget.onSelect != null} onBack=${widget.onBack != null}',
);
return result;
}
_logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})');
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'select-key-up-suppressed');
}
// Call custom key handler first
if (widget.onKeyEvent != null) {
final result = widget.onKeyEvent!(node, event);
if (result == KeyEventResult.handled) {
return result;
if (result != KeyEventResult.ignored) {
return finish(result, 'custom-onKeyEvent');
}
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
return finish(backResult, 'onBack');
}
}
@@ -351,10 +374,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
});
}
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'select-long-press-down');
} else if (event is KeyRepeatEvent) {
// Consume repeat events to prevent system sounds
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'select-long-press-repeat');
} else if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
@@ -364,52 +387,52 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
// If timer already fired, long press was triggered - do nothing on key up
_isSelectKeyDown = false;
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'select-long-press-up');
}
} else if (widget.onSelect != null) {
return handleOneShotSelect(event, widget.onSelect!);
return finish(handleOneShotSelect(event, widget.onSelect!), 'one-shot-select');
}
}
// Ignore key up events for other keys
if (!event.isActionable) {
return KeyEventResult.ignored;
return finish(KeyEventResult.ignored, 'non-actionable');
}
// Context menu key
if (key.isContextMenuKey) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
widget.onLongPress?.call();
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'context-menu');
}
// UP arrow - if callback provided, navigate up
if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) {
widget.onNavigateUp!();
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'onNavigateUp');
}
// DOWN arrow - if callback provided, navigate down
if (key == LogicalKeyboardKey.arrowDown && widget.onNavigateDown != null) {
widget.onNavigateDown!();
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'onNavigateDown');
}
// LEFT arrow - if callback provided, navigate left (caller is responsible
// for only providing this callback when the item is at the left edge)
if (key == LogicalKeyboardKey.arrowLeft && widget.onNavigateLeft != null) {
widget.onNavigateLeft!();
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'onNavigateLeft');
}
// RIGHT arrow - if callback provided, navigate right (caller is responsible
// for only providing this callback when the item is at the right edge)
if (key == LogicalKeyboardKey.arrowRight && widget.onNavigateRight != null) {
widget.onNavigateRight!();
return KeyEventResult.handled;
return finish(KeyEventResult.handled, 'onNavigateRight');
}
return KeyEventResult.ignored;
return finish(KeyEventResult.ignored, 'fall-through');
}
@override
@@ -461,6 +484,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
result = Semantics(label: widget.semanticLabel, button: widget.onSelect != null, child: result);
}
if (widget.onSelect != null || widget.onLongPress != null) {
result = ClickableCursor(child: result);
}
return result;
}
}
+25 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
@@ -52,10 +53,7 @@ class BackKeyCoordinator {
}
}
/// Handle a BACK key press by running [onBack] on key up.
///
/// This consumes KeyDown/KeyRepeat to avoid duplicate actions from key repeat.
/// Optionally suppresses stray KeyUp events delivered to the next route after a pop.
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
@@ -64,6 +62,21 @@ KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
return KeyEventResult.handled;
}
// AppleTV physical-keyboard back (Siri Remote Menu via engine-synthesized
// escape): run onBack on KeyDown only; consume KeyUp silently. The
// suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
// because onBack typically calls Navigator.pop, swapping the focus tree
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
// silently swallowing the next press's KeyDown.
if (PlatformDetector.isAppleTV() && event.isPhysicalKeyboardEvent) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
// Mark that we're closing via back key so suppressBackUntilKeyUp() knows to skip
@@ -92,12 +105,8 @@ KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event,
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Handles a select key as a one-shot button activation.
///
/// Fires [onActivate] on the initial [KeyDownEvent] only.
/// Consumes all select key events (down, repeat, up) to prevent
/// unhandled events from reaching platform-level handling.
/// Returns [KeyEventResult.ignored] for non-select keys.
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) onActivate();
@@ -173,6 +182,12 @@ FocusOnKeyEventCallback dpadKeyHandler({
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed and arming it would pin state across the
// pop's focus-tree swap. (The atomic engine fix delivers KeyDown+KeyUp in
// a single recognizer Began callback, so didPop fires squarely inside the
// window where BackKeyPressTracker.isBackKeyDown is true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}

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