Compare commits

...
257 Commits
Author SHA1 Message Date
github-actions[bot] 10d4b3088f chore: bump version to 1.8.1 2025-11-25 03:07:42 +00:00
edde746 a7f1c8a13c fix: auto hide controls on mobile
close #129
2025-11-25 04:03:23 +01:00
edde746 516bf3a689 ci: release adjustments 2025-11-25 03:38:04 +01:00
edde746 141deb8da0 fix: profile switcher 2025-11-25 03:33:30 +01:00
edde746andGitHub 4bcdb01029 Merge pull request #128 from Doezer/doezer/fix-subtitle-language-check-in-track-match
bug: fix language detection in track match
2025-11-24 13:23:06 +01:00
Doezer 3b51249341 bug: fix user preference for subtitle 2025-11-24 12:17:58 +01:00
edde746 0585c9da21 fix: properly clear connections on switch 2025-11-23 06:30:53 +01:00
edde746 7ab8dfa3c3 refactor: simplify metadata server tagging 2025-11-23 05:10:42 +01:00
github-actions[bot] 8d0d220d78 chore: bump version to 1.8.0+20 2025-11-22 23:36:51 +00:00
edde746 3e0379996c ci: release workflow 2025-11-23 00:36:02 +01:00
edde746 6aecaaa44a refactor: simplify server tracking 2025-11-22 23:41:46 +01:00
edde746 21627f7a97 fix: android resume issue 2025-11-22 22:32:07 +01:00
edde746 cb8e0bfa79 ci: dead code checks 2025-11-22 16:03:51 +01:00
edde746 19601e87c7 refactor: clean up 2025-11-22 07:54:52 +01:00
edde746 3945aa7b58 fix: multi-server connectivity optimization 2025-11-22 06:52:21 +01:00
edde746 b455db4bdf refactor: clean up 2025-11-22 06:33:49 +01:00
edde746 38a447f57a refactor: format & split video controls 2025-11-22 05:58:08 +01:00
edde746 8a24157832 fix: library manager 2025-11-22 04:42:11 +01:00
edde746 166654d374 refactor: use play queues & other improvements 2025-11-22 03:24:01 +01:00
edde746 6e8a1a373e feat: remove server switcher 2025-11-21 23:59:39 +01:00
edde746 b1bed439eb fix: defer clearShuffle call to avoid setState during build 2025-11-21 03:52:59 +01:00
edde746 2562d3d4b6 fix: linux audio output issues 2025-11-21 02:36:37 +01:00
edde746 c27d9d09e4 fix: linux crash 2025-11-21 02:03:36 +01:00
edde746 3cb8b522f6 chore: bump version 2025-11-20 19:10:18 +01:00
edde746 9115db5e15 fix: ios media controls 2025-11-20 19:08:31 +01:00
edde746 d63c10482c chore: bump version 2025-11-20 15:56:48 +01:00
edde746 bd283dd039 fix: inactive pause fixes 2025-11-20 15:54:13 +01:00
edde746andGitHub 0403acc9d3 Merge pull request #123 from Doezer/main
Add missing translations
2025-11-19 23:34:54 +01:00
DoezerandGitHub b81eee7f34 Merge pull request #11 from Doezer/copilot/check-untranslated-strings
Translate 126 untranslated strings across SV, NL, IT, and ZH locales
2025-11-19 22:31:05 +01:00
copilot-swe-agent[bot]andDoezer 8cef2d9cb0 Translate untranslated strings across all languages
- Swedish (SV): Translated 29 strings including video controls and accessibility labels
- Dutch (NL): Translated 36 strings including video controls, auth, and navigation
- Italian (IT): Translated 32 strings including video controls and discover section
- Chinese (ZH): Translated 29 strings with complete video player UI localization
- German (DE): Already well-translated, verified existing translations

Total: 126 strings translated across all languages
Remaining untranslated strings are intentional (brand names, technical terms, format strings)

Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
2025-11-19 20:41:52 +00:00
copilot-swe-agent[bot] 2e042922f4 Initial plan 2025-11-19 20:35:39 +00:00
edde746 79fb716551 fix: linux media controls 2025-11-19 09:23:39 +01:00
edde746 37b0cf65b2 fix: https required servers issue 2025-11-18 20:47:31 +01:00
edde746andGitHub b8a7d8610c Merge pull request #119 from jamal2362/main1
Update german translation
2025-11-18 20:38:11 +01:00
Jamal ad3f26de30 Update german translation 2025-11-18 19:48:08 +01:00
edde746 f23ab5b96e fix: media card playlist compatibility 2025-11-18 19:03:17 +01:00
edde746 f4c52f4069 Merge branch 'accessibility' 2025-11-18 14:44:30 +01:00
edde746 9af87d2e02 refactor: wrap buttons in semantics 2025-11-18 14:44:00 +01:00
edde746 adbf126430 chore: bump version 2025-11-18 13:38:45 +01:00
edde746 61817ed14e fix: subtitle cropping adjustments 2025-11-18 13:38:08 +01:00
edde746 0eff03a66e fix: macos crash 2025-11-18 13:37:39 +01:00
Manuel Cortez 6417a37366 Added semantics to app bar's back button. Fixed conflicts 2025-11-17 23:12:42 -06:00
Manuel Cortez e519304f2e added translation for 'tap to play' hint on media cards 2025-11-17 22:42:11 -06:00
Manuel Cortez d0bfcb107d Enhance semantic labels to hero section on discover screen 2025-11-17 22:30:49 -06:00
Manuel Cortez 2f307ee613 added proper semantic labels to media cards 2025-11-17 22:29:08 -06:00
edde746 12c0d65003 chore: bump version 2025-11-17 00:44:19 +01:00
edde746 049a63b079 issue templates 2025-11-17 00:41:39 +01:00
edde746 e7a4220765 Merge branch 'main' into copilot/add-loading-spinner-video 2025-11-17 00:33:09 +01:00
edde746 3030bac32b refactor: fix warnings 2025-11-17 00:29:12 +01:00
edde746 1e12897c8a refactor: deduplicate 2025-11-17 00:21:57 +01:00
edde746andGitHub 79a355c768 Merge pull request #101 from Doezer/copilot/add-sanity-checks-ci
Add CI sanity checks for code quality validation
2025-11-17 00:18:20 +01:00
edde746 9501831691 refactor: format 2025-11-16 23:57:32 +01:00
edde746 a6927f0edf refactor: consolidate duration format 2025-11-16 23:53:11 +01:00
edde746 a952ab0a00 refactor: deduplicate & other code quality fixes 2025-11-16 22:42:04 +01:00
edde746 0c23de9ede feat: library overhaul
add collections, library specific home screen, and more
2025-11-16 20:53:59 +01:00
edde746 080907c813 fix: don't fallback on HTTP errors 2025-11-16 11:04:39 +01:00
DoezerandGitHub b5e01fc8c2 Merge branch 'main' into copilot/add-sanity-checks-ci 2025-11-16 09:22:36 +01:00
DoezerandGitHub f25686d542 Merge branch 'edde746:main' into main 2025-11-16 09:06:55 +01:00
Doezer 4f5531b67c update the CI code analysis to not fail on 'info' and warnings 2025-11-16 08:45:08 +01:00
860b8bba49 Add comprehensive CI sanity checks and documentation
Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
2025-11-16 08:39:34 +01:00
Manuel Cortez e23f248427 Merge branch 'main' into accessibility 2025-11-15 22:36:14 -06:00
Manuel Cortez c6bd1ef867 Added missing translation file 2025-11-15 22:22:24 -06:00
Manuel Cortez 931bc9b931 Added semantic labels to media player controls 2025-11-15 22:17:04 -06:00
Manuel Cortez 3f4d5c2923 video_control_button: sets a semantic label properly for IconButtons 2025-11-15 22:07:30 -06:00
Manuel Cortez 1220dca88e Prepared translation files with new addition for accessibility enhancements 2025-11-15 22:05:41 -06:00
edde746 d10b882516 fix(android): add missing crop effect 2025-11-16 03:53:38 +01:00
edde746 be69d16948 fix(android): working mpv builds 2025-11-16 03:08:56 +01:00
edde746andGitHub 8f77b5226d Merge pull request #107 from jamal2362/main
Added more missing translations
2025-11-15 14:21:00 +01:00
Jamal 8ee2b7cfe1 Added more missing translations 2025-11-15 12:51:57 +01:00
edde746 f14ed7d762 chore: format 2025-11-15 04:08:57 +01:00
edde746 2324590175 feat: playlists 2025-11-15 04:07:08 +01:00
edde746 ed478d1ed0 i18n(zh): translate 2 new strings 2025-11-15 01:13:09 +01:00
edde746 efa7f1f724 chore: generate strings 2025-11-15 01:10:26 +01:00
edde746andGitHub 449c5b055f Merge pull request #102 from ulovecode/main
add zh i18n
2025-11-15 01:09:07 +01:00
Jovan a38513db56 fix code 2025-11-15 08:08:03 +08:00
edde746 b06609a829 fix: android mpv hashes 2025-11-15 01:03:23 +01:00
edde746andGitHub c9b4a08072 Merge pull request #105 from jamal2362/main
Add german translation
2025-11-15 00:45:23 +01:00
edde746 e6b699c976 fix: better connection selection 2025-11-15 00:42:45 +01:00
edde746 831552d5b8 refactor: better log redaction 2025-11-15 00:30:04 +01:00
Jamal 9bb9879dc4 Add german translation 2025-11-14 23:46:34 +01:00
edde746 0101dbde71 fix: make media controls follow app lifecycle 2025-11-14 23:10:05 +01:00
edde746 a618218828 feat: respect per media language preference 2025-11-14 22:27:12 +01:00
edde746 173ab59007 refactor: remove unnecessary background mode 2025-11-14 22:02:49 +01:00
edde746 0b9998ddff fix: don't crop subtitles 2025-11-14 19:26:04 +01:00
Jovan 415cb90af2 fix 2025-11-14 23:37:25 +08:00
Jovan 0da4b11f17 rename file 2025-11-14 23:34:42 +08:00
Jovan 6c41e09214 i18n(zh): minor fixes 2025-11-14 23:32:17 +08:00
Jovan d37195e01e add zh i18n 2025-11-14 23:27:21 +08:00
ed7c47b3db Add buffering spinner to video player
Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
2025-11-14 07:57:37 +01:00
copilot-swe-agent[bot]andDoezer 7dbe5db11c Initial plan 2025-11-14 07:57:37 +01:00
DoezerandGitHub 44a20e9e33 Merge pull request #7 from Doezer/copilot/add-sanity-checks-ci
Added clean before pub get + applied suggestion
2025-11-14 07:41:31 +01:00
Doezer 6d3b686524 Added clean before pub get + applied suggestion 2025-11-14 07:37:19 +01:00
DoezerandGitHub 855a70e91e Merge branch 'edde746:main' into main 2025-11-14 07:13:37 +01:00
edde746andGitHub d2bd3b8ae6 Merge pull request #100 from Doezer/doezer/run-dart-format
Format files with dart format
2025-11-14 00:23:53 +01:00
edde746andGitHub 30461f3668 Merge pull request #99 from Doezer/doezer/fix-nl-translations
Add NL translations for remove from continue watching
2025-11-14 00:21:24 +01:00
DoezerandGitHub 92fa64cfc7 Merge pull request #4 from Doezer/copilot/add-sanity-checks-ci
Add CI sanity checks for code quality validation
2025-11-13 23:24:00 +01:00
copilot-swe-agent[bot]andDoezer ce5e5d7f94 Clarify documentation: explain generated file exclusions
Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
2025-11-13 22:07:01 +00:00
fd64f26c5e Update .github/workflows/ci.yml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 23:04:06 +01:00
aeeacfc84b Add comprehensive CI sanity checks and documentation
Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
2025-11-13 22:57:32 +01:00
DoezerandGitHub 95a15a07cc Merge pull request #6 from Doezer/doezer/run-dart-format
Ran dart format using this pwsh command
2025-11-13 22:56:08 +01:00
Doezer 3366ed02f5 Ran dart format using this pwsh command
$FILES = Get-ChildItem -Path lib, test -Recurse -Filter "*.dart" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch '\.(g|freezed)\.dart$' } | Select-Object -ExpandProperty FullName; if (-not $FILES) { Write-Host "No Dart files found to format"; exit 0 } else { dart format --output=write --set-exit-if-changed $FILES }
2025-11-13 22:53:31 +01:00
DoezerandGitHub 6cfc939941 Merge pull request #5 from Doezer/doezer/fix-nl-translations
Add NL translations for remove from continue watching
2025-11-13 22:39:14 +01:00
Doezer 29bfb70cc9 Add NL translations for remove from continue watching 2025-11-13 22:37:28 +01:00
edde746andGitHub fd0f3f2241 Merge pull request #98 from Doezer/doezer/remove-from-continue-watching
feat: Add functionality to remove items from Continue Watching
2025-11-13 20:01:01 +01:00
DoezerandGitHub b25ab0da8d Merge branch 'main' into doezer/remove-from-continue-watching 2025-11-13 19:25:17 +01:00
copilot-swe-agent[bot]DoezerDoezercopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9be219ce15 feat: Add functionality to remove items from Continue Watching
- Updated the DiscoverScreen to include a callback for removing items from Continue Watching.
- Enhanced MediaCard to accept a new callback for removing items from Continue Watching.
- Modified MediaContextMenu to handle the removal of items from Continue Watching without affecting their watched status or progress.
- Updated dependencies in pubspec.lock to their latest versions.

Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-13 19:07:31 +01:00
edde746andGitHub e86f093470 Merge pull request #97 from Tazi0/feature/dutch-translations
feat: Dutch translations
2025-11-13 10:56:19 +01:00
Tazio 5fbdbe3595 fix: search screen translations 2025-11-13 09:03:09 +01:00
Tazio 0d5873171b feat: Dutch translations 2025-11-13 08:54:12 +01:00
edde746 4845d2e659 fix: add missing language in settings 2025-11-12 19:00:35 +01:00
edde746 01857a1c05 Merge branch 'feature/more-detail-in-media-screen' 2025-11-12 18:06:44 +01:00
edde746 e01ac6d03b fix: make cast scrollable on desktop
and translate cast text
2025-11-12 18:06:23 +01:00
edde746 12192c96c7 refactor: two column auth screen 2025-11-12 17:54:25 +01:00
Tazio bc741a1310 refactor: move cast below seasons 2025-11-12 09:23:56 +01:00
ComicalHysteriaandGitHub 0cbb5d6e8b Merge pull request #2 from ComicalHysteria/copilot/move-qr-code-second-column
Move QR code to second column for TV visibility
2025-11-11 16:39:15 -06:00
copilot-swe-agent[bot]andComicalHysteria b9c5d827bb Move QR code to second column for TV visibility
Co-authored-by: ComicalHysteria <16730495+ComicalHysteria@users.noreply.github.com>
2025-11-11 22:34:56 +00:00
copilot-swe-agent[bot] 3cfb676b55 Initial plan 2025-11-11 22:31:16 +00:00
ComicalHysteriaandGitHub ecfd4ca1b8 Merge branch 'edde746:main' into main 2025-11-11 16:28:03 -06:00
Tazio d2ebc2b165 fix: more height for cast 2025-11-11 15:03:02 +01:00
Tazio 3853ffb1aa fix: plex role optional id 2025-11-11 14:56:00 +01:00
Tazio 9514bbb5d6 Merge remote-tracking branch 'upstream/main' into feature/more-detail-in-media-screen 2025-11-11 14:42:40 +01:00
edde746 fdad9549b6 fix: ios media controls 2025-11-11 14:21:18 +01:00
edde746 6e06244b1c i18n(it): minor fixes 2025-11-10 21:23:46 +01:00
fixx1983andGitHub 57ca53b9a6 Create strings_it.i18n.json 2025-11-10 21:16:00 +01:00
edde746 a878a9a512 fix: respect library home screen exclusion 2025-11-10 11:08:36 +01:00
edde746 94bf264b07 docs: contributing 2025-11-10 10:20:20 +01:00
edde746 f828ee926b refactor: fix warnings 2025-11-10 10:17:14 +01:00
edde746 0c102ff3a3 refactor: reduce requests made for playback 2025-11-10 10:13:01 +01:00
edde746 995f0d006d feat: i18n 2025-11-10 10:02:34 +01:00
edde746 a607904a35 fix: better server selector errors 2025-11-09 21:24:44 +01:00
edde746 7fbd320fa7 fix: full refresh on profile switch 2025-11-09 16:52:00 +01:00
Tazio 1043db2fb8 feat: display roles 2025-11-09 15:57:33 +01:00
Tazio 41194f2807 feat: add critic and audience score 2025-11-09 15:50:59 +01:00
edde746andGitHub bbec52e5f0 Merge pull request #80 from Tazi0/feature/skip-into-and-credit
feat: Skip intro and credit
2025-11-09 15:10:54 +01:00
Tazio 349b28d25e refactor: apparently markers do exist
I swear they weren't in the documentation
2025-11-09 14:36:01 +01:00
Tazio 15dd8f813a fix: change skip credit to Next Episode
convenience is king
2025-11-09 13:14:01 +01:00
Tazio cb4a446509 feat: Skip intro and credit
These are based on chapters
2025-11-09 13:01:32 +01:00
edde746 b98374b32f fix: windows builds 2025-11-08 20:45:05 +01:00
edde746 0ca75fb305 chore: bump version 2025-11-08 20:14:16 +01:00
edde746 2d706956da feat: re-do os media controls 2025-11-08 19:53:54 +01:00
edde746 260681aa24 chore: bump version 2025-11-08 10:10:58 +01:00
edde746 6228eb2690 refactor: rewrite os media controls
# Conflicts:
#	lib/screens/video_player_screen.dart
#	lib/services/media_service_manager.dart
#	pubspec.yaml
2025-11-08 10:04:51 +01:00
edde746 0d4f7a9e9f refactor: remove duplicate audio sessions 2025-11-08 08:35:52 +01:00
edde746 fac7aed1e7 feat: shuffle settings 2025-11-07 20:50:09 +01:00
edde746 b6234120dd fix: android crash 2025-11-07 17:53:44 +01:00
edde746 2eb33eb234 fix: android icons 2025-11-07 17:44:50 +01:00
edde746 d744e783ac feat: pgs & truehd support 2025-11-07 17:24:30 +01:00
edde746 3e068b2bea chore: update lockfile 2025-11-07 02:21:23 +01:00
edde746andGitHub 36a843b43f Merge pull request #65 from ComicalHysteria/QR-code-sign-in
Add QR code generation for sign in
2025-11-07 02:19:08 +01:00
edde746 3f40007f03 feat: throttle seeking commands 2025-11-07 02:16:07 +01:00
ComicalHysteriaandGitHub de63b9466f Merge pull request #1 from ComicalHysteria/QR-code-sign-in
Add QR code generation for sign in
2025-11-06 15:20:08 -06:00
ComicalHysteriaandGitHub 66941b06d4 Add QR code generation for sign in 2025-11-06 14:40:38 -06:00
edde746 47f5454f61 feat: os media controls 2025-11-06 15:14:40 +01:00
edde746 2a6098c3ba feat: load library in parts 2025-11-06 11:54:31 +01:00
edde746 60a84380a6 feat: external subtitle support 2025-11-06 10:36:26 +01:00
edde746 e047c820ae chore: bump version 2025-11-06 07:06:52 +01:00
edde746 e4e7911573 fix: try http connections 2025-11-06 03:58:50 +01:00
edde746 7fcf79045b Revert "ci: cache cleanup"
This reverts commit 40b3142d23.
2025-11-05 20:34:32 +01:00
edde746 40b3142d23 ci: cache cleanup 2025-11-05 20:30:54 +01:00
edde746 8f18a7c941 chore: bump dependencies 2025-11-05 20:24:20 +01:00
edde746 07b4ba371e chore: bump version 2025-11-05 20:17:03 +01:00
edde746 2571a6c38b refactor: adjust video controls button sizes 2025-11-05 20:16:12 +01:00
edde746 57b691782c feat: subtitle styling 2025-11-05 20:06:18 +01:00
edde746 eb8968c428 feat(android): dark mode icon 2025-11-05 19:33:44 +01:00
edde746 8b6052d97b feat: rotation lock 2025-11-05 16:46:31 +01:00
edde746 155ac2042e feat: audio device selector 2025-11-05 15:00:17 +01:00
edde746 1c7fccdd6b feat: persist volume 2025-11-05 14:50:13 +01:00
edde746 c15bc6d4c2 feat: subtitle delay config 2025-11-05 14:43:05 +01:00
edde746 4888d125e4 refactor: merge video controls into one sheet 2025-11-05 14:22:24 +01:00
edde746 1c8d36f0e9 feat: configurable audio delay 2025-11-05 14:15:14 +01:00
edde746 6e58f26ee4 refactor: move fullscreen button into controls 2025-11-05 14:03:30 +01:00
edde746 b30c9c6420 refactor: split up video controls 2025-11-05 13:52:21 +01:00
edde746 cef1e38f6d Merge branch 'sleep-timer-47' 2025-11-05 13:29:45 +01:00
edde746 00c02730d7 refactor(sleep timer): remove default styling 2025-11-05 13:29:32 +01:00
edde746 bb97e5a418 feat: list mode 2025-11-05 13:26:02 +01:00
edde746andGitHub d51c3ff607 Merge pull request #53 from TheLidlMan/screen-refresh-fix
fix: home refresh everytime you go back a to home page
2025-11-05 12:44:04 +01:00
Sam Matthews e500fe452d fix: when navigating back to home screen it does full screen refresh, making it feel slow now it just refreshes the continue watching tab and keeps everything else the same. 2025-11-05 11:39:29 +00:00
edde746 fd9ba46b7b fix: allow malformed utf-8 2025-11-05 12:36:09 +01:00
Sam Matthews 00da8a23e3 feat: sleep timer
- default timer values in settings
- extend timer function
- pauses playback when done and shows popup
2025-11-05 11:28:51 +00:00
edde746 0817153f4b feat: shuffle play button on media detail screen 2025-11-05 12:20:52 +01:00
edde746 c559fbf89c fix: reload libraries on refresh 2025-11-05 11:06:52 +01:00
edde746 844965e051 refactor: fix warnings 2025-11-05 11:03:35 +01:00
edde746 60ba1b1570 feat: video fill modes 2025-11-05 11:00:22 +01:00
edde746 36c6adb50c chore: bump version 2025-11-05 08:26:28 +01:00
edde746 4180ab6248 feat: log viewer 2025-11-05 08:25:28 +01:00
edde746 597640ece5 ci: remove appimage 2025-11-05 08:10:41 +01:00
edde746 4031b6043c ci: appimage fixes 2025-11-05 08:05:07 +01:00
edde746 57676178e2 ci(linux): fix missing icon 2025-11-05 07:47:26 +01:00
edde746 4430581806 ci: optimize appimage build 2025-11-05 07:42:13 +01:00
edde746 7236702818 ci: replace flatpak with appimage 2025-11-05 07:36:55 +01:00
edde746 0774263d83 feat: hero section toggle 2025-11-05 07:35:04 +01:00
edde746 6243195b21 feat: shuffle play 2025-11-05 07:28:23 +01:00
edde746 8d8423d136 refactor: move library actions to context menu 2025-11-05 07:09:58 +01:00
Sam Matthews 2f7c1084cb Add library management features: scan, refresh metadata, empty trash, and analyze #38 2025-11-04 20:10:59 +00:00
edde746 03495b1ea3 refactor: deduplicate sort widget 2025-11-04 20:49:25 +01:00
edde746andGitHub a48032c706 Merge pull request #36 from TheLidlMan/plex-recommendation-on-discover-screen-28
Plex recommendation on Home screen
2025-11-04 20:39:12 +01:00
edde746 357a4a5590 ci(flatpak): correct container 2025-11-04 20:09:05 +01:00
Sam Matthews 81adf86769 formatting issues 2025-11-04 19:00:44 +00:00
Sam Matthews 50264d52f3 Merge branch 'main' into plex-recommendation-on-discover-screen-28 2025-11-04 18:59:31 +00:00
Sam Matthews 2603a3992c - Add PlexHub model to represent recommendation sections (trending, top in genre, etc.)
- Add getLibraryHubs() and getPlaylist() API methods to PlexClient
- Display library hubs below Recently Added on Discover screen
- Filter out duplicate hubs (Continue Watching, Recently Added) that are already shown
- Add dynamic context-aware icons based on hub titles (trending, genres, ratings, etc.)
- Support 20+ hub types with matching icons: trending, popular, seasonal, genres, top rated, playlists, and more
- Support for custom collections from Kometa/Pulsarr

Implements feature request #28
2025-11-04 18:57:46 +00:00
edde746 3f95b4d822 ci(flatpak): hardcode flutter version
ts garbage
2025-11-04 19:56:14 +01:00
edde746 3b8c944512 ci(flatpak): fix 2025-11-04 19:41:21 +01:00
edde746 d646adca89 docs: update google group 2025-11-04 19:21:56 +01:00
edde746 3e7a9231b7 ci(flatpak): fix 2025-11-04 19:18:26 +01:00
edde746 dd76e59bf1 fix: properly get sorts 2025-11-04 19:09:54 +01:00
Sam Matthews bc54a2230b feat: Add support for Plex recommendation hubs on Discover screen
- Add PlexHub model to represent recommendation sections (trending, top in genre, etc.)
- Add getLibraryHubs() and getPlaylist() API methods to PlexClient
- Display library hubs below Recently Added on Discover screen
- Filter out duplicate hubs (Continue Watching, Recently Added) that are already shown
- Add dynamic context-aware icons based on hub titles (trending, genres, ratings, etc.)
- Support 20+ hub types with matching icons: trending, popular, seasonal, genres, top rated, playlists, and more
- Support for custom collections from Kometa/Pulsarr

Implements feature request #28
2025-11-04 18:04:43 +00:00
Sam Matthews 4b972b0992 feat: Add 'Latest Episode Air Date' sort option for TV show libraries
- Add conditional sort option for TV show libraries that sorts by the most recent episode's original air date
- Uses 'episode.originallyAvailableAt' field to track when the latest episode aired
- Only appears for TV show libraries, not movies or other media types
- Defaults to descending order to show recently aired episodes first
- Perfect for tracking seasonal anime/shows with new episodes

Fixes #31
2025-11-04 17:15:21 +00:00
Sam Matthews 1cab790570 fix 2025-11-04 17:09:32 +00:00
edde746 feb9254aea ci(flatpak): fix
but actually this time
2025-11-04 17:38:44 +01:00
edde746andGitHub 9a2e5e5358 Merge pull request #32 from TheLidlMan/issue-20-hide-music-libraries
Hide music libraries from app (Issue #20)
2025-11-04 17:17:00 +01:00
edde746 e260f801d6 ci(flatpak): fix 2025-11-04 17:16:13 +01:00
Sam Matthews 2ac3555567 Merge main into issue-20: Combine music filtering with library ordering 2025-11-04 16:04:31 +00:00
edde746 dafdea282e fix: library management sheet scrolling 2025-11-04 16:55:52 +01:00
edde746 aa1efdff64 ci: flatpak build 2025-11-04 16:51:14 +01:00
edde746 3d5c7f7e3b feat: update checks 2025-11-04 16:50:46 +01:00
Sam Matthews 1ec9fbcc02 Hide music libraries from app (Issue #20)
- Filter out artist-type libraries in libraries_screen.dart
- Add safety check in media_card.dart to prevent music playback attempts
- Filter music content from getOnDeck() and getRecentlyAdded() in plex_client.dart

Music libraries are now hidden throughout the app since music playback
is not yet supported. This focuses the app on video content (movies and TV shows).
2025-11-04 15:49:00 +00:00
edde746 8e00ad0551 chore: bump version 2025-11-04 14:57:34 +01:00
edde746 deadbd1be7 fix: improve subtitle selection 2025-11-04 14:51:58 +01:00
edde746 3d3358e4f9 fix: library chip alignment 2025-11-04 14:43:31 +01:00
edde746 51cb83cef9 fix: request utf-8 encoding 2025-11-04 14:29:49 +01:00
edde746 0e2fe8a9f3 fix: sort not applying after restart 2025-11-04 13:35:33 +01:00
edde746 63fa08798c ci: notarization 2025-11-04 12:15:39 +01:00
edde746 94e6c4109a ci: macOS signing 2025-11-04 11:49:41 +01:00
edde746 4dd2d7b011 chore: bump version 2025-11-04 11:01:51 +01:00
edde746 4696b5c7b0 fix: hardware decoding toggle functionality 2025-11-04 11:01:43 +01:00
edde746 852cee8967 ci: sign android build 2025-11-04 11:00:22 +01:00
edde746 2e7babaae5 ci: macOS 26 runners 2025-11-04 10:53:02 +01:00
edde746 65057750f7 feat: file version picker 2025-11-04 10:28:37 +01:00
edde746 e50bd46386 fix: limit hero dots count 2025-11-04 09:40:02 +01:00
edde746 1c67fa3998 feat: mobile double tap rewind/skip 2025-11-04 09:26:03 +01:00
edde746 d586c8c067 fix: allow connections to local only servers 2025-11-04 09:07:04 +01:00
edde746 f1a5493aeb feat: higher playback speed options
add 2.5x and 3x speed options
2025-11-04 08:52:47 +01:00
edde746 7efa96be74 fix: missing subtitles on android 2025-11-04 08:48:04 +01:00
edde746 770cb6b744 feat: customizable seek durations 2025-11-04 08:26:05 +01:00
edde746 5126b67c9c refactor: remove redundant library management in settings 2025-11-04 08:21:52 +01:00
edde746 f0035d1335 fix: seek time overflow 2025-11-04 08:02:00 +01:00
edde746 ca576110b8 Merge branch 'feature/library-reordering-6'
# Conflicts:
#	lib/screens/libraries_screen.dart
#	lib/services/storage_service.dart
2025-11-04 07:50:12 +01:00
edde746 1f59374f62 feat: library hiding 2025-11-04 07:45:24 +01:00
edde746 679e5d5c39 feat: sorting 2025-11-04 07:45:24 +01:00
edde746 73568ee3ae refactor: unify library management 2025-11-04 07:44:36 +01:00
edde746 aa9dd045de Merge branch 'main' into feature/library-reordering-6
# Conflicts:
#	lib/screens/libraries_screen.dart
#	lib/services/storage_service.dart
2025-11-04 06:56:21 +01:00
edde746andGitHub 69e7e796b4 Merge pull request #14 from TheLidlMan/fix/issue-13-poster-sizing
Fix poster sizing on home screen to match libraries view
2025-11-04 06:47:18 +01:00
edde746 95263b7625 feat: library hiding 2025-11-04 06:45:16 +01:00
Sam Matthews 236e3e7cfc Fix poster sizing on home screen to match libraries view
- Increase minimum poster width from 130px to 160px
- Adjust poster sizing to account for MediaCard padding (16px)
- Calculate poster dimensions based on actual display area
- Ensure consistent poster sizing across Home and Libraries screens

Fixes #13
2025-11-04 05:06:55 +00:00
Sam Matthews f06deba0cc feat: add library reordering functionality (#6)
- Add reorder mode toggle button in Libraries screen app bar
- Implement drag & drop reordering for library chips
- Persist library order to local storage using SharedPreferences
- Restore saved library order on app launch
- Update selected library index when libraries are reordered
- Add visual feedback with drag handle icon in reorder mode

Fixes #6
2025-11-04 04:29:26 +00:00
edde746 dcb4f3ee8a feat: sorting 2025-11-04 05:25:29 +01:00
edde746andGitHub 4246d7689d Merge pull request #11 from TheLidlMan/fix/content-rating-display
Fix content rating display to remove country prefixes
2025-11-04 04:45:10 +01:00
edde746 781aa4640e fix: support pin protected profiles 2025-11-04 04:44:22 +01:00
Sam Matthews ed668ba0e4 Fix content rating display to remove country prefixes
- Add formatContentRating utility function to strip country codes (gb/, us/, de/, etc.)
- Apply formatter in discover_screen.dart hero section
- Apply formatter in media_detail_screen.dart (badge and info row)
- Ratings now display as '12', 'PG', 'PG-13' instead of 'gb/12', 'us/PG-13'
- Fixes #10
2025-11-04 03:32:28 +00:00
edde746andGitHub fffe342dd1 Merge pull request #9 from TheLidlMan/fix/rating-display-issue-7
Fix rating display showing 0.7 instead of 7.0
2025-11-04 04:22:36 +01:00
edde746 b5349d7b3d feat: library density settings
also add season/show poster toggle for episodes
2025-11-04 04:17:39 +01:00
Sam Matthews f7d98fbe44 Fix rating display showing 0.7 instead of 7.0
Fixes #7

- Changed rating field type from int to double in PlexMetadata model
- Removed incorrect division by 10 in discover screen rating display
- Regenerated JSON serialization to properly parse rating as double
- Rating now displays correctly (e.g., 7.0 instead of 0.7)
2025-11-04 03:12:42 +00:00
edde746 3a5b812a22 refactor: slow down hero scroll 2025-11-04 02:55:44 +01:00
edde746 b01f9ce3f9 fix: crashing when scrolling large libraries 2025-11-04 02:50:39 +01:00
edde746 25ff8f1d1f feat: horizontal scroll arrows on desktop 2025-11-04 02:14:50 +01:00
edde746 ce8d384e7c feat: file info 2025-11-03 15:08:48 +01:00
edde746 bae7b70feb docs: mobile store links 2025-11-02 07:21:41 +01:00
183 changed files with 40870 additions and 5081 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: What went wrong?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Tap on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what should have happened
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened
validations:
required: true
- type: input
id: plezy-version
attributes:
label: Plezy Version
description: Check the app settings for the version number and paste it here
placeholder: e.g., 1.7.0
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which platform(s) are affected?
multiple: true
options:
- Android
- iOS
- Web
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: device
attributes:
label: Device Information
description: What device are you using?
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
- type: textarea
id: logs
attributes:
label: Logs and Stack Traces
description: Please paste any relevant logs or stack traces
render: shell
placeholder: Paste logs here
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem
placeholder: Drag and drop screenshots here
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context about the problem here
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions and Discussions
url: https://github.com/edde746/plezy/discussions
about: Ask questions and discuss ideas with the community
@@ -0,0 +1,65 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new feature! Please provide as much detail as possible.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: I'm frustrated when... / It would be helpful if...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see
placeholder: What feature would solve this problem?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or features?
placeholder: Describe alternatives you've considered
- type: textarea
id: use-case
attributes:
label: Use Case
description: Describe how you would use this feature
placeholder: |
1. As a user, I would...
2. This would help me...
3. The benefit would be...
- type: dropdown
id: platform
attributes:
label: Target Platform
description: Which platform(s) should this feature target?
multiple: true
options:
- Android
- iOS
- Web
- macOS
- Windows
- Linux
- All platforms
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request
placeholder: Add mockups, links to similar features, or any other helpful context
+106 -12
View File
@@ -2,9 +2,6 @@ name: Build Flutter App
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-android:
@@ -50,8 +47,22 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Configure Android signing
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Build APK
run: flutter build apk --release
run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Clean up keystore
if: always()
run: rm -f android/app/upload-keystore.jks android/key.properties
- name: Rename APK
run: |
@@ -69,7 +80,7 @@ jobs:
path: plezy-android.apk
build-ios:
runs-on: macos-latest
runs-on: macos-26
permissions:
id-token: write
attestations: write
@@ -107,7 +118,7 @@ jobs:
run: flutter pub get
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true
- name: Create IPA
run: |
@@ -127,7 +138,7 @@ jobs:
path: plezy-ios.ipa
build-macos:
runs-on: macos-latest
runs-on: macos-26
permissions:
id-token: write
attestations: write
@@ -165,12 +176,88 @@ jobs:
run: flutter pub get
- name: Build macOS
run: flutter build macos --release
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Import Code Signing Certificate
env:
MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate to keychain
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Add keychain to search list
security list-keychains -d user -s $KEYCHAIN_PATH login.keychain
- name: Sign Application
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Find the identity
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '".*"' | tr -d '"')
echo "Signing with identity: $IDENTITY"
APP_PATH="build/macos/Build/Products/Release/plezy.app"
# Sign all frameworks and dylibs first (inside-out signing)
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -o -name "*.dylib" | while read framework; do
echo "Signing: $framework"
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$framework"
done
# Sign the app bundle itself
echo "Signing app bundle: $APP_PATH"
codesign --force --sign "$IDENTITY" --timestamp --options runtime --entitlements macos/Runner/Release.entitlements "$APP_PATH"
- name: Verify Signature
run: |
APP_PATH="build/macos/Build/Products/Release/plezy.app"
echo "Verifying signature..."
codesign -dvvv "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
- name: Notarize Application
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
APP_PATH="build/macos/Build/Products/Release/plezy.app"
# Create a temporary ZIP for notarization submission
echo "Creating ZIP for notarization..."
ditto -c -k --keepParent "$APP_PATH" notarization.zip
# Submit for notarization
echo "Submitting to Apple's notary service..."
xcrun notarytool submit notarization.zip \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
# Staple the notarization ticket to the app
echo "Stapling notarization ticket..."
xcrun stapler staple "$APP_PATH"
# Verify stapling
echo "Verifying notarization..."
xcrun stapler validate "$APP_PATH"
- name: Create ZIP
run: |
cd build/macos/Build/Products/Release
zip -r $GITHUB_WORKSPACE/plezy-macos.zip plezy.app
ditto -c -k --sequesterRsrc --keepParent plezy.app $GITHUB_WORKSPACE/plezy-macos.zip
- name: Attest macOS ZIP
uses: actions/attest-build-provenance@v2
@@ -211,7 +298,7 @@ jobs:
run: flutter pub get
- name: Build Windows
run: flutter build windows --release
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Build Windows Installer
run: .\windows\build-installer.ps1
@@ -274,7 +361,7 @@ jobs:
run: flutter pub get
- name: Build Linux
run: flutter build linux --release
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Create Archive
run: |
@@ -293,7 +380,14 @@ jobs:
path: plezy-linux.tar.gz
create-release:
needs: [build-android, build-ios, build-macos, build-windows, build-linux]
needs:
[
build-android,
build-ios,
build-macos,
build-windows,
build-linux,
]
runs-on: ubuntu-latest
permissions:
contents: write
+167
View File
@@ -0,0 +1,167 @@
name: CI - Sanity Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
analyze:
name: Code Analysis
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- 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-
- name: Install dependencies
run: |
flutter pub get
- name: Verify formatting
run: |
# Find all Dart files excluding generated files
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do
files_found=true
break
done
if [ "$files_found" != "true" ]; then
echo "No Dart files found to format"
exit 0
fi
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
- name: Analyze code
run: |
# Run flutter analyze
# Fails on any errors or warnings (info messages are allowed)
flutter analyze 2>&1 | tee analyze_output.txt
# Check the exit status
if [ ${PIPESTATUS[0]} -ne 0 ]; then
echo "❌ Analysis failed"
exit 1
fi
# Also verify no warnings or errors in output
if grep -q "error •" analyze_output.txt; then
echo "❌ Analysis failed with errors"
exit 1
elif grep -q "warning •" analyze_output.txt; then
echo "⚠️ Analysis completed with warnings"
exit 1
else
echo "✅ Analysis passed - no issues found!"
exit 0
fi
- name: Check for unused code
run: |
echo "🔍 Checking for unused code..."
dart run dart_code_linter:metrics check-unused-code lib 2>&1 | tee unused_code.txt
if grep -qi "no unused code found" unused_code.txt; then
echo "✅ No unused code found"
else
echo "❌ Found unused code:"
cat unused_code.txt
exit 1
fi
- name: Check for unused files
run: |
echo "🔍 Checking for unused files..."
dart run dart_code_linter:metrics check-unused-files lib 2>&1 | tee unused_files.txt
if grep -qi "no unused files found" unused_files.txt; then
echo "✅ No unused files found"
else
echo "❌ Found unused files:"
cat unused_files.txt
exit 1
fi
test:
name: Unit Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- 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-
- name: Install dependencies
run: |
flutter clean
flutter pub get
- name: Run tests
run: |
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
flutter test
else
echo "No tests found, skipping test execution"
fi
dependency-check:
name: Dependency Validation
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- 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-
- name: Verify dependencies
run: |
flutter clean
flutter pub get
flutter pub outdated
+82
View File
@@ -0,0 +1,82 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version number (e.g., 1.9.0)'
required: true
type: string
permissions:
contents: write
actions: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate version format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: Version must be in semantic versioning format (e.g., 1.9.0)"
exit 1
fi
- name: Extract current build number
id: get_build
run: |
# Extract current build number from pubspec.yaml
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //')
CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2)
NEW_BUILD=$((CURRENT_BUILD + 1))
echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT
echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT
echo "Current build: $CURRENT_BUILD"
echo "New build: $NEW_BUILD"
- name: Update pubspec.yaml
run: |
NEW_VERSION="${{ inputs.version }}+${{ steps.get_build.outputs.new_build }}"
sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml
echo "Updated version to: $NEW_VERSION"
cat pubspec.yaml | grep '^version:'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Commit version bump
run: |
git add pubspec.yaml
git commit -m "chore: bump version to ${{ inputs.version }}"
- name: Create Git tag
run: |
git tag "${{ inputs.version }}"
echo "Created tag: ${{ inputs.version }}"
- name: Push changes and tag
run: |
git push origin main
git push origin --tags
- name: Trigger build workflow
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build.yml/dispatches \
-d '{"ref":"main"}'
echo "Build workflow triggered successfully"
+69
View File
@@ -0,0 +1,69 @@
# Contributing
## Getting Started
1. Fork and clone the repository
2. Run `flutter pub get` to install dependencies
3. Run `dart run build_runner build` to generate code
4. Start developing!
## Development
- Follow Dart/Flutter conventions
- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks)
- Run `flutter analyze` before submitting to check for issues
- Run `flutter test` if tests are available
- Test your changes thoroughly
### Code Quality Checks
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
- Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`)
- Generated files are reformatted automatically by build tools
2. **Static Analysis**: Checks for code issues and potential bugs
- Run locally: `flutter analyze`
- Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`)
3. **Tests**: Runs unit and widget tests (when available)
- Run locally: `flutter test`
All these checks must pass before your changes can be merged.
## Internationalization (i18n)
This project uses `slang` for internationalization with JSON files.
### Adding New Strings
1. Add your string to `lib/i18n/strings.i18n.json`:
```json
{
"section": {
"myNewString": "My new text"
}
}
```
2. Run `dart run slang` to regenerate translation files
3. Use in your code:
```dart
Text(t.section.myNewString)
```
### Adding New Languages
1. Create new JSON file: `lib/i18n/strings_[locale].i18n.json`
2. Copy structure from `strings.i18n.json` and translate values
3. Run `dart run slang` to regenerate files
### Guidelines
- Organize strings logically in nested objects
- Use camelCase for keys
- Keep strings concise and clear
- Always run `dart run slang` after changes
+23 -1
View File
@@ -14,7 +14,10 @@ Plezy is a modern Plex media client that provides a seamless streaming experienc
## Download
### Mobile
Coming soon
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
> Google Play version is in closed testing ([required by Google](https://support.google.com/googleplay/android-developer/answer/14151465#overview)). Join the [Google Group](https://groups.google.com/g/plezy-testers-2) to get access.
### Desktop
- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
@@ -74,6 +77,25 @@ flutter run
## Development
### Code Quality
Before submitting changes, ensure your code passes all quality checks:
```bash
# Format code (including generated files)
dart format .
# Analyze code for issues
flutter analyze
# Run tests (if available)
flutter test
```
**Note**: CI checks exclude generated files (`.g.dart`, `.freezed.dart`) from formatting and analysis checks. You can run `dart format .` locally to format everything, but only your hand-written code will be validated in CI.
These checks are automatically run in CI for all pull requests.
### Code Generation
The project uses code generation for JSON serialization. After modifying model classes, run:
+11
View File
@@ -1 +1,12 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
plugins:
- dart_code_linter
dart_code_linter:
rules:
- avoid-unused-parameters
+4
View File
@@ -3,6 +3,10 @@
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Media session permissions for OS media controls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<!-- Explicitly remove media permissions that media_kit may add -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,5 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@mipmap/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1a1a1a</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+14 -2
View File
@@ -1,9 +1,13 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -23,9 +27,11 @@ PODS:
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -35,12 +41,16 @@ DEPENDENCIES:
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -57,12 +67,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
+13
View File
@@ -1,5 +1,6 @@
import Flutter
import UIKit
import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,18 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// Configure audio session for media playback
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
application.beginReceivingRemoteControlEvents()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -9,6 +9,7 @@ class PlexConfig {
final String platform;
final String? device;
final bool acceptJson;
final String? machineIdentifier;
PlexConfig({
required this.baseUrl,
@@ -19,6 +20,7 @@ class PlexConfig {
this.platform = 'Flutter',
this.device,
this.acceptJson = true,
this.machineIdentifier,
});
static Future<PlexConfig> create({
@@ -29,6 +31,7 @@ class PlexConfig {
String? platform,
String? device,
bool acceptJson = true,
String? machineIdentifier,
}) async {
final packageInfo = await PackageInfo.fromPlatform();
return PlexConfig(
@@ -40,6 +43,7 @@ class PlexConfig {
platform: platform ?? 'Flutter',
device: device,
acceptJson: acceptJson,
machineIdentifier: machineIdentifier,
);
}
@@ -51,6 +55,7 @@ class PlexConfig {
'X-Plex-Platform': platform,
if (device != null) 'X-Plex-Device': device!,
if (acceptJson) 'Accept': 'application/json',
'Accept-Charset': 'utf-8',
};
if (token != null) {
@@ -69,6 +74,7 @@ class PlexConfig {
String? platform,
String? device,
bool? acceptJson,
String? machineIdentifier,
}) {
return PlexConfig(
baseUrl: baseUrl ?? this.baseUrl,
@@ -79,6 +85,7 @@ class PlexConfig {
platform: platform ?? this.platform,
device: device ?? this.device,
acceptJson: acceptJson ?? this.acceptJson,
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Layout and sizing constants used throughout the application
/// Screen width breakpoints for responsive design
class ScreenBreakpoints {
/// Breakpoint for tablet devices (600px)
static const double tablet = 600;
/// Breakpoint for desktop devices (1200px)
static const double desktop = 1200;
/// Breakpoint for large desktop devices (1600px)
static const double largeDesktop = 1600;
}
/// Grid layout constants
class GridLayoutConstants {
/// Maximum cross-axis extent for grid items in comfortable density mode
static const double comfortableDesktop = 280;
static const double comfortableTablet = 240;
static const double comfortableMobile = 200;
/// Maximum cross-axis extent for grid items in compact density mode
static const double compactDesktop = 200;
static const double compactTablet = 170;
static const double compactMobile = 140;
/// Maximum cross-axis extent for grid items in normal density mode
static const double normalDesktop = 240;
static const double normalTablet = 200;
static const double normalMobile = 170;
/// Default aspect ratio for media cards (poster)
static const double posterAspectRatio = 2 / 3.3;
/// Grid spacing
static const double crossAxisSpacing = 0;
static const double mainAxisSpacing = 0;
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
{
"app": {
"title": "Plezy",
"loading": "Loading..."
},
"auth": {
"signInWithPlex": "Sign in with Plex",
"showQRCode": "Show QR Code",
"cancel": "Cancel",
"authenticate": "Authenticate",
"retry": "Retry",
"debugEnterToken": "Debug: Enter Plex Token",
"plexTokenLabel": "Plex Auth Token",
"plexTokenHint": "Enter your Plex.tv token",
"authenticationTimeout": "Authentication timed out. Please try again.",
"scanQRCodeInstruction": "Scan this QR code with a device logged into Plex to authenticate.",
"waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser."
},
"common": {
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"clear": "Clear",
"reset": "Reset",
"later": "Later",
"submit": "Submit",
"confirm": "Confirm",
"retry": "Retry",
"playNow": "Play Now",
"logout": "Logout",
"online": "Online",
"offline": "Offline",
"owned": "Owned",
"shared": "Shared",
"current": "CURRENT",
"unknown": "Unknown",
"refresh": "Refresh",
"yes": "Yes",
"no": "No",
"server": "Server",
"delete": "Delete",
"shuffle": "Shuffle",
"addTo": "Add to..."
},
"screens": {
"licenses": "Licenses",
"switchProfile": "Switch Profile",
"subtitleStyling": "Subtitle Styling",
"search": "Search",
"logs": "Logs"
},
"update": {
"available": "Update Available",
"versionAvailable": "Version ${version} is available",
"currentVersion": "Current: ${version}",
"skipVersion": "Skip This Version",
"viewRelease": "View Release",
"latestVersion": "You are on the latest version",
"checkFailed": "Failed to check for updates"
},
"settings": {
"title": "Settings",
"language": "Language",
"theme": "Theme",
"appearance": "Appearance",
"videoPlayback": "Video Playback",
"shufflePlay": "Shuffle Play",
"advanced": "Advanced",
"useSeasonPostersDescription": "Show season poster instead of series poster for episodes",
"showHeroSectionDescription": "Display featured content carousel on home screen",
"secondsLabel": "Seconds",
"minutesLabel": "Minutes",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Enter duration (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Follow system settings",
"lightTheme": "Light",
"darkTheme": "Dark",
"libraryDensity": "Library Density",
"compact": "Compact",
"compactDescription": "Smaller cards, more items visible",
"normal": "Normal",
"normalDescription": "Default size",
"comfortable": "Comfortable",
"comfortableDescription": "Larger cards, fewer items visible",
"viewMode": "View Mode",
"gridView": "Grid",
"gridViewDescription": "Display items in a grid layout",
"listView": "List",
"listViewDescription": "Display items in a list layout",
"useSeasonPosters": "Use Season Posters",
"showHeroSection": "Show Hero Section",
"hardwareDecoding": "Hardware Decoding",
"hardwareDecodingDescription": "Use hardware acceleration when available",
"bufferSize": "Buffer Size",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Subtitle Styling",
"subtitleStylingDescription": "Customize subtitle appearance",
"smallSkipDuration": "Small Skip Duration",
"largeSkipDuration": "Large Skip Duration",
"secondsUnit": "${seconds} seconds",
"defaultSleepTimer": "Default Sleep Timer",
"minutesUnit": "${minutes} minutes",
"rememberTrackSelections": "Remember track selections per show/movie",
"rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback",
"unwatchedOnly": "Unwatched Only",
"unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue",
"shuffleOrderNavigation": "Shuffle Order Navigation",
"shuffleOrderNavigationDescription": "Next/previous buttons follow shuffled order",
"loopShuffleQueue": "Loop Shuffle Queue",
"loopShuffleQueueDescription": "Restart queue when reaching the end",
"videoPlayerControls": "Video Player Controls",
"keyboardShortcuts": "Keyboard Shortcuts",
"keyboardShortcutsDescription": "Customize keyboard shortcuts",
"debugLogging": "Debug Logging",
"debugLoggingDescription": "Enable detailed logging for troubleshooting",
"viewLogs": "View Logs",
"viewLogsDescription": "View application logs",
"clearCache": "Clear Cache",
"clearCacheDescription": "This will clear all cached images and data. The app may take longer to load content after clearing the cache.",
"clearCacheSuccess": "Cache cleared successfully",
"resetSettings": "Reset Settings",
"resetSettingsDescription": "This will reset all settings to their default values. This action cannot be undone.",
"resetSettingsSuccess": "Settings reset successfully",
"shortcutsReset": "Shortcuts reset to defaults",
"about": "About",
"aboutDescription": "App information and licenses",
"updates": "Updates",
"updateAvailable": "Update Available",
"checkForUpdates": "Check for Updates",
"validationErrorEnterNumber": "Please enter a valid number",
"validationErrorDuration": "Duration must be between ${min} and ${max} ${unit}",
"shortcutAlreadyAssigned": "Shortcut already assigned to ${action}",
"shortcutUpdated": "Shortcut updated for ${action}"
},
"search": {
"hint": "Search movies, shows, music...",
"tryDifferentTerm": "Try a different search term",
"searchYourMedia": "Search your media",
"enterTitleActorOrKeyword": "Enter a title, actor, or keyword"
},
"hotkeys": {
"setShortcutFor": "Set Shortcut for ${actionName}",
"clearShortcut": "Clear shortcut"
},
"pinEntry": {
"enterPin": "Enter PIN",
"showPin": "Show PIN",
"hidePin": "Hide PIN"
},
"fileInfo": {
"title": "File Info",
"video": "Video",
"audio": "Audio",
"file": "File",
"advanced": "Advanced",
"codec": "Codec",
"resolution": "Resolution",
"bitrate": "Bitrate",
"frameRate": "Frame Rate",
"aspectRatio": "Aspect Ratio",
"profile": "Profile",
"bitDepth": "Bit Depth",
"colorSpace": "Color Space",
"colorRange": "Color Range",
"colorPrimaries": "Color Primaries",
"chromaSubsampling": "Chroma Subsampling",
"channels": "Channels",
"path": "Path",
"size": "Size",
"container": "Container",
"duration": "Duration",
"optimizedForStreaming": "Optimized for Streaming",
"has64bitOffsets": "64-bit Offsets"
},
"mediaMenu": {
"markAsWatched": "Mark as Watched",
"markAsUnwatched": "Mark as Unwatched",
"removeFromContinueWatching": "Remove from Continue Watching",
"goToSeries": "Go to series",
"goToSeason": "Go to season",
"shufflePlay": "Shuffle Play",
"fileInfo": "File Info"
},
"accessibility": {
"mediaCardMovie": "${title}, movie",
"mediaCardShow": "${title}, TV show",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "watched",
"mediaCardPartiallyWatched": "${percent} percent watched",
"mediaCardUnwatched": "unwatched",
"tapToPlay": "Tap to play"
},
"tooltips": {
"shufflePlay": "Shuffle play",
"markAsWatched": "Mark as watched",
"markAsUnwatched": "Mark as unwatched"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Subtitles",
"resetToZero": "Reset to 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} plays later",
"playsEarlier": "${label} plays earlier",
"noOffset": "No offset",
"letterbox": "Letterbox",
"fillScreen": "Fill screen",
"stretch": "Stretch",
"lockRotation": "Lock rotation",
"unlockRotation": "Unlock rotation",
"sleepTimer": "Sleep Timer",
"timerActive": "Timer Active",
"playbackWillPauseIn": "Playback will pause in ${duration}",
"sleepTimerCompleted": "Sleep timer completed - playback paused",
"playButton": "Play",
"pauseButton": "Pause",
"seekBackwardButton": "Seek backward ${seconds} seconds",
"seekForwardButton": "Seek forward ${seconds} seconds",
"previousButton": "Previous episode",
"nextButton": "Next episode",
"previousChapterButton": "Previous chapter",
"nextChapterButton": "Next chapter",
"volumeButton": "Volume",
"muteButton": "Mute",
"unmuteButton": "Unmute",
"settingsButton": "Video settings",
"audioTrackButton": "Audio tracks",
"subtitlesButton": "Subtitles",
"chaptersButton": "Chapters",
"versionsButton": "Video versions",
"aspectRatioButton": "Aspect ratio",
"fullscreenButton": "Enter fullscreen",
"exitFullscreenButton": "Exit fullscreen",
"rotationLockButton": "Rotation lock",
"timelineSlider": "Video timeline",
"volumeSlider": "Volume level",
"backButton": "Back"
},
"userStatus": {
"admin": "Admin",
"restricted": "Restricted",
"protected": "Protected",
"current": "CURRENT"
},
"messages": {
"markedAsWatched": "Marked as watched",
"markedAsUnwatched": "Marked as unwatched",
"removedFromContinueWatching": "Removed from Continue Watching",
"errorLoading": "Error: ${error}",
"fileInfoNotAvailable": "File information not available",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"errorLoadingSeason": "Error loading season",
"musicNotSupported": "Music playback is not yet supported",
"logsCleared": "Logs cleared",
"logsCopied": "Logs copied to clipboard",
"noLogsAvailable": "No logs available",
"libraryScanning": "Scanning \"${title}\"...",
"libraryScanStarted": "Library scan started for \"${title}\"",
"libraryScanFailed": "Failed to scan library: ${error}",
"metadataRefreshing": "Refreshing metadata for \"${title}\"...",
"metadataRefreshStarted": "Metadata refresh started for \"${title}\"",
"metadataRefreshFailed": "Failed to refresh metadata: ${error}",
"noPlexToken": "No Plex token found. Please login again.",
"logoutConfirm": "Are you sure you want to logout?",
"noSeasonsFound": "No seasons found",
"noEpisodesFound": "No episodes found in first season",
"noEpisodesFoundGeneral": "No episodes found",
"noResultsFound": "No results found",
"sleepTimerSet": "Sleep timer set for ${label}",
"failedToSwitchProfile": "Failed to switch to ${displayName}",
"noItemsAvailable": "No items available",
"failedToCreatePlayQueue": "Failed to create play queue",
"failedToCreatePlayQueueNoItems": "Failed to create play queue - no items",
"failedPlayback": "Failed to ${action}: ${error}"
},
"profile": {
"noUsersAvailable": "No users available"
},
"subtitlingStyling": {
"stylingOptions": "Styling Options",
"fontSize": "Font Size",
"textColor": "Text Color",
"borderSize": "Border Size",
"borderColor": "Border Color",
"backgroundOpacity": "Background Opacity",
"backgroundColor": "Background Color"
},
"dialog": {
"confirmAction": "Confirm Action",
"areYouSure": "Are you sure you want to perform this action?",
"cancel": "Cancel",
"playNow": "Play Now"
},
"discover": {
"title": "Discover",
"switchProfile": "Switch Profile",
"logout": "Logout",
"noContentAvailable": "No content available",
"addMediaToLibraries": "Add some media to your libraries",
"continueWatching": "Continue Watching",
"recentlyAdded": "Recently Added",
"play": "Play",
"resume": "Resume",
"playEpisode": "Play S${season}, E${episode}",
"resumeEpisode": "Resume S${season}, E${episode}",
"pause": "Pause",
"overview": "Overview",
"cast": "Cast",
"seasons": "Seasons",
"studio": "Studio",
"rating": "Rating",
"watched": "Watched",
"episodeCount": "${count} episodes",
"watchedProgress": "${watched}/${total} watched",
"movie": "Movie",
"tvShow": "TV Show",
"minutesLeft": "${minutes} min left"
},
"errors": {
"searchFailed": "Search failed: ${error}",
"connectionTimeout": "Connection timeout while loading ${context}",
"connectionFailed": "Unable to connect to Plex server",
"failedToLoad": "Failed to load ${context}: ${error}",
"noClientAvailable": "No client available",
"authenticationFailed": "Authentication failed: ${error}",
"couldNotLaunchUrl": "Could not launch auth URL",
"pleaseEnterToken": "Please enter a token",
"invalidToken": "Invalid token",
"failedToVerifyToken": "Failed to verify token: ${error}",
"failedToSwitchProfile": "Failed to switch to ${displayName}",
"connectionFailedGeneric": "Connection failed"
},
"libraries": {
"title": "Libraries",
"scanLibraryFiles": "Scan Library Files",
"scanLibrary": "Scan Library",
"analyze": "Analyze",
"analyzeLibrary": "Analyze Library",
"refreshMetadata": "Refresh Metadata",
"emptyTrash": "Empty Trash",
"emptyingTrash": "Emptying trash for \"${title}\"...",
"trashEmptied": "Trash emptied for \"${title}\"",
"failedToEmptyTrash": "Failed to empty trash: ${error}",
"analyzing": "Analyzing \"${title}\"...",
"analysisStarted": "Analysis started for \"${title}\"",
"failedToAnalyze": "Failed to analyze library: ${error}",
"noLibrariesFound": "No libraries found",
"thisLibraryIsEmpty": "This library is empty",
"all": "All",
"clearAll": "Clear All",
"scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?",
"analyzeLibraryConfirm": "Are you sure you want to analyze \"${title}\"?",
"refreshMetadataConfirm": "Are you sure you want to refresh metadata for \"${title}\"?",
"emptyTrashConfirm": "Are you sure you want to empty trash for \"${title}\"?",
"manageLibraries": "Manage Libraries",
"sort": "Sort",
"sortBy": "Sort By",
"filters": "Filters",
"loadingLibraryWithCount": "Loading library... (${count} items loaded)",
"confirmActionMessage": "Are you sure you want to perform this action?",
"showLibrary": "Show library",
"hideLibrary": "Hide library",
"libraryOptions": "Library options",
"content": "library content",
"selectLibrary": "Select library",
"filtersWithCount": "Filters (${count})",
"noRecommendations": "No recommendations available",
"noCollections": "No collections in this library",
"noFoldersFound": "No folders found",
"folders": "folders",
"tabs": {
"recommended": "Recommended",
"browse": "Browse",
"collections": "Collections",
"playlists": "Playlists"
},
"groupings": {
"all": "All",
"movies": "Movies",
"shows": "TV Shows",
"seasons": "Seasons",
"episodes": "Episodes",
"folders": "Folders"
}
},
"about": {
"title": "About",
"openSourceLicenses": "Open Source Licenses",
"versionLabel": "Version ${version}",
"appDescription": "A beautiful Plex client for Flutter",
"viewLicensesDescription": "View licenses of third-party libraries"
},
"serverSelection": {
"connectingToServer": "Connecting to server...",
"connectingToAllServers": "Connecting to all servers...",
"connectToAllServers": "Connect to All Servers",
"allServerConnectionsFailed": "Failed to connect to any servers. Please check your network and try again.",
"foundServers": "Found ${count} server(s)",
"orSelectIndividually": "Or select a server individually:",
"serverDebugCopied": "Server debug data copied to clipboard",
"copyDebugData": "Copy Debug Data",
"noServersFound": "No servers found",
"malformedServerData": "Found ${count} server(s) with malformed data. No valid servers available.",
"incompleteServerInfo": "Some servers have incomplete information and were skipped. Please check your Plex.tv account.",
"incompleteConnectionInfo": "Server connection information is incomplete. Please try again.",
"malformedServerInfo": "Server information is malformed: ${message}",
"networkConnectionFailed": "Network connection failed. Please check your internet connection and try again.",
"authenticationFailed": "Authentication failed. Please sign in again.",
"plexServiceUnavailable": "Plex service unavailable. Please try again later.",
"failedToLoadServers": "Failed to load servers: ${error}"
},
"hubDetail": {
"title": "Title",
"releaseYear": "Release Year",
"dateAdded": "Date Added",
"rating": "Rating",
"noItemsFound": "No items found"
},
"logs": {
"title": "Logs",
"clearLogs": "Clear Logs",
"copyLogs": "Copy Logs",
"exportLogs": "Export Logs",
"noLogsToShow": "No logs to show",
"error": "Error:",
"stackTrace": "Stack Trace:"
},
"licenses": {
"relatedPackages": "Related Packages",
"license": "License",
"licenseNumber": "License ${number}",
"licensesCount": "${count} licenses"
},
"navigation": {
"home": "Home",
"search": "Search",
"libraries": "Libraries",
"settings": "Settings"
},
"collections": {
"title": "Collections",
"collection": "Collection",
"empty": "Collection is empty",
"noItems": "No items in this collection",
"unknownLibrarySection": "Cannot delete: Unknown library section",
"deleteCollection": "Delete Collection",
"deleteConfirm": "Are you sure you want to delete \"${title}\"? This action cannot be undone.",
"deleted": "Collection deleted",
"deleteFailed": "Failed to delete collection",
"deleteFailedWithError": "Failed to delete collection: ${error}",
"failedToLoadItems": "Failed to load collection items: ${error}",
"addTo": "Add to collection",
"selectCollection": "Select Collection",
"createNewCollection": "Create New Collection",
"collectionName": "Collection Name",
"enterCollectionName": "Enter collection name",
"addedToCollection": "Added to collection",
"errorAddingToCollection": "Failed to add to collection",
"created": "Collection created",
"removeFromCollection": "Remove from collection",
"removeFromCollectionConfirm": "Remove \"${title}\" from this collection?",
"removedFromCollection": "Removed from collection",
"removeFromCollectionFailed": "Failed to remove from collection",
"removeFromCollectionError": "Error removing from collection: ${error}"
},
"playlists": {
"title": "Playlists",
"playlist": "Playlist",
"noPlaylists": "No playlists found",
"create": "Create Playlist",
"newPlaylist": "New Playlist",
"playlistName": "Playlist Name",
"enterPlaylistName": "Enter playlist name",
"edit": "Edit Playlist",
"delete": "Delete Playlist",
"addTo": "Add to Playlist",
"addItems": "Add Items",
"removeItem": "Remove from Playlist",
"clearPlaylist": "Clear Playlist",
"playAll": "Play All",
"shuffle": "Shuffle",
"smartPlaylist": "Smart Playlist",
"regularPlaylist": "Regular Playlist",
"itemCount": "${count} items",
"oneItem": "1 item",
"emptyPlaylist": "This playlist is empty",
"deleteConfirm": "Delete Playlist?",
"deleteMessage": "Are you sure you want to delete \"${name}\"?",
"created": "Playlist created",
"updated": "Playlist updated",
"deleted": "Playlist deleted",
"itemAdded": "Added to playlist",
"itemRemoved": "Removed from playlist",
"selectPlaylist": "Select Playlist",
"createNewPlaylist": "Create New Playlist",
"errorCreating": "Failed to create playlist",
"errorDeleting": "Failed to delete playlist",
"errorLoading": "Failed to load playlists",
"errorAdding": "Failed to add to playlist",
"errorReordering": "Failed to reorder playlist item",
"errorRemoving": "Failed to remove from playlist"
}
}
+510
View File
@@ -0,0 +1,510 @@
{
"app": {
"title": "Plezy",
"loading": "Lädt..."
},
"auth": {
"signInWithPlex": "Mit Plex anmelden",
"showQRCode": "QR-Code anzeigen",
"cancel": "Abbrechen",
"authenticate": "Authentifizieren",
"retry": "Erneut versuchen",
"debugEnterToken": "Debug: Plex-Token eingeben",
"plexTokenLabel": "Plex-Auth-Token",
"plexTokenHint": "Plex.tv-Token eingeben",
"authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.",
"scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.",
"waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen."
},
"common": {
"cancel": "Abbrechen",
"save": "Speichern",
"close": "Schließen",
"clear": "Leeren",
"reset": "Zurücksetzen",
"later": "Später",
"submit": "Senden",
"confirm": "Bestätigen",
"retry": "Erneut versuchen",
"playNow": "Jetzt abspielen",
"logout": "Abmelden",
"online": "Online",
"offline": "Offline",
"owned": "Besitzer",
"shared": "Geteilt",
"current": "AKTUELL",
"unknown": "Unbekannt",
"refresh": "Aktualisieren",
"yes": "Ja",
"no": "Nein",
"server": "Server",
"delete": "Löschen",
"shuffle": "Zufall",
"addTo": "Hinzufügen zu..."
},
"screens": {
"licenses": "Lizenzen",
"selectServer": "Server auswählen",
"switchProfile": "Profil wechseln",
"subtitleStyling": "Untertitel-Stil",
"search": "Suche",
"logs": "Protokolle"
},
"update": {
"available": "Update verfügbar",
"versionAvailable": "Version ${version} ist verfügbar",
"currentVersion": "Aktuell: ${version}",
"skipVersion": "Diese Version überspringen",
"viewRelease": "Release anzeigen",
"latestVersion": "Aktuellste Version installiert",
"checkFailed": "Fehler bei der Updateprüfung"
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"theme": "Design",
"appearance": "Darstellung",
"videoPlayback": "Videowiedergabe",
"shufflePlay": "Zufallswiedergabe",
"advanced": "Erweitert",
"useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen",
"showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen",
"secondsLabel": "Sekunden",
"minutesLabel": "Minuten",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Dauer eingeben (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Systemeinstellungen folgen",
"lightTheme": "Hell",
"darkTheme": "Dunkel",
"libraryDensity": "Mediathekdichte",
"compact": "Kompakt",
"compactDescription": "Kleinere Karten, mehr Elemente sichtbar",
"normal": "Normal",
"normalDescription": "Standardgröße",
"comfortable": "Großzügig",
"comfortableDescription": "Größere Karten, weniger Elemente sichtbar",
"viewMode": "Ansichtsmodus",
"gridView": "Raster",
"gridViewDescription": "Elemente im Raster anzeigen",
"listView": "Liste",
"listViewDescription": "Elemente in Listenansicht anzeigen",
"useSeasonPosters": "Staffelposter verwenden",
"showHeroSection": "Hero-Bereich anzeigen",
"hardwareDecoding": "Hardware-Decodierung",
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
"bufferSize": "Puffergröße",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Untertitel-Stil",
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
"smallSkipDuration": "Kleine Sprungdauer",
"largeSkipDuration": "Große Sprungdauer",
"secondsUnit": "${seconds} Sekunden",
"defaultSleepTimer": "Standard-Sleep-Timer",
"minutesUnit": "${minutes} Minuten",
"rememberTrackSelections": "Spurauswahl pro Serie/Film merken",
"rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert",
"unwatchedOnly": "Nur ungesehene",
"unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen",
"shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge",
"shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge",
"loopShuffleQueue": "Shuffle-Warteschlange wiederholen",
"loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist",
"videoPlayerControls": "Videoplayer-Steuerung",
"keyboardShortcuts": "Tastenkürzel",
"keyboardShortcutsDescription": "Tastenkürzel anpassen",
"debugLogging": "Debug-Protokollierung",
"debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren",
"viewLogs": "Protokolle anzeigen",
"viewLogsDescription": "App-Protokolle anzeigen",
"clearCache": "Cache löschen",
"clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.",
"clearCacheSuccess": "Cache erfolgreich gelöscht",
"resetSettings": "Einstellungen zurücksetzen",
"resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.",
"resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt",
"shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt",
"about": "Über",
"aboutDescription": "App-Informationen und Lizenzen",
"updates": "Updates",
"updateAvailable": "Update verfügbar",
"checkForUpdates": "Nach Updates suchen",
"validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben",
"validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen",
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}"
},
"search": {
"hint": "Filme, Serien, Musik suchen...",
"tryDifferentTerm": "Anderen Suchbegriff versuchen",
"searchYourMedia": "In den eigenen Medien suchen",
"enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben"
},
"hotkeys": {
"setShortcutFor": "Tastenkürzel festlegen für ${actionName}",
"clearShortcut": "Kürzel löschen"
},
"pinEntry": {
"enterPin": "PIN eingeben",
"showPin": "PIN anzeigen",
"hidePin": "PIN verbergen"
},
"fileInfo": {
"title": "Dateiinfo",
"video": "Video",
"audio": "Audio",
"file": "Datei",
"advanced": "Erweitert",
"codec": "Codec",
"resolution": "Auflösung",
"bitrate": "Bitrate",
"frameRate": "Bildrate",
"aspectRatio": "Seitenverhältnis",
"profile": "Profil",
"bitDepth": "Farbtiefe",
"colorSpace": "Farbraum",
"colorRange": "Farbbereich",
"colorPrimaries": "Primärfarben",
"chromaSubsampling": "Chroma-Subsampling",
"channels": "Kanäle",
"path": "Pfad",
"size": "Größe",
"container": "Container",
"duration": "Dauer",
"optimizedForStreaming": "Für Streaming optimiert",
"has64bitOffsets": "64-Bit-Offsets"
},
"mediaMenu": {
"markAsWatched": "Als gesehen markieren",
"markAsUnwatched": "Als ungesehen markieren",
"removeFromContinueWatching": "Aus Weiterschauen entfernen",
"goToSeries": "Zur Serie",
"goToSeason": "Zur Staffel",
"shufflePlay": "Zufallswiedergabe",
"fileInfo": "Dateiinfo"
},
"accessibility": {
"mediaCardMovie": "${title}, Film",
"mediaCardShow": "${title}, Serie",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "angesehen",
"mediaCardPartiallyWatched": "${percent} Prozent angesehen",
"mediaCardUnwatched": "ungeschaut",
"tapToPlay": "Zum Abspielen tippen"
},
"tooltips": {
"shufflePlay": "Zufallswiedergabe",
"markAsWatched": "Als gesehen markieren",
"markAsUnwatched": "Als ungesehen markieren"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Untertitel",
"resetToZero": "Auf 0 ms zurücksetzen",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} spielt später",
"playsEarlier": "${label} spielt früher",
"noOffset": "Kein Offset",
"letterbox": "Letterbox",
"fillScreen": "Bild füllen",
"stretch": "Strecken",
"lockRotation": "Rotation sperren",
"unlockRotation": "Rotation entsperren",
"sleepTimer": "Schlaftimer",
"timerActive": "Schlaftimer aktiv",
"playbackWillPauseIn": "Wiedergabe wird in ${duration} pausiert",
"sleepTimerCompleted": "Schlaftimer abgelaufen Wiedergabe pausiert",
"playButton": "Wiedergeben",
"pauseButton": "Pause",
"seekBackwardButton": "${seconds} Sekunden zurück",
"seekForwardButton": "${seconds} Sekunden vor",
"previousButton": "Vorherige Episode",
"nextButton": "Nächste Episode",
"previousChapterButton": "Vorheriges Kapitel",
"nextChapterButton": "Nächstes Kapitel",
"volumeButton": "Lautstärke",
"muteButton": "Stumm schalten",
"unmuteButton": "Stummschaltung aufheben",
"settingsButton": "Videoeinstellungen",
"audioTrackButton": "Tonspuren",
"subtitlesButton": "Untertitel",
"chaptersButton": "Kapitel",
"versionsButton": "Videoversionen",
"aspectRatioButton": "Seitenverhältnis",
"fullscreenButton": "Vollbild aktivieren",
"exitFullscreenButton": "Vollbild verlassen",
"rotationLockButton": "Dreh­sperre",
"timelineSlider": "Video-Zeitleiste",
"volumeSlider": "Lautstärkepegel",
"backButton": "Zurück"
},
"userStatus": {
"admin": "Eigentümer",
"restricted": "Eingeschränkt",
"protected": "Geschützt",
"current": "AKTUELL"
},
"messages": {
"markedAsWatched": "Als gesehen markiert",
"markedAsUnwatched": "Als ungesehen markiert",
"removedFromContinueWatching": "Aus Weiterschauen entfernt",
"errorLoading": "Fehler: ${error}",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"errorLoadingSeason": "Fehler beim Laden der Staffel",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
"logsCleared": "Protokolle gelöscht",
"logsCopied": "Protokolle in Zwischenablage kopiert",
"noLogsAvailable": "Keine Protokolle verfügbar",
"libraryScanning": "Scanne „${title}“...",
"libraryScanStarted": "Mediathekscan gestartet für „${title}“",
"libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}",
"metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...",
"metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“",
"metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}",
"noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.",
"logoutConfirm": "Abmeldung wirklich durchführen?",
"noSeasonsFound": "Keine Staffeln gefunden",
"noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden",
"noEpisodesFoundGeneral": "Keine Episoden gefunden",
"noResultsFound": "Keine Ergebnisse gefunden",
"sleepTimerSet": "Sleep-Timer gesetzt auf ${label}",
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
"noItemsAvailable": "Keine Elemente verfügbar",
"failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden",
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden keine Elemente",
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}"
},
"profile": {
"noUsersAvailable": "Keine Benutzer verfügbar"
},
"subtitlingStyling": {
"stylingOptions": "Stiloptionen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"borderSize": "Rahmengröße",
"borderColor": "Rahmenfarbe",
"backgroundOpacity": "Hintergrunddeckkraft",
"backgroundColor": "Hintergrundfarbe"
},
"dialog": {
"confirmAction": "Aktion bestätigen",
"areYouSure": "Aktion wirklich ausführen?",
"cancel": "Abbrechen",
"playNow": "Jetzt abspielen"
},
"discover": {
"title": "Entdecken",
"switchProfile": "Profil wechseln",
"switchServer": "Server wechseln",
"logout": "Abmelden",
"noContentAvailable": "Kein Inhalt verfügbar",
"addMediaToLibraries": "Medien zur Mediathek hinzufügen",
"continueWatching": "Weiterschauen",
"recentlyAdded": "Kürzlich hinzugefügt",
"play": "Abspielen",
"resume": "Fortsetzen",
"playEpisode": "S${season}, E${episode} - Abspielen",
"resumeEpisode": "S${season}, E${episode} - Fortsetzen",
"pause": "Pause",
"overview": "Übersicht",
"cast": "Besetzung",
"seasons": "Staffeln",
"studio": "Studio",
"rating": "Altersfreigabe",
"watched": "Gesehen",
"episodeCount": "${count} Episoden",
"watchedProgress": "${watched} von ${total} gesehen",
"movie": "Film",
"tvShow": "Serie",
"minutesLeft": "${minutes} Min übrig"
},
"errors": {
"searchFailed": "Suche fehlgeschlagen: ${error}",
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
"connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen",
"failedToLoad": "Fehler beim Laden von ${context}: ${error}",
"noClientAvailable": "Kein Client verfügbar",
"authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}",
"couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden",
"pleaseEnterToken": "Bitte Token eingeben",
"invalidToken": "Ungültiges Token",
"failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}",
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
"connectionFailedGeneric": "Verbindung fehlgeschlagen"
},
"libraries": {
"title": "Mediatheken",
"scanLibraryFiles": "Mediatheksdateien scannen",
"scanLibrary": "Mediathek scannen",
"analyze": "Analysieren",
"analyzeLibrary": "Mediathek analysieren",
"refreshMetadata": "Metadaten aktualisieren",
"emptyTrash": "Papierkorb leeren",
"emptyingTrash": "Papierkorb für „${title}“ wird geleert...",
"trashEmptied": "Papierkorb für „${title}“ geleert",
"failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}",
"analyzing": "Analysiere „${title}“...",
"analysisStarted": "Analyse gestartet für „${title}“",
"failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}",
"noLibrariesFound": "Keine Mediatheken gefunden",
"thisLibraryIsEmpty": "Diese Mediathek ist leer",
"all": "Alle",
"clearAll": "Alle löschen",
"scanLibraryConfirm": "„${title}“ wirklich scannen?",
"analyzeLibraryConfirm": "„${title}“ wirklich analysieren?",
"refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?",
"emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?",
"manageLibraries": "Mediatheken verwalten",
"sort": "Sortieren",
"sortBy": "Sortieren nach",
"filters": "Filter",
"loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)",
"confirmActionMessage": "Aktion wirklich durchführen?",
"showLibrary": "Mediathek anzeigen",
"hideLibrary": "Mediathek ausblenden",
"libraryOptions": "Mediatheksoptionen",
"content": "Bibliotheksinhalt",
"selectLibrary": "Bibliothek auswählen",
"filtersWithCount": "Filter (${count})",
"noRecommendations": "Keine Empfehlungen verfügbar",
"noCollections": "Keine Sammlungen in dieser Mediathek",
"noFoldersFound": "Keine Ordner gefunden",
"folders": "Ordner",
"tabs": {
"recommended": "Empfohlen",
"browse": "Durchsuchen",
"collections": "Sammlungen",
"playlists": "Wiedergabelisten"
},
"groupings": {
"all": "Alle",
"movies": "Filme",
"shows": "Serien",
"seasons": "Staffeln",
"episodes": "Episoden",
"folders": "Ordner"
}
},
"about": {
"title": "Über",
"openSourceLicenses": "Open-Source-Lizenzen",
"versionLabel": "Version ${version}",
"appDescription": "Ein schöner Plex-Client für Flutter",
"viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen"
},
"serverSelection": {
"connectingToServer": "Verbinde mit Server...",
"connectingToAllServers": "Verbinde mit allen Servern...",
"connectToAllServers": "Mit allen Servern verbinden",
"allServerConnectionsFailed": "Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.",
"foundServers": "${count} Server gefunden",
"orSelectIndividually": "Oder einzelnen Server auswählen:",
"serverDebugCopied": "Server-Debugdaten kopiert",
"copyDebugData": "Debugdaten kopieren",
"noServersFound": "Keine Server gefunden",
"malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.",
"incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.",
"incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.",
"malformedServerInfo": "Serverinformationen fehlerhaft: ${message}",
"networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.",
"authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.",
"plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.",
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
},
"hubDetail": {
"title": "Titel",
"releaseYear": "Erscheinungsjahr",
"dateAdded": "Hinzugefügt am",
"rating": "Bewertung",
"noItemsFound": "Keine Elemente gefunden"
},
"logs": {
"title": "Protokolle",
"clearLogs": "Protokolle löschen",
"copyLogs": "Protokolle kopieren",
"exportLogs": "Protokolle exportieren",
"noLogsToShow": "Keine Protokolle zum Anzeigen",
"error": "Fehler:",
"stackTrace": "Stacktrace:"
},
"licenses": {
"relatedPackages": "Verwandte Pakete",
"license": "Lizenz",
"licenseNumber": "Lizenz ${number}",
"licensesCount": "${count} Lizenzen"
},
"navigation": {
"home": "Start",
"search": "Suche",
"libraries": "Mediatheken",
"settings": "Einstellungen"
},
"playlists": {
"title": "Wiedergabelisten",
"noPlaylists": "Keine Wiedergabelisten gefunden",
"create": "Wiedergabeliste erstellen",
"newPlaylist": "Neue Wiedergabeliste",
"playlistName": "Name der Wiedergabeliste",
"enterPlaylistName": "Name der Wiedergabeliste eingeben",
"edit": "Wiedergabeliste bearbeiten",
"delete": "Wiedergabeliste löschen",
"addTo": "Zur Wiedergabeliste hinzufügen",
"addItems": "Elemente hinzufügen",
"removeItem": "Aus Wiedergabeliste entfernen",
"clearPlaylist": "Wiedergabeliste leeren",
"playAll": "Alle abspielen",
"shuffle": "Zufällig",
"smartPlaylist": "Intelligente Wiedergabeliste",
"regularPlaylist": "Normale Wiedergabeliste",
"itemCount": "${count} Elemente",
"oneItem": "1 Element",
"emptyPlaylist": "Diese Wiedergabeliste ist leer",
"deleteConfirm": "Wiedergabeliste löschen?",
"deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?",
"created": "Wiedergabeliste erstellt",
"updated": "Wiedergabeliste aktualisiert",
"deleted": "Wiedergabeliste gelöscht",
"itemAdded": "Zur Wiedergabeliste hinzugefügt",
"itemRemoved": "Aus Wiedergabeliste entfernt",
"selectPlaylist": "Wiedergabeliste auswählen",
"createNewPlaylist": "Neue Wiedergabeliste erstellen",
"errorCreating": "Wiedergabeliste konnte nicht erstellt werden",
"errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden",
"errorLoading": "Wiedergabelisten konnten nicht geladen werden",
"errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden",
"errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden",
"errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden",
"playlist": "Wiedergabeliste"
},
"collections": {
"title": "Sammlungen",
"collection": "Sammlung",
"empty": "Sammlung ist leer",
"noItems": "Keine Elemente in dieser Sammlung",
"unknownLibrarySection": "Löschen nicht möglich: Unbekannte Bibliothekssektion",
"deleteCollection": "Sammlung löschen",
"deleteConfirm": "Sind Sie sicher, dass Sie \"${title}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"deleted": "Sammlung gelöscht",
"deleteFailed": "Sammlung konnte nicht gelöscht werden",
"deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}",
"failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}",
"addTo": "Zur Sammlung hinzufügen",
"selectCollection": "Sammlung auswählen",
"createNewCollection": "Neue Sammlung erstellen",
"collectionName": "Sammlungsname",
"enterCollectionName": "Sammlungsnamen eingeben",
"addedToCollection": "Zur Sammlung hinzugefügt",
"errorAddingToCollection": "Fehler beim Hinzufügen zur Sammlung",
"created": "Sammlung erstellt",
"removeFromCollection": "Aus Sammlung entfernen",
"removeFromCollectionConfirm": "\"${title}\" aus dieser Sammlung entfernen?",
"removedFromCollection": "Aus Sammlung entfernt",
"removeFromCollectionFailed": "Entfernen aus Sammlung fehlgeschlagen",
"removeFromCollectionError": "Fehler beim Entfernen aus der Sammlung: ${error}"
}
}
+510
View File
@@ -0,0 +1,510 @@
{
"app": {
"title": "Plezy",
"loading": "Caricamento..."
},
"auth": {
"signInWithPlex": "Accedi con Plex",
"showQRCode": "Mostra QR Code",
"cancel": "Cancella",
"authenticate": "Autenticazione",
"retry": "Riprova",
"debugEnterToken": "Debug: Inserisci Token Plex",
"plexTokenLabel": "Token Auth Plex",
"plexTokenHint": "Inserisci il tuo token di Plex.tv",
"authenticationTimeout": "Autenticazione scaduta. Riprova.",
"scanQRCodeInstruction": "Scansiona questo QR code con un dispositivo connesso a Plex per autenticarti.",
"waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser."
},
"common": {
"cancel": "Cancella",
"save": "Salva",
"close": "Chiudi",
"clear": "Pulisci",
"reset": "Ripristina",
"later": "Più tardi",
"submit": "Invia",
"confirm": "Conferma",
"retry": "Riprova",
"playNow": "Riproduci ora",
"logout": "Disconnetti",
"online": "Online",
"offline": "Offline",
"owned": "Di proprietà",
"shared": "Condiviso",
"current": "CORRENTE",
"unknown": "Sconosciuto",
"refresh": "Aggiorna",
"yes": "Sì",
"no": "No",
"server": "Server",
"delete": "Elimina",
"shuffle": "Casuale",
"addTo": "Aggiungi a..."
},
"screens": {
"licenses": "Licenze",
"selectServer": "Seleziona server",
"switchProfile": "Cambia profilo",
"subtitleStyling": "Stile sottotitoli",
"search": "Cerca",
"logs": "Registro"
},
"update": {
"available": "Aggiornamento disponibile",
"versionAvailable": "Versione ${version} disponibile",
"currentVersion": "Corrente: ${version}",
"skipVersion": "Salta questa versione",
"viewRelease": "Visualizza dettagli release",
"latestVersion": "La versione installata è l'ultima disponibile",
"checkFailed": "Impossibile controllare gli aggiornamenti"
},
"settings": {
"title": "Impostazioni",
"language": "Lingua",
"theme": "Tema",
"appearance": "Aspetto",
"videoPlayback": "Riproduzione video",
"shufflePlay": "Riproduzione casuale",
"advanced": "Avanzate",
"useSeasonPostersDescription": "Mostra il poster della stagione invece del poster della serie per gli episodi",
"showHeroSectionDescription": "Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale",
"secondsLabel": "Secondi",
"minutesLabel": "Minuti",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Inserisci durata (${min}-${max})",
"systemTheme": "Sistema",
"systemThemeDescription": "Segui le impostazioni di sistema",
"lightTheme": "Chiaro",
"darkTheme": "Scuro",
"libraryDensity": "Densità libreria",
"compact": "Compatta",
"compactDescription": "Schede più piccole, più elementi visibili",
"normal": "Normale",
"normalDescription": "Dimensione predefinita",
"comfortable": "Comoda",
"comfortableDescription": "Schede più grandi, meno elementi visibili",
"viewMode": "Modalità di visualizzazione",
"gridView": "Griglia",
"gridViewDescription": "Visualizza gli elementi in un layout a griglia",
"listView": "Elenco",
"listViewDescription": "Visualizza gli elementi in un layout a elenco",
"useSeasonPosters": "Usa poster delle stagioni",
"showHeroSection": "Mostra sezione principale",
"hardwareDecoding": "Decodifica Hardware",
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
"bufferSize": "Dimensione buffer",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Stile sottotitoli",
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
"smallSkipDuration": "Durata skip breve",
"largeSkipDuration": "Durata skip lungo",
"secondsUnit": "${seconds} secondi",
"defaultSleepTimer": "Timer spegnimento predefinito",
"minutesUnit": "${minutes} minuti",
"rememberTrackSelections": "Ricorda selezioni tracce per serie/film",
"rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione",
"unwatchedOnly": "Solo non guardati",
"unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale",
"shuffleOrderNavigation": "Navigazione in ordine casuale",
"shuffleOrderNavigationDescription": "I pulsanti Avanti/Indietro seguono l'ordine casuale",
"loopShuffleQueue": "Coda di riproduzione casuale in loop",
"loopShuffleQueueDescription": "Riavvia la coda quando raggiungi la fine",
"videoPlayerControls": "Controlli del lettore video",
"keyboardShortcuts": "Scorciatoie da tastiera",
"keyboardShortcutsDescription": "Personalizza le scorciatoie da tastiera",
"debugLogging": "Log di debug",
"debugLoggingDescription": "Abilita il logging dettagliato per la risoluzione dei problemi",
"viewLogs": "Visualizza log",
"viewLogsDescription": "Visualizza i log dell'applicazione",
"clearCache": "Svuota cache",
"clearCacheDescription": "Questa opzione cancellerà tutte le immagini e i dati memorizzati nella cache. Dopo aver cancellato la cache, l'app potrebbe impiegare più tempo per caricare i contenuti.",
"clearCacheSuccess": "Cache cancellata correttamente",
"resetSettings": "Ripristina impostazioni",
"resetSettingsDescription": "Questa opzione ripristinerà tutte le impostazioni ai valori predefiniti. Non può essere annullata.",
"resetSettingsSuccess": "Impostazioni ripristinate correttamente",
"shortcutsReset": "Scorciatoie ripristinate alle impostazioni predefinite",
"about": "Informazioni",
"aboutDescription": "Informazioni sull'app e le licenze",
"updates": "Aggiornamenti",
"updateAvailable": "Aggiornamento disponibile",
"checkForUpdates": "Controlla aggiornamenti",
"validationErrorEnterNumber": "Inserisci un numero valido",
"validationErrorDuration": "la durata deve essere compresa tra ${min} e ${max} ${unit}",
"shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}",
"shortcutUpdated": "Scorciatoia aggiornata per ${action}"
},
"search": {
"hint": "Cerca film. spettacoli, musica...",
"tryDifferentTerm": "Prova altri termini di ricerca",
"searchYourMedia": "Cerca nei tuoi media",
"enterTitleActorOrKeyword": "Inserisci un titolo, attore o parola chiave"
},
"hotkeys": {
"setShortcutFor": "Imposta scorciatoia per ${actionName}",
"clearShortcut": "Elimina scorciatoia"
},
"pinEntry": {
"enterPin": "Inserisci PIN",
"showPin": "Mostra PIN",
"hidePin": "Nascondi PIN"
},
"fileInfo": {
"title": "Info sul file",
"video": "Video",
"audio": "Audio",
"file": "File",
"advanced": "Avanzate",
"codec": "Codec",
"resolution": "Risoluzione",
"bitrate": "Bitrate",
"frameRate": "Frame Rate",
"aspectRatio": "Aspect Ratio",
"profile": "Profilo",
"bitDepth": "Profondità colore",
"colorSpace": "Spazio colore",
"colorRange": "Gamma colori",
"colorPrimaries": "Colori primari",
"chromaSubsampling": "Sottocampionamento cromatico",
"channels": "Canali",
"path": "Percorso",
"size": "Dimensione",
"container": "Contenitore",
"duration": "Durata",
"optimizedForStreaming": "Ottimizzato per lo streaming",
"has64bitOffsets": "Offset a 64-bit"
},
"mediaMenu": {
"markAsWatched": "Segna come visto",
"markAsUnwatched": "Segna come non visto",
"removeFromContinueWatching": "Rimuovi da Continua a guardare",
"goToSeries": "Vai alle serie",
"goToSeason": "Vai alla stagione",
"shufflePlay": "Riproduzione casuale",
"fileInfo": "Info sul file"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
"mediaCardShow": "${title}, serie TV",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "visto",
"mediaCardPartiallyWatched": "${percent} percento visto",
"mediaCardUnwatched": "non visto",
"tapToPlay": "Tocca per riprodurre"
},
"tooltips": {
"shufflePlay": "Riproduzione casuale",
"markAsWatched": "Segna come visto",
"markAsUnwatched": "Segna come non visto"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Sottotitoli",
"resetToZero": "Riporta a 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} riprodotto dopo",
"playsEarlier": "${label} riprodotto prima",
"noOffset": "Nessun offset",
"letterbox": "Letterbox",
"fillScreen": "Riempi schermo",
"stretch": "Allunga",
"lockRotation": "Blocca rotazione",
"unlockRotation": "Sblocca rotazione",
"sleepTimer": "Timer di spegnimento",
"timerActive": "Timer attivo",
"playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}",
"sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa",
"playButton": "Riproduci",
"pauseButton": "Pausa",
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
"seekForwardButton": "Avanza di ${seconds} secondi",
"previousButton": "Episodio precedente",
"nextButton": "Episodio successivo",
"previousChapterButton": "Capitolo precedente",
"nextChapterButton": "Capitolo successivo",
"volumeButton": "Volume",
"muteButton": "Silenzia",
"unmuteButton": "Riattiva audio",
"settingsButton": "Impostazioni video",
"audioTrackButton": "Tracce audio",
"subtitlesButton": "Sottotitoli",
"chaptersButton": "Capitoli",
"versionsButton": "Versioni video",
"aspectRatioButton": "Proporzioni",
"fullscreenButton": "Attiva schermo intero",
"exitFullscreenButton": "Esci da schermo intero",
"rotationLockButton": "Blocco rotazione",
"timelineSlider": "Timeline video",
"volumeSlider": "Livello volume",
"backButton": "Indietro"
},
"userStatus": {
"admin": "Admin",
"restricted": "Limitato",
"protected": "Protetto",
"current": "ATTUALE"
},
"messages": {
"markedAsWatched": "Segna come visto",
"markedAsUnwatched": "Segna come non visto",
"removedFromContinueWatching": "Rimosso da Continua a guardare",
"errorLoading": "Errore: ${error}",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
"errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}",
"errorLoadingSeries": "Errore caricamento serie",
"errorLoadingSeason": "Errore caricamento stagione",
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
"logsCleared": "Log eliminati",
"logsCopied": "Log copiati negli appunti",
"noLogsAvailable": "Nessun log disponibile",
"libraryScanning": "Scansione \"${title}\"...",
"libraryScanStarted": "Scansione libreria iniziata per \"${title}\"",
"libraryScanFailed": "Impossibile eseguire scansione della libreria: ${error}",
"metadataRefreshing": "Aggiornamento metadati per \"${title}\"...",
"metadataRefreshStarted": "Aggiornamento metadati per \"${title}\"",
"metadataRefreshFailed": "Errore aggiornamento metadati: ${error}",
"noPlexToken": "Nessun token Plex trovato. Riesegui l'accesso.",
"logoutConfirm": "Sei sicuro di volerti disconnettere?",
"noSeasonsFound": "Nessuna stagione trovata",
"noEpisodesFound": "Nessun episodio trovato nella prima stagione",
"noEpisodesFoundGeneral": "Nessun episodio trovato",
"noResultsFound": "Nessun risultato",
"sleepTimerSet": "Imposta timer spegnimento per ${label}",
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
"noItemsAvailable": "Nessun elemento disponibile",
"failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione",
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
"failedPlayback": "Impossibile ${action}: ${error}"
},
"profile": {
"noUsersAvailable": "Nessun utente disponibile"
},
"subtitlingStyling": {
"stylingOptions": "Opzioni stile",
"fontSize": "Dimensione",
"textColor": "Colore testo",
"borderSize": "Dimensione bordo",
"borderColor": "Colore bordo",
"backgroundOpacity": "Opacità sfondo",
"backgroundColor": "Colore sfondo"
},
"dialog": {
"confirmAction": "Conferma azione",
"areYouSure": "Sei sicuro di voler eseguire questa azione?",
"cancel": "Cancella",
"playNow": "Riproduci ora"
},
"discover": {
"title": "Esplora",
"switchProfile": "Cambia profilo",
"switchServer": "Cambia server",
"logout": "Disconnetti",
"noContentAvailable": "Nessun contenuto disponibile",
"addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie",
"continueWatching": "Continua a guardare",
"recentlyAdded": "Aggiunti di recente",
"play": "Riproduci",
"resume": "Riprendi",
"playEpisode": "Riproduci S${season}, E${episode}",
"resumeEpisode": "Riprendi S${season}, E${episode}",
"pause": "Pausa",
"overview": "Panoramica",
"cast": "Attori",
"seasons": "Stagioni",
"studio": "Studio",
"rating": "Classificazione",
"watched": "Guardato",
"episodeCount": "${count} episodi",
"watchedProgress": "${watched}/${total} guardati",
"movie": "Film",
"tvShow": "Serie TV",
"minutesLeft": "${minutes} minuti rimanenti"
},
"errors": {
"searchFailed": "Ricerca fallita: ${error}",
"connectionTimeout": "Timeout connessione durante caricamento di ${context}",
"connectionFailed": "Impossibile connettersi al server Plex.",
"failedToLoad": "Impossibile caricare ${context}: ${error}",
"noClientAvailable": "Nessun client disponibile",
"authenticationFailed": "Autenticazione fallita: ${error}",
"couldNotLaunchUrl": "Impossibile avviare URL di autenticazione",
"pleaseEnterToken": "Inserisci token",
"invalidToken": "Token non valido",
"failedToVerifyToken": "Verifica token fallita: ${error}",
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
"connectionFailedGeneric": "Connessione fallita"
},
"libraries": {
"title": "Librerie",
"scanLibraryFiles": "Scansiona file libreria",
"scanLibrary": "Scansiona libreria",
"analyze": "Analizza",
"analyzeLibrary": "Analizza libreria",
"refreshMetadata": "Aggiorna metadati",
"emptyTrash": "Svuota cestino",
"emptyingTrash": "Svuotamento cestino per \"${title}\"...",
"trashEmptied": "Cestino svuotato per \"${title}\"",
"failedToEmptyTrash": "Impossibile svuotare cestino: ${error}",
"analyzing": "Analisi \"${title}\"...",
"analysisStarted": "Analisi iniziata per \"${title}\"",
"failedToAnalyze": "Impossibile analizzare libreria: ${error}",
"noLibrariesFound": "Nessuna libreria trovata",
"thisLibraryIsEmpty": "Questa libreria è vuota",
"all": "Tutto",
"clearAll": "Cancella tutto",
"scanLibraryConfirm": "Sei sicuro di voler scansionare \"${title}\"?",
"analyzeLibraryConfirm": "Sei sicuro di voler analizzare \"${title}\"?",
"refreshMetadataConfirm": "Sei sicuro di voler aggiornare i metadati per \"${title}\"?",
"emptyTrashConfirm": "Sei sicuro di voler svuotare il cestino per \"${title}\"?",
"manageLibraries": "Gestisci librerie",
"sort": "Ordina",
"sortBy": "Ordina per",
"filters": "Filtri",
"loadingLibraryWithCount": "Caricamento librerie... (${count} oggetti caricati)",
"confirmActionMessage": "Sei sicuro di voler eseguire questa azione?",
"showLibrary": "Mostra libreria",
"hideLibrary": "Nascondi libreria",
"libraryOptions": "Opzioni libreria",
"content": "contenuto della libreria",
"selectLibrary": "Seleziona libreria",
"filtersWithCount": "Filtri (${count})",
"noRecommendations": "Nessun consiglio disponibile",
"noCollections": "Nessuna raccolta in questa libreria",
"noFoldersFound": "Nessuna cartella trovata",
"folders": "cartelle",
"tabs": {
"recommended": "Consigliati",
"browse": "Esplora",
"collections": "Raccolte",
"playlists": "Playlist"
},
"groupings": {
"all": "Tutti",
"movies": "Film",
"shows": "Serie TV",
"seasons": "Stagioni",
"episodes": "Episodi",
"folders": "Cartelle"
}
},
"about": {
"title": "Informazioni",
"openSourceLicenses": "Licenze Open Source",
"versionLabel": "Versione ${version}",
"appDescription": "Un bellissimo client Plex per Flutter",
"viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti"
},
"serverSelection": {
"connectingToServer": "Connessione al server...",
"connectingToAllServers": "Connessione a tutti i server...",
"connectToAllServers": "Connetti a tutti i server",
"allServerConnectionsFailed": "Impossibile connettersi a nessun server. Controlla la tua rete e riprova.",
"foundServers": "Trovati ${count} server",
"orSelectIndividually": "Oppure seleziona un server singolarmente:",
"serverDebugCopied": "Dati di debug del server copiati negli appunti",
"copyDebugData": "Copia dati di debug",
"noServersFound": "Nessun server trovato",
"malformedServerData": "Trovato ${count} server con dati difettosi. Nessun server valido disponibile.",
"incompleteServerInfo": "Alcuni server presentano informazioni incomplete e sono stati ignorati. Controlla il tuo account Plex.tv.",
"incompleteConnectionInfo": "Le informazioni di connessione al server sono incomplete. Riprova.",
"malformedServerInfo": "Le informazioni sul server sono errate: ${message}",
"networkConnectionFailed": "Connessione di rete non riuscita. Controlla la tua connessione Internet e riprova.",
"authenticationFailed": "Autenticazione fallita. Effettua nuovamente l'accesso.",
"plexServiceUnavailable": "Servizio Plex non disponibile. Riprova più tardi.",
"failedToLoadServers": "Impossibile caricare i server: ${error}"
},
"hubDetail": {
"title": "Titolo",
"releaseYear": "Anno rilascio",
"dateAdded": "Data aggiunta",
"rating": "Valutazione",
"noItemsFound": "Nessun elemento trovato"
},
"logs": {
"title": "Registro",
"clearLogs": "Cancella log",
"copyLogs": "Copia log",
"exportLogs": "Esporta log",
"noLogsToShow": "Nessun log da mostrare",
"error": "Errore:",
"stackTrace": "Traccia dello stack:"
},
"licenses": {
"relatedPackages": "Pacchetti correlati",
"license": "Licenza",
"licenseNumber": "Licenza ${number}",
"licensesCount": "${count} licenze"
},
"navigation": {
"home": "Home",
"search": "Cerca",
"libraries": "Librerie",
"settings": "Impostazioni"
},
"playlists": {
"title": "Playlist",
"noPlaylists": "Nessuna playlist trovata",
"create": "Crea playlist",
"newPlaylist": "Nuova playlist",
"playlistName": "Nome playlist",
"enterPlaylistName": "Inserisci nome playlist",
"edit": "Modifica playlist",
"delete": "Elimina playlist",
"addTo": "Aggiungi a playlist",
"addItems": "Aggiungi elementi",
"removeItem": "Rimuovi da playlist",
"clearPlaylist": "Svuota playlist",
"playAll": "Riproduci tutto",
"shuffle": "Casuale",
"smartPlaylist": "Playlist intelligente",
"regularPlaylist": "Playlist normale",
"itemCount": "${count} elementi",
"oneItem": "1 elemento",
"emptyPlaylist": "Questa playlist è vuota",
"deleteConfirm": "Eliminare playlist?",
"deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?",
"created": "Playlist creata",
"updated": "Playlist aggiornata",
"deleted": "Playlist eliminata",
"itemAdded": "Aggiunto alla playlist",
"itemRemoved": "Rimosso dalla playlist",
"selectPlaylist": "Seleziona playlist",
"createNewPlaylist": "Crea nuova playlist",
"errorCreating": "Errore durante la creazione della playlist",
"errorDeleting": "Errore durante l'eliminazione della playlist",
"errorLoading": "Errore durante il caricamento delle playlist",
"errorAdding": "Errore durante l'aggiunta alla playlist",
"errorReordering": "Errore durante il riordino dell'elemento della playlist",
"errorRemoving": "Errore durante la rimozione dalla playlist",
"playlist": "Playlist"
},
"collections": {
"title": "Raccolte",
"collection": "Raccolta",
"empty": "La raccolta è vuota",
"noItems": "Nessun elemento in questa raccolta",
"unknownLibrarySection": "Impossibile eliminare: sezione libreria sconosciuta",
"deleteCollection": "Elimina raccolta",
"deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Questa azione non può essere annullata.",
"deleted": "Raccolta eliminata",
"deleteFailed": "Impossibile eliminare la raccolta",
"deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}",
"failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}",
"addTo": "Aggiungi alla raccolta",
"selectCollection": "Seleziona raccolta",
"createNewCollection": "Crea nuova raccolta",
"collectionName": "Nome raccolta",
"enterCollectionName": "Inserisci nome raccolta",
"addedToCollection": "Aggiunto alla raccolta",
"errorAddingToCollection": "Errore nell'aggiunta alla raccolta",
"created": "Raccolta creata",
"removeFromCollection": "Rimuovi dalla raccolta",
"removeFromCollectionConfirm": "Rimuovere \"${title}\" da questa raccolta?",
"removedFromCollection": "Rimosso dalla raccolta",
"removeFromCollectionFailed": "Impossibile rimuovere dalla raccolta",
"removeFromCollectionError": "Errore durante la rimozione dalla raccolta: ${error}"
}
}
+510
View File
@@ -0,0 +1,510 @@
{
"app": {
"title": "Plezy",
"loading": "Laden..."
},
"auth": {
"signInWithPlex": "Inloggen met Plex",
"showQRCode": "Toon QR-code",
"cancel": "Annuleren",
"authenticate": "Authenticeren",
"retry": "Opnieuw proberen",
"debugEnterToken": "Debug: Voer Plex Token in",
"plexTokenLabel": "Plex Authenticatietoken",
"plexTokenHint": "Voer je Plex.tv token in",
"authenticationTimeout": "Authenticatie verlopen. Probeer opnieuw.",
"scanQRCodeInstruction": "Scan deze QR-code met een apparaat dat is ingelogd op Plex om te authenticeren.",
"waitingForAuth": "Wachten op authenticatie...\nVoltooi het inloggen in je browser."
},
"common": {
"cancel": "Annuleren",
"save": "Opslaan",
"close": "Sluiten",
"clear": "Wissen",
"reset": "Resetten",
"later": "Later",
"submit": "Verzenden",
"confirm": "Bevestigen",
"retry": "Opnieuw proberen",
"playNow": "Nu afspelen",
"logout": "Uitloggen",
"online": "Online",
"offline": "Offline",
"owned": "Eigendom",
"shared": "Gedeeld",
"current": "HUIDIG",
"unknown": "Onbekend",
"refresh": "Vernieuwen",
"yes": "Ja",
"no": "Nee",
"server": "Server",
"delete": "Verwijderen",
"shuffle": "Willekeurig",
"addTo": "Toevoegen aan..."
},
"screens": {
"licenses": "Licenties",
"selectServer": "Selecteer server",
"switchProfile": "Wissel van profiel",
"subtitleStyling": "Ondertitel opmaak",
"search": "Zoeken",
"logs": "Logbestanden"
},
"update": {
"available": "Update beschikbaar",
"versionAvailable": "Versie ${version} is beschikbaar",
"currentVersion": "Huidig: ${version}",
"skipVersion": "Deze versie overslaan",
"viewRelease": "Bekijk release",
"latestVersion": "Je hebt de nieuwste versie",
"checkFailed": "Kon niet controleren op updates"
},
"settings": {
"title": "Instellingen",
"language": "Taal",
"theme": "Thema",
"appearance": "Uiterlijk",
"videoPlayback": "Video afspelen",
"shufflePlay": "Willekeurig afspelen",
"advanced": "Geavanceerd",
"useSeasonPostersDescription": "Toon seizoenposter in plaats van serieposter voor afleveringen",
"showHeroSectionDescription": "Toon uitgelichte inhoud carrousel op startscherm",
"secondsLabel": "Seconden",
"minutesLabel": "Minuten",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Voer duur in (${min}-${max})",
"systemTheme": "Systeem",
"systemThemeDescription": "Volg systeeminstellingen",
"lightTheme": "Licht",
"darkTheme": "Donker",
"libraryDensity": "Bibliotheek dichtheid",
"compact": "Compact",
"compactDescription": "Kleinere kaarten, meer items zichtbaar",
"normal": "Normaal",
"normalDescription": "Standaard grootte",
"comfortable": "Comfortabel",
"comfortableDescription": "Grotere kaarten, minder items zichtbaar",
"viewMode": "Weergavemodus",
"gridView": "Raster",
"gridViewDescription": "Items weergeven in een rasterindeling",
"listView": "Lijst",
"listViewDescription": "Items weergeven in een lijstindeling",
"useSeasonPosters": "Gebruik seizoenposters",
"showHeroSection": "Toon hoofdsectie",
"hardwareDecoding": "Hardware decodering",
"hardwareDecodingDescription": "Gebruik hardware versnelling indien beschikbaar",
"bufferSize": "Buffer grootte",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Ondertitel opmaak",
"subtitleStylingDescription": "Pas ondertitel uiterlijk aan",
"smallSkipDuration": "Korte skip duur",
"largeSkipDuration": "Lange skip duur",
"secondsUnit": "${seconds} seconden",
"defaultSleepTimer": "Standaard slaap timer",
"minutesUnit": "bij ${minutes} minuten",
"rememberTrackSelections": "Onthoud track selecties per serie/film",
"rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen",
"unwatchedOnly": "Alleen ongekeken",
"unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij",
"shuffleOrderNavigation": "Willekeurige volgorde navigatie",
"shuffleOrderNavigationDescription": "Volgende/vorige knoppen volgen willekeurige volgorde",
"loopShuffleQueue": "Herhaal willekeurige wachtrij",
"loopShuffleQueueDescription": "Start wachtrij opnieuw bij het einde",
"videoPlayerControls": "Videospeler bediening",
"keyboardShortcuts": "Toetsenbord sneltoetsen",
"keyboardShortcutsDescription": "Pas toetsenbord sneltoetsen aan",
"debugLogging": "Debug logging",
"debugLoggingDescription": "Schakel gedetailleerde logging in voor probleemoplossing",
"viewLogs": "Bekijk logs",
"viewLogsDescription": "Bekijk applicatie logs",
"clearCache": "Cache wissen",
"clearCacheDescription": "Dit wist alle gecachte afbeeldingen en gegevens. De app kan langer duren om inhoud te laden na het wissen van de cache.",
"clearCacheSuccess": "Cache succesvol gewist",
"resetSettings": "Instellingen resetten",
"resetSettingsDescription": "Dit reset alle instellingen naar hun standaard waarden. Deze actie kan niet ongedaan gemaakt worden.",
"resetSettingsSuccess": "Instellingen succesvol gereset",
"shortcutsReset": "Sneltoetsen gereset naar standaard",
"about": "Over",
"aboutDescription": "App informatie en licenties",
"updates": "Updates",
"updateAvailable": "Update beschikbaar",
"checkForUpdates": "Controleer op updates",
"validationErrorEnterNumber": "Voer een geldig nummer in",
"validationErrorDuration": "Duur moet tussen ${min} en ${max} ${unit} zijn",
"shortcutAlreadyAssigned": "Sneltoets al toegewezen aan ${action}",
"shortcutUpdated": "Sneltoets bijgewerkt voor ${action}"
},
"search": {
"hint": "Zoek films, series, muziek...",
"tryDifferentTerm": "Probeer een andere zoekterm",
"searchYourMedia": "Zoek in je media",
"enterTitleActorOrKeyword": "Voer een titel, acteur of trefwoord in"
},
"hotkeys": {
"setShortcutFor": "Stel sneltoets in voor ${actionName}",
"clearShortcut": "Wis sneltoets"
},
"pinEntry": {
"enterPin": "Voer PIN in",
"showPin": "Toon PIN",
"hidePin": "Verberg PIN"
},
"fileInfo": {
"title": "Bestand info",
"video": "Video",
"audio": "Audio",
"file": "Bestand",
"advanced": "Geavanceerd",
"codec": "Codec",
"resolution": "Resolutie",
"bitrate": "Bitrate",
"frameRate": "Frame rate",
"aspectRatio": "Beeldverhouding",
"profile": "Profiel",
"bitDepth": "Bit diepte",
"colorSpace": "Kleurruimte",
"colorRange": "Kleurbereik",
"colorPrimaries": "Kleurprimaires",
"chromaSubsampling": "Chroma subsampling",
"channels": "Kanalen",
"path": "Pad",
"size": "Grootte",
"container": "Container",
"duration": "Duur",
"optimizedForStreaming": "Geoptimaliseerd voor streaming",
"has64bitOffsets": "64-bit Offsets"
},
"mediaMenu": {
"markAsWatched": "Markeer als gekeken",
"markAsUnwatched": "Markeer als ongekeken",
"removeFromContinueWatching": "Verwijder uit Doorgaan met kijken",
"goToSeries": "Ga naar serie",
"goToSeason": "Ga naar seizoen",
"shufflePlay": "Willekeurig afspelen",
"fileInfo": "Bestand info"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
"mediaCardShow": "${title}, TV-serie",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "bekeken",
"mediaCardPartiallyWatched": "${percent} procent bekeken",
"mediaCardUnwatched": "niet bekeken",
"tapToPlay": "Tik om af te spelen"
},
"tooltips": {
"shufflePlay": "Willekeurig afspelen",
"markAsWatched": "Markeer als gekeken",
"markAsUnwatched": "Markeer als ongekeken"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Ondertitels",
"resetToZero": "Reset naar 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} speelt later af",
"playsEarlier": "${label} speelt eerder af",
"noOffset": "Geen offset",
"letterbox": "Letterbox",
"fillScreen": "Vul scherm",
"stretch": "Uitrekken",
"lockRotation": "Vergrendel rotatie",
"unlockRotation": "Ontgrendel rotatie",
"sleepTimer": "Slaaptimer",
"timerActive": "Timer actief",
"playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}",
"sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd",
"playButton": "Afspelen",
"pauseButton": "Pauzeren",
"seekBackwardButton": "Terugspoelen ${seconds} seconden",
"seekForwardButton": "Vooruitspoelen ${seconds} seconden",
"previousButton": "Vorige aflevering",
"nextButton": "Volgende aflevering",
"previousChapterButton": "Vorig hoofdstuk",
"nextChapterButton": "Volgend hoofdstuk",
"volumeButton": "Volume",
"muteButton": "Dempen",
"unmuteButton": "Dempen opheffen",
"settingsButton": "Video-instellingen",
"audioTrackButton": "Audiosporen",
"subtitlesButton": "Ondertitels",
"chaptersButton": "Hoofdstukken",
"versionsButton": "Videoversies",
"aspectRatioButton": "Beeldverhouding",
"fullscreenButton": "Volledig scherm activeren",
"exitFullscreenButton": "Volledig scherm verlaten",
"rotationLockButton": "Rotatievergrendeling",
"timelineSlider": "Videotijdlijn",
"volumeSlider": "Volumeniveau",
"backButton": "Terug"
},
"userStatus": {
"admin": "Beheerder",
"restricted": "Beperkt",
"protected": "Beschermd",
"current": "HUIDIG"
},
"messages": {
"markedAsWatched": "Gemarkeerd als gekeken",
"markedAsUnwatched": "Gemarkeerd als ongekeken",
"removedFromContinueWatching": "Verwijderd uit Doorgaan met kijken",
"errorLoading": "Fout: ${error}",
"fileInfoNotAvailable": "Bestand informatie niet beschikbaar",
"errorLoadingFileInfo": "Fout bij laden bestand info: ${error}",
"errorLoadingSeries": "Fout bij laden serie",
"errorLoadingSeason": "Fout bij laden seizoen",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
"logsCleared": "Logs gewist",
"logsCopied": "Logs gekopieerd naar klembord",
"noLogsAvailable": "Geen logs beschikbaar",
"libraryScanning": "Scannen \"${title}\"...",
"libraryScanStarted": "Bibliotheek scan gestart voor \"${title}\"",
"libraryScanFailed": "Kon bibliotheek niet scannen: ${error}",
"metadataRefreshing": "Metadata vernieuwen voor \"${title}\"...",
"metadataRefreshStarted": "Metadata vernieuwen gestart voor \"${title}\"",
"metadataRefreshFailed": "Kon metadata niet vernieuwen: ${error}",
"noPlexToken": "Geen Plex token gevonden. Log opnieuw in.",
"logoutConfirm": "Weet je zeker dat je wilt uitloggen?",
"noSeasonsFound": "Geen seizoenen gevonden",
"noEpisodesFound": "Geen afleveringen gevonden in eerste seizoen",
"noEpisodesFoundGeneral": "Geen afleveringen gevonden",
"noResultsFound": "Geen resultaten gevonden",
"sleepTimerSet": "Slaap timer ingesteld voor ${label}",
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
"noItemsAvailable": "Geen items beschikbaar",
"failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken",
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
"failedPlayback": "Afspelen van ${action} mislukt: ${error}"
},
"profile": {
"noUsersAvailable": "Geen gebruikers beschikbaar"
},
"subtitlingStyling": {
"stylingOptions": "Opmaak opties",
"fontSize": "Lettergrootte",
"textColor": "Tekstkleur",
"borderSize": "Rand grootte",
"borderColor": "Randkleur",
"backgroundOpacity": "Achtergrond transparantie",
"backgroundColor": "Achtergrondkleur"
},
"dialog": {
"confirmAction": "Bevestig actie",
"areYouSure": "Weet je zeker dat je deze actie wilt uitvoeren?",
"cancel": "Annuleren",
"playNow": "Nu afspelen"
},
"discover": {
"title": "Ontdekken",
"switchProfile": "Wissel van profiel",
"switchServer": "Wissel van server",
"logout": "Uitloggen",
"noContentAvailable": "Geen inhoud beschikbaar",
"addMediaToLibraries": "Voeg wat media toe aan je bibliotheken",
"continueWatching": "Verder kijken",
"recentlyAdded": "Recent toegevoegd",
"play": "Afspelen",
"resume": "Hervatten",
"playEpisode": "Speel S${season}, E${episode}",
"resumeEpisode": "Hervat S${season}, E${episode}",
"pause": "Pauzeren",
"overview": "Overzicht",
"cast": "Acteurs",
"seasons": "Seizoenen",
"studio": "Studio",
"rating": "Leeftijd",
"watched": "Bekeken",
"episodeCount": "${count} afleveringen",
"watchedProgress": "${watched}/${total} gekeken",
"movie": "Film",
"tvShow": "TV Serie",
"minutesLeft": "${minutes} min over"
},
"errors": {
"searchFailed": "Zoeken mislukt: ${error}",
"connectionTimeout": "Verbinding time-out tijdens laden ${context}",
"connectionFailed": "Kan geen verbinding maken met Plex server",
"failedToLoad": "Kon ${context} niet laden: ${error}",
"noClientAvailable": "Geen client beschikbaar",
"authenticationFailed": "Authenticatie mislukt: ${error}",
"couldNotLaunchUrl": "Kon auth URL niet openen",
"pleaseEnterToken": "Voer een token in",
"invalidToken": "Ongeldig token",
"failedToVerifyToken": "Kon token niet verifiëren: ${error}",
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
"connectionFailedGeneric": "Verbinding mislukt"
},
"libraries": {
"title": "Bibliotheken",
"scanLibraryFiles": "Scan bibliotheek bestanden",
"scanLibrary": "Scan bibliotheek",
"analyze": "Analyseren",
"analyzeLibrary": "Analyseer bibliotheek",
"refreshMetadata": "Vernieuw metadata",
"emptyTrash": "Prullenbak legen",
"emptyingTrash": "Prullenbak legen voor \"${title}\"...",
"trashEmptied": "Prullenbak geleegd voor \"${title}\"",
"failedToEmptyTrash": "Kon prullenbak niet legen: ${error}",
"analyzing": "Analyseren \"${title}\"...",
"analysisStarted": "Analyse gestart voor \"${title}\"",
"failedToAnalyze": "Kon bibliotheek niet analyseren: ${error}",
"noLibrariesFound": "Geen bibliotheken gevonden",
"thisLibraryIsEmpty": "Deze bibliotheek is leeg",
"all": "Alles",
"clearAll": "Alles wissen",
"scanLibraryConfirm": "Weet je zeker dat je \"${title}\" wilt scannen?",
"analyzeLibraryConfirm": "Weet je zeker dat je \"${title}\" wilt analyseren?",
"refreshMetadataConfirm": "Weet je zeker dat je metadata wilt vernieuwen voor \"${title}\"?",
"emptyTrashConfirm": "Weet je zeker dat je de prullenbak wilt legen voor \"${title}\"?",
"manageLibraries": "Beheer bibliotheken",
"sort": "Sorteren",
"sortBy": "Sorteer op",
"filters": "Filters",
"loadingLibraryWithCount": "Bibliotheek laden... (${count} items geladen)",
"confirmActionMessage": "Weet je zeker dat je deze actie wilt uitvoeren?",
"showLibrary": "Toon bibliotheek",
"hideLibrary": "Verberg bibliotheek",
"libraryOptions": "Bibliotheek opties",
"content": "bibliotheekinhoud",
"selectLibrary": "Bibliotheek kiezen",
"filtersWithCount": "Filters (${count})",
"noRecommendations": "Geen aanbevelingen beschikbaar",
"noCollections": "Geen collecties in deze bibliotheek",
"noFoldersFound": "Geen mappen gevonden",
"folders": "mappen",
"tabs": {
"recommended": "Aanbevolen",
"browse": "Bladeren",
"collections": "Collecties",
"playlists": "Afspeellijsten"
},
"groupings": {
"all": "Alles",
"movies": "Films",
"shows": "Series",
"seasons": "Seizoenen",
"episodes": "Afleveringen",
"folders": "Mappen"
}
},
"about": {
"title": "Over",
"openSourceLicenses": "Open Source licenties",
"versionLabel": "Versie ${version}",
"appDescription": "Een mooie Plex client voor Flutter",
"viewLicensesDescription": "Bekijk licenties van third-party bibliotheken"
},
"serverSelection": {
"connectingToServer": "Verbinden met server...",
"connectingToAllServers": "Verbinden met alle servers...",
"connectToAllServers": "Verbind met alle servers",
"allServerConnectionsFailed": "Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.",
"foundServers": "${count} server(s) gevonden",
"orSelectIndividually": "Of selecteer een server individueel:",
"serverDebugCopied": "Server debug gegevens gekopieerd naar klembord",
"copyDebugData": "Kopieer debug gegevens",
"noServersFound": "Geen servers gevonden",
"malformedServerData": "${count} server(s) gevonden met verkeerde data. Geen geldige servers beschikbaar.",
"incompleteServerInfo": "Sommige servers hebben incomplete informatie en zijn overgeslagen. Controleer je Plex.tv account.",
"incompleteConnectionInfo": "Server verbinding informatie is incompleet. Probeer opnieuw.",
"malformedServerInfo": "Server informatie is verkeerd geformatteerd: ${message}",
"networkConnectionFailed": "Netwerkverbinding mislukt. Controleer je internetverbinding en probeer opnieuw.",
"authenticationFailed": "Authenticatie mislukt. Log opnieuw in.",
"plexServiceUnavailable": "Plex service niet beschikbaar. Probeer later opnieuw.",
"failedToLoadServers": "Kon servers niet laden: ${error}"
},
"hubDetail": {
"title": "Titel",
"releaseYear": "Uitgavejaar",
"dateAdded": "Datum toegevoegd",
"rating": "Beoordeling",
"noItemsFound": "Geen items gevonden"
},
"logs": {
"title": "Logbestanden",
"clearLogs": "Wis logs",
"copyLogs": "Kopieer logs",
"exportLogs": "Exporteer logs",
"noLogsToShow": "Geen logs om te tonen",
"error": "Fout:",
"stackTrace": "Stacktracering:"
},
"licenses": {
"relatedPackages": "Gerelateerde pakketten",
"license": "Licentie",
"licenseNumber": "Licentie ${number}",
"licensesCount": "${count} licenties"
},
"navigation": {
"home": "Thuis",
"search": "Zoeken",
"libraries": "Bibliotheken",
"settings": "Instellingen"
},
"playlists": {
"title": "Afspeellijsten",
"noPlaylists": "Geen afspeellijsten gevonden",
"create": "Afspeellijst maken",
"newPlaylist": "Nieuwe afspeellijst",
"playlistName": "Naam afspeellijst",
"enterPlaylistName": "Voer naam afspeellijst in",
"edit": "Afspeellijst bewerken",
"delete": "Afspeellijst verwijderen",
"addTo": "Toevoegen aan afspeellijst",
"addItems": "Items toevoegen",
"removeItem": "Verwijderen uit afspeellijst",
"clearPlaylist": "Afspeellijst wissen",
"playAll": "Alles afspelen",
"shuffle": "Willekeurig",
"smartPlaylist": "Slimme afspeellijst",
"regularPlaylist": "Normale afspeellijst",
"itemCount": "${count} items",
"oneItem": "1 item",
"emptyPlaylist": "Deze afspeellijst is leeg",
"deleteConfirm": "Afspeellijst verwijderen?",
"deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?",
"created": "Afspeellijst gemaakt",
"updated": "Afspeellijst bijgewerkt",
"deleted": "Afspeellijst verwijderd",
"itemAdded": "Toegevoegd aan afspeellijst",
"itemRemoved": "Verwijderd uit afspeellijst",
"selectPlaylist": "Selecteer afspeellijst",
"createNewPlaylist": "Nieuwe afspeellijst maken",
"errorCreating": "Fout bij maken afspeellijst",
"errorDeleting": "Fout bij verwijderen afspeellijst",
"errorLoading": "Fout bij laden afspeellijsten",
"errorAdding": "Fout bij toevoegen aan afspeellijst",
"errorReordering": "Fout bij herschikken van afspeellijstitem",
"errorRemoving": "Fout bij verwijderen uit afspeellijst",
"playlist": "Afspeellijst"
},
"collections": {
"title": "Collecties",
"collection": "Collectie",
"empty": "Collectie is leeg",
"noItems": "Geen items in deze collectie",
"unknownLibrarySection": "Kan niet verwijderen: onbekende bibliotheeksectie",
"deleteCollection": "Collectie verwijderen",
"deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleted": "Collectie verwijderd",
"deleteFailed": "Collectie verwijderen mislukt",
"deleteFailedWithError": "Collectie verwijderen mislukt: ${error}",
"failedToLoadItems": "Collectie-items laden mislukt: ${error}",
"addTo": "Toevoegen aan collectie",
"selectCollection": "Selecteer collectie",
"createNewCollection": "Nieuwe collectie maken",
"collectionName": "Collectienaam",
"enterCollectionName": "Voer collectienaam in",
"addedToCollection": "Toegevoegd aan collectie",
"errorAddingToCollection": "Fout bij toevoegen aan collectie",
"created": "Collectie gemaakt",
"removeFromCollection": "Verwijderen uit collectie",
"removeFromCollectionConfirm": "\"${title}\" uit deze collectie verwijderen?",
"removedFromCollection": "Uit collectie verwijderd",
"removeFromCollectionFailed": "Verwijderen uit collectie mislukt",
"removeFromCollectionError": "Fout bij verwijderen uit collectie: ${error}"
}
}
+510
View File
@@ -0,0 +1,510 @@
{
"app": {
"title": "Plezy",
"loading": "Laddar..."
},
"auth": {
"signInWithPlex": "Logga in med Plex",
"showQRCode": "Visa QR-kod",
"cancel": "Avbryt",
"authenticate": "Autentisera",
"retry": "Försök igen",
"debugEnterToken": "Debug: Ange Plex-token",
"plexTokenLabel": "Plex-autentiseringstoken",
"plexTokenHint": "Ange din Plex.tv-token",
"authenticationTimeout": "Autentisering tog för lång tid. Försök igen.",
"scanQRCodeInstruction": "Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.",
"waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare."
},
"common": {
"cancel": "Avbryt",
"save": "Spara",
"close": "Stäng",
"clear": "Rensa",
"reset": "Återställ",
"later": "Senare",
"submit": "Skicka",
"confirm": "Bekräfta",
"retry": "Försök igen",
"playNow": "Spela nu",
"logout": "Logga ut",
"online": "Online",
"offline": "Offline",
"owned": "Egen",
"shared": "Delad",
"current": "NUVARANDE",
"unknown": "Okänd",
"refresh": "Uppdatera",
"yes": "Ja",
"no": "Nej",
"server": "Server",
"delete": "Ta bort",
"shuffle": "Blanda",
"addTo": "Lägg till i..."
},
"screens": {
"licenses": "Licenser",
"selectServer": "Välj server",
"switchProfile": "Byt profil",
"subtitleStyling": "Undertext-styling",
"search": "Sök",
"logs": "Loggar"
},
"update": {
"available": "Uppdatering tillgänglig",
"versionAvailable": "Version ${version} är tillgänglig",
"currentVersion": "Nuvarande: ${version}",
"skipVersion": "Hoppa över denna version",
"viewRelease": "Visa release",
"latestVersion": "Du har den senaste versionen",
"checkFailed": "Misslyckades att kontrollera uppdateringar"
},
"settings": {
"title": "Inställningar",
"language": "Språk",
"theme": "Tema",
"appearance": "Utseende",
"videoPlayback": "Videouppspelning",
"shufflePlay": "Blanda uppspelning",
"advanced": "Avancerat",
"useSeasonPostersDescription": "Visa säsongsaffisch istället för serieaffisch för avsnitt",
"showHeroSectionDescription": "Visa utvalda innehållskarusell på startsidan",
"secondsLabel": "Sekunder",
"minutesLabel": "Minuter",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Ange tid (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Följ systeminställningar",
"lightTheme": "Ljust",
"darkTheme": "Mörkt",
"libraryDensity": "Biblioteksdensitet",
"compact": "Kompakt",
"compactDescription": "Mindre kort, fler objekt synliga",
"normal": "Normal",
"normalDescription": "Standardstorlek",
"comfortable": "Bekväm",
"comfortableDescription": "Större kort, färre objekt synliga",
"viewMode": "Visningsläge",
"gridView": "Rutnät",
"gridViewDescription": "Visa objekt i rutnätslayout",
"listView": "Lista",
"listViewDescription": "Visa objekt i listlayout",
"useSeasonPosters": "Använd säsongsaffischer",
"showHeroSection": "Visa hjältesektion",
"hardwareDecoding": "Hårdvaruavkodning",
"hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt",
"bufferSize": "Bufferstorlek",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Undertext-styling",
"subtitleStylingDescription": "Anpassa undertextutseende",
"smallSkipDuration": "Kort hoppvaraktighet",
"largeSkipDuration": "Lång hoppvaraktighet",
"secondsUnit": "${seconds} sekunder",
"defaultSleepTimer": "Standard sovtimer",
"minutesUnit": "${minutes} minuter",
"rememberTrackSelections": "Kom ihåg spårval per serie/film",
"rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning",
"unwatchedOnly": "Endast osedda",
"unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön",
"shuffleOrderNavigation": "Blandningsordning-navigation",
"shuffleOrderNavigationDescription": "Nästa/föregående knappar följer blandad ordning",
"loopShuffleQueue": "Loopa blandningskö",
"loopShuffleQueueDescription": "Starta om kö när slutet nås",
"videoPlayerControls": "Videospelar-kontroller",
"keyboardShortcuts": "Tangentbordsgenvägar",
"keyboardShortcutsDescription": "Anpassa tangentbordsgenvägar",
"debugLogging": "Felsökningsloggning",
"debugLoggingDescription": "Aktivera detaljerad loggning för felsökning",
"viewLogs": "Visa loggar",
"viewLogsDescription": "Visa applikationsloggar",
"clearCache": "Rensa cache",
"clearCacheDescription": "Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.",
"clearCacheSuccess": "Cache rensad framgångsrikt",
"resetSettings": "Återställ inställningar",
"resetSettingsDescription": "Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.",
"resetSettingsSuccess": "Inställningar återställda framgångsrikt",
"shortcutsReset": "Genvägar återställda till standard",
"about": "Om",
"aboutDescription": "Appinformation och licenser",
"updates": "Uppdateringar",
"updateAvailable": "Uppdatering tillgänglig",
"checkForUpdates": "Kontrollera uppdateringar",
"validationErrorEnterNumber": "Vänligen ange ett giltigt nummer",
"validationErrorDuration": "Tiden måste vara mellan ${min} och ${max} ${unit}",
"shortcutAlreadyAssigned": "Genväg redan tilldelad ${action}",
"shortcutUpdated": "Genväg uppdaterad för ${action}"
},
"search": {
"hint": "Sök filmer, serier, musik...",
"tryDifferentTerm": "Prova en annan sökterm",
"searchYourMedia": "Sök i dina media",
"enterTitleActorOrKeyword": "Ange en titel, skådespelare eller nyckelord"
},
"hotkeys": {
"setShortcutFor": "Sätt genväg för ${actionName}",
"clearShortcut": "Rensa genväg"
},
"pinEntry": {
"enterPin": "Ange PIN",
"showPin": "Visa PIN",
"hidePin": "Dölj PIN"
},
"fileInfo": {
"title": "Filinformation",
"video": "Video",
"audio": "Ljud",
"file": "Fil",
"advanced": "Avancerat",
"codec": "Kodek",
"resolution": "Upplösning",
"bitrate": "Bithastighet",
"frameRate": "Bildfrekvens",
"aspectRatio": "Bildförhållande",
"profile": "Profil",
"bitDepth": "Bitdjup",
"colorSpace": "Färgrymd",
"colorRange": "Färgområde",
"colorPrimaries": "Färggrunder",
"chromaSubsampling": "Kroma-undersampling",
"channels": "Kanaler",
"path": "Sökväg",
"size": "Storlek",
"container": "Container",
"duration": "Varaktighet",
"optimizedForStreaming": "Optimerad för streaming",
"has64bitOffsets": "64-bit offset"
},
"mediaMenu": {
"markAsWatched": "Markera som sedd",
"markAsUnwatched": "Markera som osedd",
"removeFromContinueWatching": "Ta bort från Fortsätt titta",
"goToSeries": "Gå till serie",
"goToSeason": "Gå till säsong",
"shufflePlay": "Blanda uppspelning",
"fileInfo": "Filinformation"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
"mediaCardShow": "${title}, TV-serie",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "sedd",
"mediaCardPartiallyWatched": "${percent} procent sedd",
"mediaCardUnwatched": "osedd",
"tapToPlay": "Tryck för att spela"
},
"tooltips": {
"shufflePlay": "Blanda uppspelning",
"markAsWatched": "Markera som sedd",
"markAsUnwatched": "Markera som osedd"
},
"videoControls": {
"audioLabel": "Ljud",
"subtitlesLabel": "Undertexter",
"resetToZero": "Återställ till 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} spelas senare",
"playsEarlier": "${label} spelas tidigare",
"noOffset": "Ingen offset",
"letterbox": "Letterbox",
"fillScreen": "Fyll skärm",
"stretch": "Sträck",
"lockRotation": "Lås rotation",
"unlockRotation": "Lås upp rotation",
"sleepTimer": "Sovtimer",
"timerActive": "Timer aktiv",
"playbackWillPauseIn": "Uppspelningen pausas om ${duration}",
"sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad",
"playButton": "Spela",
"pauseButton": "Pausa",
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
"seekForwardButton": "Spola framåt ${seconds} sekunder",
"previousButton": "Föregående avsnitt",
"nextButton": "Nästa avsnitt",
"previousChapterButton": "Föregående kapitel",
"nextChapterButton": "Nästa kapitel",
"volumeButton": "Volym",
"muteButton": "Tysta",
"unmuteButton": "Slå på ljud",
"settingsButton": "Videoinställningar",
"audioTrackButton": "Ljudspår",
"subtitlesButton": "Undertexter",
"chaptersButton": "Kapitel",
"versionsButton": "Videoversioner",
"aspectRatioButton": "Bildförhållande",
"fullscreenButton": "Aktivera helskärm",
"exitFullscreenButton": "Avsluta helskärm",
"rotationLockButton": "Rotationslås",
"timelineSlider": "Videotidslinje",
"volumeSlider": "Volymnivå",
"backButton": "Tillbaka"
},
"userStatus": {
"admin": "Admin",
"restricted": "Begränsad",
"protected": "Skyddad",
"current": "NUVARANDE"
},
"messages": {
"markedAsWatched": "Markerad som sedd",
"markedAsUnwatched": "Markerad som osedd",
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
"errorLoading": "Fel: ${error}",
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"errorLoadingSeason": "Fel vid laddning av säsong",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
"logsCleared": "Loggar rensade",
"logsCopied": "Loggar kopierade till urklipp",
"noLogsAvailable": "Inga loggar tillgängliga",
"libraryScanning": "Skannar \"${title}\"...",
"libraryScanStarted": "Biblioteksskanning startad för \"${title}\"",
"libraryScanFailed": "Misslyckades att skanna bibliotek: ${error}",
"metadataRefreshing": "Uppdaterar metadata för \"${title}\"...",
"metadataRefreshStarted": "Metadata-uppdatering startad för \"${title}\"",
"metadataRefreshFailed": "Misslyckades att uppdatera metadata: ${error}",
"noPlexToken": "Ingen Plex-token hittad. Vänligen logga in igen.",
"logoutConfirm": "Är du säker på att du vill logga ut?",
"noSeasonsFound": "Inga säsonger hittades",
"noEpisodesFound": "Inga avsnitt hittades i första säsongen",
"noEpisodesFoundGeneral": "Inga avsnitt hittades",
"noResultsFound": "Inga resultat hittades",
"sleepTimerSet": "Sovtimer inställd för ${label}",
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
"noItemsAvailable": "Inga objekt tillgängliga",
"failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö",
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö inga objekt",
"failedPlayback": "Kunde inte ${action}: ${error}"
},
"profile": {
"noUsersAvailable": "Inga användare tillgängliga"
},
"subtitlingStyling": {
"stylingOptions": "Stilalternativ",
"fontSize": "Teckenstorlek",
"textColor": "Textfärg",
"borderSize": "Kantstorlek",
"borderColor": "Kantfärg",
"backgroundOpacity": "Bakgrundsopacitet",
"backgroundColor": "Bakgrundsfärg"
},
"dialog": {
"confirmAction": "Bekräfta åtgärd",
"areYouSure": "Är du säker på att du vill utföra denna åtgärd?",
"cancel": "Avbryt",
"playNow": "Spela nu"
},
"discover": {
"title": "Upptäck",
"switchProfile": "Byt profil",
"switchServer": "Byt server",
"logout": "Logga ut",
"noContentAvailable": "Inget innehåll tillgängligt",
"addMediaToLibraries": "Lägg till media till dina bibliotek",
"continueWatching": "Fortsätt titta",
"recentlyAdded": "Nyligen tillagda",
"play": "Spela",
"resume": "Återuppta",
"playEpisode": "Spela S${season}, E${episode}",
"resumeEpisode": "Återuppta S${season}, E${episode}",
"pause": "Pausa",
"overview": "Översikt",
"cast": "Rollbesättning",
"seasons": "Säsonger",
"studio": "Studio",
"rating": "Åldersgräns",
"watched": "Tittad",
"episodeCount": "${count} avsnitt",
"watchedProgress": "${watched}/${total} sedda",
"movie": "Film",
"tvShow": "TV-serie",
"minutesLeft": "${minutes} min kvar"
},
"errors": {
"searchFailed": "Sökning misslyckades: ${error}",
"connectionTimeout": "Anslutnings-timeout vid laddning ${context}",
"connectionFailed": "Kan inte ansluta till Plex-server",
"failedToLoad": "Misslyckades att ladda ${context}: ${error}",
"noClientAvailable": "Ingen klient tillgänglig",
"authenticationFailed": "Autentisering misslyckades: ${error}",
"couldNotLaunchUrl": "Kunde inte öppna autentiserings-URL",
"pleaseEnterToken": "Vänligen ange en token",
"invalidToken": "Ogiltig token",
"failedToVerifyToken": "Misslyckades att verifiera token: ${error}",
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
"connectionFailedGeneric": "Anslutning misslyckades"
},
"libraries": {
"title": "Bibliotek",
"scanLibraryFiles": "Skanna biblioteksfiler",
"scanLibrary": "Skanna bibliotek",
"analyze": "Analysera",
"analyzeLibrary": "Analysera bibliotek",
"refreshMetadata": "Uppdatera metadata",
"emptyTrash": "Töm papperskorg",
"emptyingTrash": "Tömmer papperskorg för \"${title}\"...",
"trashEmptied": "Papperskorg tömd för \"${title}\"",
"failedToEmptyTrash": "Misslyckades att tömma papperskorg: ${error}",
"analyzing": "Analyserar \"${title}\"...",
"analysisStarted": "Analys startad för \"${title}\"",
"failedToAnalyze": "Misslyckades att analysera bibliotek: ${error}",
"noLibrariesFound": "Inga bibliotek hittades",
"thisLibraryIsEmpty": "Detta bibliotek är tomt",
"all": "Alla",
"clearAll": "Rensa alla",
"scanLibraryConfirm": "Är du säker på att du vill skanna \"${title}\"?",
"analyzeLibraryConfirm": "Är du säker på att du vill analysera \"${title}\"?",
"refreshMetadataConfirm": "Är du säker på att du vill uppdatera metadata för \"${title}\"?",
"emptyTrashConfirm": "Är du säker på att du vill tömma papperskorgen för \"${title}\"?",
"manageLibraries": "Hantera bibliotek",
"sort": "Sortera",
"sortBy": "Sortera efter",
"filters": "Filter",
"loadingLibraryWithCount": "Laddar bibliotek... (${count} objekt laddade)",
"confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?",
"showLibrary": "Visa bibliotek",
"hideLibrary": "Dölj bibliotek",
"libraryOptions": "Biblioteksalternativ",
"content": "bibliotekets innehåll",
"selectLibrary": "Välj bibliotek",
"filtersWithCount": "Filter (${count})",
"noRecommendations": "Inga rekommendationer tillgängliga",
"noCollections": "Inga samlingar i det här biblioteket",
"noFoldersFound": "Inga mappar hittades",
"folders": "mappar",
"tabs": {
"recommended": "Rekommenderat",
"browse": "Bläddra",
"collections": "Samlingar",
"playlists": "Spellistor"
},
"groupings": {
"all": "Alla",
"movies": "Filmer",
"shows": "Serier",
"seasons": "Säsonger",
"episodes": "Avsnitt",
"folders": "Mappar"
}
},
"about": {
"title": "Om",
"openSourceLicenses": "Öppen källkod-licenser",
"versionLabel": "Version ${version}",
"appDescription": "En vacker Plex-klient för Flutter",
"viewLicensesDescription": "Visa licenser för tredjepartsbibliotek"
},
"serverSelection": {
"connectingToServer": "Ansluter till server...",
"connectingToAllServers": "Ansluter till alla servrar...",
"connectToAllServers": "Anslut till alla servrar",
"allServerConnectionsFailed": "Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.",
"foundServers": "Hittade ${count} server(ar)",
"orSelectIndividually": "Eller välj en server individuellt:",
"serverDebugCopied": "Server-felsökningsdata kopierad till urklipp",
"copyDebugData": "Kopiera felsökningsdata",
"noServersFound": "Inga servrar hittades",
"malformedServerData": "Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.",
"incompleteServerInfo": "Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.",
"incompleteConnectionInfo": "Server-anslutningsinformation är ofullständig. Försök igen.",
"malformedServerInfo": "Serverinformation är felformaterad: ${message}",
"networkConnectionFailed": "Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.",
"authenticationFailed": "Autentisering misslyckades. Logga in igen.",
"plexServiceUnavailable": "Plex-tjänst otillgänglig. Försök igen senare.",
"failedToLoadServers": "Misslyckades att ladda servrar: ${error}"
},
"hubDetail": {
"title": "Titel",
"releaseYear": "Utgivningsår",
"dateAdded": "Datum tillagd",
"rating": "Betyg",
"noItemsFound": "Inga objekt hittades"
},
"logs": {
"title": "Loggar",
"clearLogs": "Rensa loggar",
"copyLogs": "Kopiera loggar",
"exportLogs": "Exportera loggar",
"noLogsToShow": "Inga loggar att visa",
"error": "Fel:",
"stackTrace": "Stack trace:"
},
"licenses": {
"relatedPackages": "Relaterade paket",
"license": "Licens",
"licenseNumber": "Licens ${number}",
"licensesCount": "${count} licenser"
},
"navigation": {
"home": "Hem",
"search": "Sök",
"libraries": "Bibliotek",
"settings": "Inställningar"
},
"playlists": {
"title": "Spellistor",
"noPlaylists": "Inga spellistor hittades",
"create": "Skapa spellista",
"newPlaylist": "Ny spellista",
"playlistName": "Spellistans namn",
"enterPlaylistName": "Ange spellistans namn",
"edit": "Redigera spellista",
"delete": "Ta bort spellista",
"addTo": "Lägg till i spellista",
"addItems": "Lägg till objekt",
"removeItem": "Ta bort från spellista",
"clearPlaylist": "Rensa spellista",
"playAll": "Spela alla",
"shuffle": "Blanda",
"smartPlaylist": "Smart spellista",
"regularPlaylist": "Vanlig spellista",
"itemCount": "${count} objekt",
"oneItem": "1 objekt",
"emptyPlaylist": "Denna spellista är tom",
"deleteConfirm": "Ta bort spellista?",
"deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?",
"created": "Spellista skapad",
"updated": "Spellista uppdaterad",
"deleted": "Spellista borttagen",
"itemAdded": "Tillagd i spellista",
"itemRemoved": "Borttagen från spellista",
"selectPlaylist": "Välj spellista",
"createNewPlaylist": "Skapa ny spellista",
"errorCreating": "Det gick inte att skapa spellista",
"errorDeleting": "Det gick inte att ta bort spellista",
"errorLoading": "Det gick inte att ladda spellistor",
"errorAdding": "Det gick inte att lägga till i spellista",
"errorReordering": "Det gick inte att omordna spellisteobjekt",
"errorRemoving": "Det gick inte att ta bort från spellista",
"playlist": "Spellista"
},
"collections": {
"title": "Samlingar",
"collection": "Samling",
"empty": "Samlingen är tom",
"noItems": "Inga objekt i den här samlingen",
"unknownLibrarySection": "Kan inte ta bort: okänd bibliotekssektion",
"deleteCollection": "Ta bort samling",
"deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Detta går inte att ångra.",
"deleted": "Samling borttagen",
"deleteFailed": "Det gick inte att ta bort samlingen",
"deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}",
"failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}",
"addTo": "Lägg till i samling",
"selectCollection": "Välj samling",
"createNewCollection": "Skapa ny samling",
"collectionName": "Samlingsnamn",
"enterCollectionName": "Ange samlingsnamn",
"addedToCollection": "Tillagd i samling",
"errorAddingToCollection": "Fel vid tillägg i samling",
"created": "Samling skapad",
"removeFromCollection": "Ta bort från samling",
"removeFromCollectionConfirm": "Ta bort \"${title}\" från denna samling?",
"removedFromCollection": "Borttagen från samling",
"removeFromCollectionFailed": "Misslyckades med att ta bort från samling",
"removeFromCollectionError": "Fel vid borttagning från samling: ${error}"
}
}
+510
View File
@@ -0,0 +1,510 @@
{
"app": {
"title": "Plezy",
"loading": "加载中..."
},
"auth": {
"signInWithPlex": "使用 Plex 登录",
"showQRCode": "显示二维码",
"cancel": "取消",
"authenticate": "验证",
"retry": "重试",
"debugEnterToken": "调试:输入 Plex Token",
"plexTokenLabel": "Plex 授权令牌 (Auth Token)",
"plexTokenHint": "输入你的 Plex.tv 令牌",
"authenticationTimeout": "验证超时。请重试。",
"scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。",
"waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。"
},
"common": {
"cancel": "取消",
"save": "保存",
"close": "关闭",
"clear": "清除",
"reset": "重置",
"later": "稍后",
"submit": "提交",
"confirm": "确认",
"retry": "重试",
"playNow": "立即播放",
"logout": "登出",
"online": "在线",
"offline": "离线",
"owned": "拥有",
"shared": "共享",
"current": "当前",
"unknown": "未知",
"refresh": "刷新",
"yes": "是",
"no": "否",
"server": "服务器",
"delete": "删除",
"shuffle": "随机播放",
"addTo": "添加到..."
},
"screens": {
"licenses": "许可证",
"selectServer": "选择服务器",
"switchProfile": "切换配置文件",
"subtitleStyling": "字幕样式",
"search": "搜索",
"logs": "日志"
},
"update": {
"available": "有可用更新",
"versionAvailable": "版本 ${version} 已发布",
"currentVersion": "当前版本: ${version}",
"skipVersion": "跳过此版本",
"viewRelease": "查看发布详情",
"latestVersion": "已安装的版本是可用的最新版本",
"checkFailed": "无法检查更新"
},
"settings": {
"title": "设置",
"language": "语言",
"theme": "主题",
"appearance": "外观",
"videoPlayback": "视频播放",
"shufflePlay": "随机播放",
"advanced": "高级",
"useSeasonPostersDescription": "为剧集显示季海报而非剧集海报",
"showHeroSectionDescription": "在主屏幕上显示精选内容轮播区",
"secondsLabel": "秒",
"minutesLabel": "分钟",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "输入时长 (${min}-${max})",
"systemTheme": "系统",
"systemThemeDescription": "跟随系统设置",
"lightTheme": "浅色",
"darkTheme": "深色",
"libraryDensity": "媒体库密度",
"compact": "紧凑",
"compactDescription": "卡片更小,显示更多项目",
"normal": "标准",
"normalDescription": "默认尺寸",
"comfortable": "舒适",
"comfortableDescription": "卡片更大,显示更少项目",
"viewMode": "视图模式",
"gridView": "网格视图",
"gridViewDescription": "以网格布局显示项目",
"listView": "列表视图",
"listViewDescription": "以列表布局显示项目",
"useSeasonPosters": "使用季海报",
"showHeroSection": "显示主要精选区",
"hardwareDecoding": "硬件解码",
"hardwareDecodingDescription": "如果可用,使用硬件加速",
"bufferSize": "缓冲区大小",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "字幕样式",
"subtitleStylingDescription": "调整字幕外观",
"smallSkipDuration": "短跳过时长",
"largeSkipDuration": "长跳过时长",
"secondsUnit": "${seconds} 秒",
"defaultSleepTimer": "默认睡眠定时器",
"minutesUnit": "${minutes} 分钟",
"rememberTrackSelections": "记住每个剧集/电影的音轨选择",
"rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好",
"unwatchedOnly": "仅未观看",
"unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集",
"shuffleOrderNavigation": "随机顺序导航",
"shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序",
"loopShuffleQueue": "循环随机队列",
"loopShuffleQueueDescription": "在队列结束时重新开始",
"videoPlayerControls": "视频播放器控制",
"keyboardShortcuts": "键盘快捷键",
"keyboardShortcutsDescription": "自定义键盘快捷键",
"debugLogging": "调试日志",
"debugLoggingDescription": "启用详细日志记录以便故障排除",
"viewLogs": "查看日志",
"viewLogsDescription": "查看应用程序日志",
"clearCache": "清除缓存",
"clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。",
"clearCacheSuccess": "缓存清除成功",
"resetSettings": "重置设置",
"resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。",
"resetSettingsSuccess": "设置重置成功",
"shortcutsReset": "快捷键已重置为默认值",
"about": "关于",
"aboutDescription": "应用程序信息和许可证",
"updates": "更新",
"updateAvailable": "有可用更新",
"checkForUpdates": "检查更新",
"validationErrorEnterNumber": "请输入一个有效的数字",
"validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间",
"shortcutAlreadyAssigned": "快捷键已被分配给 ${action}",
"shortcutUpdated": "快捷键已为 ${action} 更新"
},
"search": {
"hint": "搜索电影、系列、音乐...",
"tryDifferentTerm": "尝试不同的搜索词",
"searchYourMedia": "搜索媒体",
"enterTitleActorOrKeyword": "输入标题、演员或关键词"
},
"hotkeys": {
"setShortcutFor": "为 ${actionName} 设置快捷键",
"clearShortcut": "清除快捷键"
},
"pinEntry": {
"enterPin": "输入 PIN",
"showPin": "显示 PIN",
"hidePin": "隐藏 PIN"
},
"fileInfo": {
"title": "文件信息",
"video": "视频",
"audio": "音频",
"file": "文件",
"advanced": "高级",
"codec": "编解码器",
"resolution": "分辨率",
"bitrate": "比特率",
"frameRate": "帧率",
"aspectRatio": "宽高比",
"profile": "配置文件",
"bitDepth": "位深度",
"colorSpace": "色彩空间",
"colorRange": "色彩范围",
"colorPrimaries": "颜色原色",
"chromaSubsampling": "色度子采样",
"channels": "声道",
"path": "路径",
"size": "大小",
"container": "容器",
"duration": "时长",
"optimizedForStreaming": "已优化用于流媒体",
"has64bitOffsets": "64位偏移量"
},
"mediaMenu": {
"markAsWatched": "标记为已观看",
"markAsUnwatched": "标记为未观看",
"removeFromContinueWatching": "从继续观看中移除",
"goToSeries": "转到系列",
"goToSeason": "转到季",
"shufflePlay": "随机播放",
"fileInfo": "文件信息"
},
"accessibility": {
"mediaCardMovie": "${title}, 电影",
"mediaCardShow": "${title}, 电视剧",
"mediaCardEpisode": "${title}, ${episodeInfo}",
"mediaCardSeason": "${title}, ${seasonInfo}",
"mediaCardWatched": "已观看",
"mediaCardPartiallyWatched": "已观看 ${percent} 百分比",
"mediaCardUnwatched": "未观看",
"tapToPlay": "点击播放"
},
"tooltips": {
"shufflePlay": "随机播放",
"markAsWatched": "标记为已观看",
"markAsUnwatched": "标记为未观看"
},
"videoControls": {
"audioLabel": "音频",
"subtitlesLabel": "字幕",
"resetToZero": "重置为 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} 播放较晚",
"playsEarlier": "${label} 播放较早",
"noOffset": "无偏移",
"letterbox": "信箱模式(Letterbox",
"fillScreen": "填充屏幕",
"stretch": "拉伸",
"lockRotation": "锁定旋转",
"unlockRotation": "解锁旋转",
"sleepTimer": "睡眠定时器",
"timerActive": "定时器已激活",
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停",
"playButton": "播放",
"pauseButton": "暂停",
"seekBackwardButton": "后退 ${seconds} 秒",
"seekForwardButton": "前进 ${seconds} 秒",
"previousButton": "上一集",
"nextButton": "下一集",
"previousChapterButton": "上一章节",
"nextChapterButton": "下一章节",
"volumeButton": "音量",
"muteButton": "静音",
"unmuteButton": "取消静音",
"settingsButton": "视频设置",
"audioTrackButton": "音轨",
"subtitlesButton": "字幕",
"chaptersButton": "章节",
"versionsButton": "视频版本",
"aspectRatioButton": "宽高比",
"fullscreenButton": "进入全屏",
"exitFullscreenButton": "退出全屏",
"rotationLockButton": "旋转锁定",
"timelineSlider": "视频时间轴",
"volumeSlider": "音量调节",
"backButton": "返回"
},
"userStatus": {
"admin": "管理员",
"restricted": "受限",
"protected": "受保护",
"current": "当前"
},
"messages": {
"markedAsWatched": "已标记为已观看",
"markedAsUnwatched": "已标记为未观看",
"removedFromContinueWatching": "已从继续观看中移除",
"errorLoading": "错误: ${error}",
"fileInfoNotAvailable": "文件信息不可用",
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
"errorLoadingSeries": "加载系列时出错",
"errorLoadingSeason": "加载季时出错",
"musicNotSupported": "尚不支持播放音乐",
"logsCleared": "日志已清除",
"logsCopied": "日志已复制到剪贴板",
"noLogsAvailable": "没有可用日志",
"libraryScanning": "正在扫描 “${title}”...",
"libraryScanStarted": "已开始扫描 “${title}” 媒体库",
"libraryScanFailed": "无法扫描媒体库: ${error}",
"metadataRefreshing": "正在刷新 “${title}” 的元数据...",
"metadataRefreshStarted": "已开始刷新 “${title}” 的元数据",
"metadataRefreshFailed": "无法刷新元数据: ${error}",
"noPlexToken": "未找到 Plex 令牌。请重新登录。",
"logoutConfirm": "你确定要登出吗?",
"noSeasonsFound": "未找到季",
"noEpisodesFound": "在第一季中未找到剧集",
"noEpisodesFoundGeneral": "未找到剧集",
"noResultsFound": "未找到结果",
"sleepTimerSet": "睡眠定时器已设置为 ${label}",
"failedToSwitchProfile": "无法切换到 ${displayName}",
"noItemsAvailable": "没有可用的项目",
"failedToCreatePlayQueue": "创建播放队列失败",
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
"failedPlayback": "无法${action}: ${error}"
},
"profile": {
"noUsersAvailable": "没有可用用户"
},
"subtitlingStyling": {
"stylingOptions": "样式选项",
"fontSize": "字号",
"textColor": "文本颜色",
"borderSize": "边框大小",
"borderColor": "边框颜色",
"backgroundOpacity": "背景不透明度",
"backgroundColor": "背景颜色"
},
"dialog": {
"confirmAction": "确认操作",
"areYouSure": "确定要执行此操作吗?",
"cancel": "取消",
"playNow": "立即播放"
},
"discover": {
"title": "发现",
"switchProfile": "切换配置文件",
"switchServer": "切换服务器",
"logout": "登出",
"noContentAvailable": "没有可用内容",
"addMediaToLibraries": "请向你的媒体库添加一些媒体",
"continueWatching": "继续观看",
"recentlyAdded": "最近添加",
"play": "播放",
"resume": "继续",
"playEpisode": "播放 S${season}, E${episode}",
"resumeEpisode": "继续 S${season}, E${episode}",
"pause": "暂停",
"overview": "概述",
"cast": "演员表",
"seasons": "季数",
"studio": "制作公司",
"rating": "年龄分级",
"watched": "已观看",
"episodeCount": "${count} 集",
"watchedProgress": "已观看 ${watched}/${total} 集",
"movie": "电影",
"tvShow": "电视剧",
"minutesLeft": "剩余 ${minutes} 分钟"
},
"errors": {
"searchFailed": "搜索失败: ${error}",
"connectionTimeout": "加载 ${context} 时连接超时",
"connectionFailed": "无法连接到 Plex 服务器",
"failedToLoad": "无法加载 ${context}: ${error}",
"noClientAvailable": "没有可用客户端",
"authenticationFailed": "验证失败: ${error}",
"couldNotLaunchUrl": "无法打开授权 URL",
"pleaseEnterToken": "请输入一个令牌",
"invalidToken": "令牌无效",
"failedToVerifyToken": "无法验证令牌: ${error}",
"failedToSwitchProfile": "无法切换到 ${displayName}",
"connectionFailedGeneric": "连接失败"
},
"libraries": {
"title": "媒体库",
"scanLibraryFiles": "扫描媒体库文件",
"scanLibrary": "扫描媒体库",
"analyze": "分析",
"analyzeLibrary": "分析媒体库",
"refreshMetadata": "刷新元数据",
"emptyTrash": "清空回收站",
"emptyingTrash": "正在清空 “${title}” 的回收站...",
"trashEmptied": "已清空 “${title}” 的回收站",
"failedToEmptyTrash": "无法清空回收站: ${error}",
"analyzing": "正在分析 “${title}”...",
"analysisStarted": "已开始分析 “${title}”",
"failedToAnalyze": "无法分析媒体库: ${error}",
"noLibrariesFound": "未找到媒体库",
"thisLibraryIsEmpty": "此媒体库为空",
"all": "全部",
"clearAll": "全部清除",
"scanLibraryConfirm": "确定要扫描 “${title}” 吗?",
"analyzeLibraryConfirm": "确定要分析 “${title}” 吗?",
"refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?",
"emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?",
"manageLibraries": "管理媒体库",
"sort": "排序",
"sortBy": "排序依据",
"filters": "筛选器",
"loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)",
"confirmActionMessage": "确定要执行此操作吗?",
"showLibrary": "显示媒体库",
"hideLibrary": "隐藏媒体库",
"libraryOptions": "媒体库选项",
"content": "媒体库内容",
"selectLibrary": "选择媒体库",
"filtersWithCount": "筛选器(${count}",
"noRecommendations": "暂无推荐",
"noCollections": "此媒体库中没有合集",
"noFoldersFound": "未找到文件夹",
"folders": "文件夹",
"tabs": {
"recommended": "推荐",
"browse": "浏览",
"collections": "合集",
"playlists": "播放列表"
},
"groupings": {
"all": "全部",
"movies": "电影",
"shows": "剧集",
"seasons": "季",
"episodes": "集",
"folders": "文件夹"
}
},
"about": {
"title": "关于",
"openSourceLicenses": "开源许可证",
"versionLabel": "版本 ${version}",
"appDescription": "一款精美的 Flutter Plex 客户端",
"viewLicensesDescription": "查看第三方库的许可证"
},
"serverSelection": {
"connectingToServer": "正在连接服务器...",
"connectingToAllServers": "正在连接所有服务器...",
"connectToAllServers": "连接所有服务器",
"allServerConnectionsFailed": "无法连接到任何服务器。请检查你的网络并重试。",
"foundServers": "找到 ${count} 个服务器",
"orSelectIndividually": "或单独选择一个服务器:",
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
"copyDebugData": "复制调试数据",
"noServersFound": "未找到服务器",
"malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。",
"incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。",
"incompleteConnectionInfo": "服务器连接信息不完整。请重试。",
"malformedServerInfo": "服务器信息格式错误: ${message}",
"networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。",
"authenticationFailed": "验证失败。请重新登录。",
"plexServiceUnavailable": "Plex 服务不可用。请稍后重试。",
"failedToLoadServers": "无法加载服务器: ${error}"
},
"hubDetail": {
"title": "标题",
"releaseYear": "发行年份",
"dateAdded": "添加日期",
"rating": "评分",
"noItemsFound": "未找到项目"
},
"logs": {
"title": "日志",
"clearLogs": "清除日志",
"copyLogs": "复制日志",
"exportLogs": "导出日志",
"noLogsToShow": "没有可显示的日志",
"error": "错误:",
"stackTrace": "堆栈跟踪 (Stack Trace):"
},
"licenses": {
"relatedPackages": "相关软件包",
"license": "许可证",
"licenseNumber": "许可证 ${number}",
"licensesCount": "${count} 个许可证"
},
"navigation": {
"home": "主页",
"search": "搜索",
"libraries": "媒体库",
"settings": "设置"
},
"playlists": {
"title": "播放列表",
"noPlaylists": "未找到播放列表",
"create": "创建播放列表",
"newPlaylist": "新播放列表",
"playlistName": "播放列表名称",
"enterPlaylistName": "输入播放列表名称",
"edit": "编辑播放列表",
"delete": "删除播放列表",
"addTo": "添加到播放列表",
"addItems": "添加项目",
"removeItem": "从播放列表中移除",
"clearPlaylist": "清空播放列表",
"playAll": "全部播放",
"shuffle": "随机播放",
"smartPlaylist": "智能播放列表",
"regularPlaylist": "普通播放列表",
"itemCount": "${count} 个项目",
"oneItem": "1 个项目",
"emptyPlaylist": "此播放列表为空",
"deleteConfirm": "删除播放列表?",
"deleteMessage": "确定要删除 \"${name}\" 吗?",
"created": "播放列表已创建",
"updated": "播放列表已更新",
"deleted": "播放列表已删除",
"itemAdded": "已添加到播放列表",
"itemRemoved": "已从播放列表中移除",
"selectPlaylist": "选择播放列表",
"createNewPlaylist": "创建新播放列表",
"errorCreating": "创建播放列表失败",
"errorDeleting": "删除播放列表失败",
"errorLoading": "加载播放列表失败",
"errorAdding": "添加到播放列表失败",
"errorReordering": "重新排序播放列表项目失败",
"errorRemoving": "从播放列表中移除失败",
"playlist": "播放列表"
},
"collections": {
"title": "合集",
"collection": "合集",
"empty": "合集为空",
"noItems": "此合集没有项目",
"unknownLibrarySection": "无法删除:未知的媒体库分区",
"deleteCollection": "删除合集",
"deleteConfirm": "确定要删除\"${title}\"吗?此操作无法撤销。",
"deleted": "已删除合集",
"deleteFailed": "删除合集失败",
"deleteFailedWithError": "删除合集失败:${error}",
"failedToLoadItems": "加载合集项目失败:${error}",
"addTo": "添加到合集",
"selectCollection": "选择合集",
"createNewCollection": "创建新合集",
"collectionName": "合集名称",
"enterCollectionName": "输入合集名称",
"addedToCollection": "已添加到合集",
"errorAddingToCollection": "添加到合集失败",
"created": "已创建合集",
"removeFromCollection": "从合集移除",
"removeFromCollectionConfirm": "将“${title}”从此合集移除?",
"removedFromCollection": "已从合集移除",
"removeFromCollectionFailed": "从合集移除失败",
"removeFromCollectionError": "从合集移除时出错:${error}"
}
}
+215 -83
View File
@@ -3,24 +3,44 @@ import 'dart:io' show Platform;
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'screens/main_screen.dart';
import 'screens/auth_screen.dart';
import 'services/storage_service.dart';
import 'services/plex_auth_service.dart';
import 'services/server_connection_service.dart';
import 'services/macos_titlebar_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'services/update_service.dart';
import 'services/settings_service.dart';
import 'providers/user_profile_provider.dart';
import 'providers/plex_client_provider.dart';
import 'providers/multi_server_provider.dart';
import 'providers/server_state_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/hidden_libraries_provider.dart';
import 'providers/playback_state_provider.dart';
import 'services/multi_server_manager.dart';
import 'services/data_aggregation_service.dart';
import 'services/server_registry.dart';
import 'utils/language_codes.dart';
import 'utils/app_logger.dart';
import 'utils/provider_extensions.dart';
import 'utils/orientation_helper.dart';
import 'i18n/strings.g.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize settings first to get saved locale
final settings = await SettingsService.getInstance();
final savedLocale = settings.getAppLocale();
// Initialize localization with saved locale
LocaleSettings.setLocale(savedLocale);
// Configure image cache for large libraries
PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB
PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images
// Initialize window_manager for desktop platforms
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
await windowManager.ensureInitialized();
@@ -39,6 +59,10 @@ void main() async {
// Initialize language codes for track selection
await LanguageCodes.initialize();
// Initialize logger level based on debug setting
final debugEnabled = settings.getEnableDebugLogging();
setLoggerLevel(debugEnabled);
// Start global fullscreen state monitoring
FullscreenStateManager().startMonitoring();
@@ -55,24 +79,41 @@ class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Initialize multi-server infrastructure
final serverManager = MultiServerManager();
final aggregationService = DataAggregationService(serverManager);
return MultiProvider(
providers: [
// Legacy provider for backward compatibility
ChangeNotifierProvider(create: (context) => PlexClientProvider()),
// New multi-server providers
ChangeNotifierProvider(
create: (context) =>
MultiServerProvider(serverManager, aggregationService),
),
ChangeNotifierProvider(create: (context) => ServerStateProvider()),
// Existing providers
ChangeNotifierProvider(
create: (context) => UserProfileProvider()..initialize(),
),
ChangeNotifierProvider(create: (context) => ThemeProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Plezy',
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const OrientationAwareSetup(),
return TranslationProvider(
child: MaterialApp(
title: t.app.title,
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const OrientationAwareSetup(),
),
);
},
),
@@ -118,97 +159,188 @@ class _SetupScreenState extends State<SetupScreen> {
_loadSavedCredentials();
}
void _checkForUpdatesOnStartup() async {
// Delay slightly to allow UI to settle
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
try {
final updateInfo = await UpdateService.checkForUpdatesOnStartup();
if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) {
_showUpdateDialog(updateInfo);
}
} catch (e) {
appLogger.e('Error checking for updates', error: e);
}
}
void _showUpdateDialog(Map<String, dynamic> updateInfo) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text(t.update.available),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.update.versionAvailable(version: updateInfo['latestVersion']),
style: Theme.of(dialogContext).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
t.update.currentVersion(version: updateInfo['currentVersion']),
style: Theme.of(dialogContext).textTheme.bodySmall,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
},
child: Text(t.common.later),
),
TextButton(
onPressed: () async {
await UpdateService.skipVersion(updateInfo['latestVersion']);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: Text(t.update.skipVersion),
),
FilledButton(
onPressed: () async {
final url = Uri.parse(updateInfo['releaseUrl']);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: Text(t.update.viewRelease),
),
],
);
},
);
}
Future<void> _loadSavedCredentials() async {
final storage = await StorageService.getInstance();
final registry = ServerRegistry(storage);
// Check if we have server data
final serverData = storage.getServerData();
final clientId = storage.getClientIdentifier();
final plexToken = storage.getPlexToken();
// Migrate from single-server to multi-server if needed
await registry.migrateFromSingleServer();
// Get current user's server token (prioritize over original server token)
final currentUserToken = storage.getToken();
// Load enabled servers
final servers = await registry.getEnabledServers();
if (serverData != null && clientId != null) {
try {
// Recreate PlexServer from stored data
final server = PlexServer.fromJson(serverData);
// Use current user's token if available, fallback to server's original token
final tokenToUse = currentUserToken ?? server.accessToken;
appLogger.d(
'App startup token selection: currentUserToken=${currentUserToken != null ? 'present' : 'null'}, using=${currentUserToken != null ? 'current user' : 'original server'} token',
if (servers.isEmpty) {
// No servers configured - show auth screen
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
);
// Create updated server with correct token for current user
final serverWithCurrentToken = PlexServer(
name: server.name,
clientIdentifier: server.clientIdentifier,
accessToken: tokenToUse,
connections: server.connections,
owned: server.owned,
product: server.product,
platform: server.platform,
lastSeenAt: server.lastSeenAt,
presence: server.presence,
);
// Connect using the optimized service with current user's token
final result = await ServerConnectionService.connectToServer(
serverWithCurrentToken,
clientIdentifier: clientId,
verifyServer: true,
fetchUserProfile: plexToken != null,
plexToken: plexToken,
);
// Handle result
if (result.isSuccess) {
// Success! Set client in provider and navigate to main screen
if (mounted) {
context.plexClient.setClient(result.client!);
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: result.client!),
),
);
}
}
return;
} else {
// Connection failed, clear credentials
await storage.clearCredentials();
}
} catch (e) {
// Error loading or testing server
appLogger.e('Error during auto-login', error: e);
await storage.clearCredentials();
}
return;
}
// No saved credentials or auto-login failed - show auth screen
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
// Get multi-server provider
if (!mounted) return;
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
try {
appLogger.i('Connecting to ${servers.length} enabled servers...');
// Get or generate client identifier
final clientId = storage.getClientIdentifier();
// Connect to all servers in parallel
final connectedCount = await multiServerProvider.serverManager
.connectToAllServers(
servers,
clientIdentifier: clientId,
timeout: const Duration(seconds: 10),
onServerConnected: (serverId, client) {
// Set first connected client in legacy provider for backward compatibility
final legacyProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
);
if (legacyProvider.client == null) {
legacyProvider.setClient(client);
}
},
);
if (connectedCount > 0) {
// At least one server connected successfully
appLogger.i('Successfully connected to $connectedCount servers');
if (mounted) {
// Check for updates BEFORE navigation
_checkForUpdatesOnStartup();
// Navigate to main screen
// Get first connected client for backward compatibility
final firstClient =
multiServerProvider.serverManager.onlineClients.values.first;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: firstClient),
),
);
}
} else {
// All connections failed
appLogger.w('Failed to connect to any servers');
if (mounted) {
// Show auth screen to re-authenticate
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
);
}
}
} catch (e, stackTrace) {
appLogger.e(
'Error during multi-server connection',
error: e,
stackTrace: stackTrace,
);
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
);
}
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading...'),
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(t.app.loading),
],
),
),
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import '../client/plex_client.dart';
import '../models/plex_library.dart';
import '../utils/provider_extensions.dart';
/// Mixin providing common functionality for library tab screens
/// Provides server-specific client resolution for multi-server support
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
/// The library being displayed
PlexLibrary get library;
/// Get the correct PlexClient for this library's server
/// Throws an exception if no client is available
PlexClient getClientForLibrary() => context.getClientForLibrary(library);
}
-31
View File
@@ -1,31 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'plex_metadata.dart';
import 'plex_library.dart';
part 'media_container.g.dart';
@JsonSerializable()
class MediaContainer<T> {
final int? size;
final int? totalSize;
final int? offset;
final String? identifier;
@JsonKey(name: 'Directory')
final List<PlexLibrary>? directories;
@JsonKey(name: 'Metadata')
final List<PlexMetadata>? metadata;
MediaContainer({
this.size,
this.totalSize,
this.offset,
this.identifier,
this.directories,
this.metadata,
});
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
_$MediaContainerFromJson(json);
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
}
-31
View File
@@ -1,31 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_container.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
MediaContainer<T>(
size: (json['size'] as num?)?.toInt(),
totalSize: (json['totalSize'] as num?)?.toInt(),
offset: (json['offset'] as num?)?.toInt(),
identifier: json['identifier'] as String?,
directories: (json['Directory'] as List<dynamic>?)
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
.toList(),
metadata: (json['Metadata'] as List<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
<String, dynamic>{
'size': instance.size,
'totalSize': instance.totalSize,
'offset': instance.offset,
'identifier': instance.identifier,
'Directory': instance.directories,
'Metadata': instance.metadata,
};
@@ -0,0 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
/// Mixin that provides multi-server support fields for models.
///
/// This mixin adds serverId and serverName fields that are excluded from
/// JSON serialization but can be used to track which server an item belongs to.
mixin MultiServerFields {
/// Server machine identifier (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverId;
/// Server display name (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverName;
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:json_annotation/json_annotation.dart';
import 'plex_metadata.dart';
part 'play_queue_response.g.dart';
/// Converter to handle both int (0/1) and bool values from Plex API
class BoolOrIntConverter implements JsonConverter<bool, Object> {
const BoolOrIntConverter();
@override
bool fromJson(Object json) {
if (json is bool) return json;
if (json is int) return json != 0;
if (json is String) return json.toLowerCase() == 'true' || json == '1';
return false;
}
@override
Object toJson(bool object) => object;
}
/// Response from Plex play queue API
/// Contains queue metadata and a window of items
@JsonSerializable(createToJson: false)
class PlayQueueResponse {
final int playQueueID;
final int? playQueueSelectedItemID;
final int? playQueueSelectedItemOffset;
final String? playQueueSelectedMetadataItemID;
@BoolOrIntConverter()
final bool playQueueShuffled;
final String? playQueueSourceURI;
final int? playQueueTotalCount;
final int playQueueVersion;
final int? size; // Number of items in this response window
@JsonKey(name: 'Metadata')
final List<PlexMetadata>? items;
PlayQueueResponse({
required this.playQueueID,
this.playQueueSelectedItemID,
this.playQueueSelectedItemOffset,
this.playQueueSelectedMetadataItemID,
required this.playQueueShuffled,
this.playQueueSourceURI,
required this.playQueueTotalCount,
required this.playQueueVersion,
this.size,
this.items,
});
factory PlayQueueResponse.fromJson(
Map<String, dynamic> json, {
String? serverId,
String? serverName,
}) {
// The API returns data wrapped in MediaContainer
final container = json['MediaContainer'] as Map<String, dynamic>? ?? json;
final response = _$PlayQueueResponseFromJson(container);
// Tag all items with server info
if (response.items != null && (serverId != null || serverName != null)) {
final taggedItems = response.items!
.map(
(item) => item.copyWith(serverId: serverId, serverName: serverName),
)
.toList();
return PlayQueueResponse(
playQueueID: response.playQueueID,
playQueueSelectedItemID: response.playQueueSelectedItemID,
playQueueSelectedItemOffset: response.playQueueSelectedItemOffset,
playQueueSelectedMetadataItemID:
response.playQueueSelectedMetadataItemID,
playQueueShuffled: response.playQueueShuffled,
playQueueSourceURI: response.playQueueSourceURI,
playQueueTotalCount: response.playQueueTotalCount,
playQueueVersion: response.playQueueVersion,
size: response.size,
items: taggedItems,
);
}
return response;
}
/// Get the current selected item from the queue
PlexMetadata? get selectedItem {
if (items == null || playQueueSelectedItemID == null) return null;
try {
return items!.firstWhere(
(item) => item.playQueueItemID == playQueueSelectedItemID,
);
} catch (e) {
return null;
}
}
/// Get the index of the selected item in the current window
int? get selectedItemIndex {
if (items == null || playQueueSelectedItemID == null) return null;
return items!.indexWhere(
(item) => item.playQueueItemID == playQueueSelectedItemID,
);
}
}
+28
View File
@@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'play_queue_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
PlayQueueResponse(
playQueueID: (json['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
?.toInt(),
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
?.toInt(),
playQueueSelectedMetadataItemID:
json['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: const BoolOrIntConverter().fromJson(
json['playQueueShuffled'] as Object,
),
playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
+152
View File
@@ -0,0 +1,152 @@
class PlexFileInfo {
// Media level properties
final String? container;
final String? videoCodec;
final String? videoResolution;
final String? videoFrameRate;
final String? videoProfile;
final int? width;
final int? height;
final double? aspectRatio;
final int? bitrate;
final int? duration;
final String? audioCodec;
final String? audioProfile;
final int? audioChannels;
final bool? optimizedForStreaming;
final bool? has64bitOffsets;
// Part level properties (file)
final String? filePath;
final int? fileSize;
// Stream level properties (video stream details)
final String? colorSpace;
final String? colorRange;
final String? colorPrimaries;
final String? colorTrc;
final String? chromaSubsampling;
final double? frameRate;
final int? bitDepth;
final String? audioChannelLayout;
PlexFileInfo({
this.container,
this.videoCodec,
this.videoResolution,
this.videoFrameRate,
this.videoProfile,
this.width,
this.height,
this.aspectRatio,
this.bitrate,
this.duration,
this.audioCodec,
this.audioProfile,
this.audioChannels,
this.optimizedForStreaming,
this.has64bitOffsets,
this.filePath,
this.fileSize,
this.colorSpace,
this.colorRange,
this.colorPrimaries,
this.colorTrc,
this.chromaSubsampling,
this.frameRate,
this.bitDepth,
this.audioChannelLayout,
});
/// Format file size in human-readable format (GB, MB, KB, bytes)
String get fileSizeFormatted {
if (fileSize == null) return 'Unknown';
const kb = 1024;
const mb = kb * 1024;
const gb = mb * 1024;
if (fileSize! >= gb) {
return '${(fileSize! / gb).toStringAsFixed(2)} GB';
} else if (fileSize! >= mb) {
return '${(fileSize! / mb).toStringAsFixed(2)} MB';
} else if (fileSize! >= kb) {
return '${(fileSize! / kb).toStringAsFixed(2)} KB';
} else {
return '$fileSize bytes';
}
}
/// Format duration in HH:MM:SS or MM:SS format
String get durationFormatted {
if (duration == null) return 'Unknown';
final seconds = duration! ~/ 1000;
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m ${secs}s';
} else {
return '${minutes}m ${secs}s';
}
}
/// Format bitrate in Mbps or Kbps
String get bitrateFormatted {
if (bitrate == null) return 'Unknown';
const kbps = 1000;
const mbps = kbps * 1000;
if (bitrate! >= mbps) {
return '${(bitrate! / mbps).toStringAsFixed(2)} Mbps';
} else if (bitrate! >= kbps) {
return '${(bitrate! / kbps).toStringAsFixed(2)} Kbps';
} else {
return '$bitrate bps';
}
}
/// Format resolution as widthxheight
String get resolutionFormatted {
if (width != null && height != null) {
return '${width}x$height';
} else if (videoResolution != null) {
return videoResolution!;
}
return 'Unknown';
}
/// Format aspect ratio
String get aspectRatioFormatted {
if (aspectRatio != null) {
return aspectRatio!.toStringAsFixed(2);
}
return 'Unknown';
}
/// Format frame rate
String get frameRateFormatted {
if (frameRate != null) {
return '${frameRate!.toStringAsFixed(3)} fps';
} else if (videoFrameRate != null) {
return videoFrameRate!;
}
return 'Unknown';
}
/// Format audio channels (e.g., "2 channels (stereo)")
String get audioChannelsFormatted {
if (audioChannels != null) {
String channelText =
'$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
if (audioChannelLayout != null) {
channelText += ' ($audioChannelLayout)';
}
return channelText;
}
return 'Unknown';
}
}
+1 -1
View File
@@ -70,5 +70,5 @@ class PlexHomeUser {
bool get isAdminUser => admin;
bool get isRestrictedUser => restricted;
bool get isGuestUser => guest;
bool get requiresPassword => hasPassword;
bool get requiresPassword => protected;
}
+58
View File
@@ -0,0 +1,58 @@
import 'plex_metadata.dart';
/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers)
class PlexHub {
final String hubKey;
final String title;
final String type;
final String? hubIdentifier;
final int size;
final bool more;
final List<PlexMetadata> items;
// Multi-server support fields
final String? serverId; // Server machine identifier
final String? serverName; // Server display name
PlexHub({
required this.hubKey,
required this.title,
required this.type,
this.hubIdentifier,
required this.size,
required this.more,
required this.items,
this.serverId,
this.serverName,
});
factory PlexHub.fromJson(Map<String, dynamic> json) {
final metadataList = <PlexMetadata>[];
// Helper function to parse entries from a JSON list
void parseEntries(List? entries) {
if (entries == null) return;
for (final item in entries) {
try {
metadataList.add(PlexMetadata.fromJson(item));
} catch (e) {
// Skip items that fail to parse
}
}
}
// Hubs can contain either Metadata or Directory entries
parseEntries(json['Metadata'] as List?);
parseEntries(json['Directory'] as List?);
return PlexHub(
hubKey: json['key'] as String? ?? '',
title: json['title'] as String? ?? 'Unknown',
type: json['type'] as String? ?? 'hub',
hubIdentifier: json['hubIdentifier'] as String?,
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
more: json['more'] == true || json['more'] == 1,
items: metadataList,
);
}
}
+49 -1
View File
@@ -1,9 +1,11 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
part 'plex_library.g.dart';
@JsonSerializable()
class PlexLibrary {
class PlexLibrary with MultiServerFields {
final String key;
final String title;
final String type;
@@ -13,6 +15,18 @@ class PlexLibrary {
final String? uuid;
final int? updatedAt;
final int? createdAt;
final int? hidden;
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
/// Global unique identifier across all servers (serverId:key)
String get globalKey => serverId != null ? '$serverId:$key' : key;
PlexLibrary({
required this.key,
@@ -24,10 +38,44 @@ class PlexLibrary {
this.uuid,
this.updatedAt,
this.createdAt,
this.hidden,
this.serverId,
this.serverName,
});
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
_$PlexLibraryFromJson(json);
Map<String, dynamic> toJson() => _$PlexLibraryToJson(this);
/// Create a copy of this library with optional field overrides
PlexLibrary copyWith({
String? key,
String? title,
String? type,
String? agent,
String? scanner,
String? language,
String? uuid,
int? updatedAt,
int? createdAt,
int? hidden,
String? serverId,
String? serverName,
}) {
return PlexLibrary(
key: key ?? this.key,
title: title ?? this.title,
type: type ?? this.type,
agent: agent ?? this.agent,
scanner: scanner ?? this.scanner,
language: language ?? this.language,
uuid: uuid ?? this.uuid,
updatedAt: updatedAt ?? this.updatedAt,
createdAt: createdAt ?? this.createdAt,
hidden: hidden ?? this.hidden,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
);
}
}
+2
View File
@@ -16,6 +16,7 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
uuid: json['uuid'] as String?,
updatedAt: (json['updatedAt'] as num?)?.toInt(),
createdAt: (json['createdAt'] as num?)?.toInt(),
hidden: (json['hidden'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
@@ -29,4 +30,5 @@ Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
+109 -13
View File
@@ -12,13 +12,40 @@ class PlexMediaInfo {
});
}
class PlexAudioTrack {
/// Mixin for building track labels with a consistent pattern
mixin TrackLabelBuilder {
int get id;
int? get index;
String? get displayTitle;
String? get language;
/// Builds a label from the given parts
/// If displayTitle is present, returns it
/// Otherwise, combines language and additional parts
String buildLabel(List<String> additionalParts) {
if (displayTitle != null && displayTitle!.isNotEmpty) {
return displayTitle!;
}
final parts = <String>[];
if (language != null && language!.isNotEmpty) {
parts.add(language!);
}
parts.addAll(additionalParts);
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
}
}
class PlexAudioTrack with TrackLabelBuilder {
@override
final int id;
@override
final int? index;
final String? codec;
@override
final String? language;
final String? languageCode;
final String? title;
@override
final String? displayTitle;
final int? channels;
final bool selected;
@@ -36,22 +63,24 @@ class PlexAudioTrack {
});
String get label {
if (displayTitle != null) return displayTitle!;
final parts = <String>[];
if (language != null) parts.add(language!);
if (codec != null) parts.add(codec!.toUpperCase());
if (channels != null) parts.add('${channels!}ch');
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
final additionalParts = <String>[];
if (codec != null) additionalParts.add(codec!.toUpperCase());
if (channels != null) additionalParts.add('${channels!}ch');
return buildLabel(additionalParts);
}
}
class PlexSubtitleTrack {
class PlexSubtitleTrack with TrackLabelBuilder {
@override
final int id;
@override
final int? index;
final String? codec;
@override
final String? language;
final String? languageCode;
final String? title;
@override
final String? displayTitle;
final bool selected;
final bool forced;
@@ -71,11 +100,53 @@ class PlexSubtitleTrack {
});
String get label {
if (displayTitle != null) return displayTitle!;
final parts = <String>[];
if (language != null) parts.add(language!);
if (forced) parts.add('Forced');
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
final additionalParts = <String>[];
if (forced) additionalParts.add('Forced');
return buildLabel(additionalParts);
}
/// Returns true if this subtitle track is an external file (sidecar subtitle)
/// External subtitles have a key property that points to /library/streams/{id}
bool get isExternal => key != null && key!.isNotEmpty;
/// Constructs the full URL for fetching external subtitle files
/// Returns null if this is not an external subtitle
String? getSubtitleUrl(String baseUrl, String token) {
if (!isExternal) return null;
// Determine file extension based on codec
final ext = _getExtensionFromCodec(codec);
// Construct URL with authentication token
return '$baseUrl$key.$ext?X-Plex-Token=$token';
}
/// Maps Plex subtitle codec names to file extensions
String _getExtensionFromCodec(String? codec) {
if (codec == null) return 'srt';
switch (codec.toLowerCase()) {
case 'subrip':
case 'srt':
return 'srt';
case 'ass':
return 'ass';
case 'ssa':
return 'ssa';
case 'webvtt':
case 'vtt':
return 'vtt';
case 'mov_text':
return 'srt';
case 'pgs':
case 'hdmv_pgs_subtitle':
return 'sup';
case 'dvd_subtitle':
case 'dvdsub':
return 'sub';
default:
return 'srt'; // Default to SRT for unknown codecs
}
}
}
@@ -102,3 +173,28 @@ class PlexChapter {
Duration? get endTime =>
endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
}
class PlexMarker {
final int id;
final String type;
final int startTimeOffset;
final int endTimeOffset;
PlexMarker({
required this.id,
required this.type,
required this.startTimeOffset,
required this.endTimeOffset,
});
Duration get startTime => Duration(milliseconds: startTimeOffset);
Duration get endTime => Duration(milliseconds: endTimeOffset);
bool get isIntro => type == 'intro';
bool get isCredits => type == 'credits';
bool containsPosition(Duration position) {
final posMs = position.inMilliseconds;
return posMs >= startTimeOffset && posMs <= endTimeOffset;
}
}
+77
View File
@@ -0,0 +1,77 @@
class PlexMediaVersion {
final int id;
final String? videoResolution;
final String? videoCodec;
final int? bitrate;
final int? width;
final int? height;
final String? container;
final String partKey;
PlexMediaVersion({
required this.id,
this.videoResolution,
this.videoCodec,
this.bitrate,
this.width,
this.height,
this.container,
required this.partKey,
});
/// Creates a PlexMediaVersion from Plex API Media object
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
// Get the first Part key for playback
final parts = json['Part'] as List<dynamic>?;
final partKey = parts != null && parts.isNotEmpty
? parts[0]['key'] as String? ?? ''
: '';
return PlexMediaVersion(
id: json['id'] as int? ?? 0,
videoResolution: json['videoResolution'] as String?,
videoCodec: json['videoCodec'] as String?,
bitrate: json['bitrate'] as int?,
width: json['width'] as int?,
height: json['height'] as int?,
container: json['container'] as String?,
partKey: partKey,
);
}
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)"
String get displayLabel {
final parts = <String>[];
// Add resolution
if (videoResolution != null && videoResolution!.isNotEmpty) {
parts.add('${videoResolution}p');
} else if (height != null) {
parts.add('${height}p');
}
// Add codec
if (videoCodec != null && videoCodec!.isNotEmpty) {
parts.add(videoCodec!.toUpperCase());
}
// Add container
if (container != null && container!.isNotEmpty) {
parts.add(container!.toUpperCase());
}
// Build main label
String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown';
// Add bitrate in parentheses
if (bitrate != null && bitrate! > 0) {
final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1);
label += ' ($bitrateInMbps Mbps)';
}
return label;
}
@override
String toString() => displayLabel;
}
+142 -6
View File
@@ -1,9 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
import 'plex_role.dart';
part 'plex_metadata.g.dart';
@JsonSerializable()
class PlexMetadata {
class PlexMetadata with MultiServerFields {
final String ratingKey;
final String key;
final String? guid;
@@ -12,18 +15,21 @@ class PlexMetadata {
final String title;
final String? contentRating;
final String? summary;
final int? rating;
final double? rating;
final double? audienceRating;
final int? year;
final String? thumb;
final String? art;
final int? duration;
final int? addedAt;
final int? updatedAt;
final int? lastViewedAt; // Timestamp when item was last viewed
final String? grandparentTitle; // Show title for episodes
final String? grandparentThumb; // Show poster for episodes
final String? grandparentArt; // Show art for episodes
final String? grandparentRatingKey; // Show rating key for episodes
final String? parentTitle; // Season title for episodes
final String? parentThumb; // Season poster for episodes
final String? parentRatingKey; // Season rating key for episodes
final int? parentIndex; // Season number
final int? index; // Episode number
@@ -32,11 +38,30 @@ class PlexMetadata {
final int? viewCount;
final int? leafCount; // Total number of episodes in a series/season
final int? viewedLeafCount; // Number of watched episodes in a series/season
final int? childCount; // Number of items in a collection or playlist
@JsonKey(name: 'Role')
final List<PlexRole>? role; // Cast members
final String? audioLanguage; // Per-media preferred audio language
final String? subtitleLanguage; // Per-media preferred subtitle language
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
final int? librarySectionID; // Library section ID this item belongs to
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
// Transient field for clear logo (extracted from Image array)
String? _clearLogo;
String? get clearLogo => _clearLogo;
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
PlexMetadata({
required this.ratingKey,
required this.key,
@@ -47,17 +72,20 @@ class PlexMetadata {
this.contentRating,
this.summary,
this.rating,
this.audienceRating,
this.year,
this.thumb,
this.art,
this.duration,
this.addedAt,
this.updatedAt,
this.lastViewedAt,
this.grandparentTitle,
this.grandparentThumb,
this.grandparentArt,
this.grandparentRatingKey,
this.parentTitle,
this.parentThumb,
this.parentRatingKey,
this.parentIndex,
this.index,
@@ -66,8 +94,107 @@ class PlexMetadata {
this.viewCount,
this.leafCount,
this.viewedLeafCount,
this.childCount,
this.role,
this.audioLanguage,
this.subtitleLanguage,
this.playlistItemID,
this.playQueueItemID,
this.librarySectionID,
this.serverId,
this.serverName,
});
/// Create a copy of this metadata with optional field overrides
PlexMetadata copyWith({
String? ratingKey,
String? key,
String? guid,
String? studio,
String? type,
String? title,
String? contentRating,
String? summary,
double? rating,
double? audienceRating,
int? year,
String? thumb,
String? art,
int? duration,
int? addedAt,
int? updatedAt,
int? lastViewedAt,
String? grandparentTitle,
String? grandparentThumb,
String? grandparentArt,
String? grandparentRatingKey,
String? parentTitle,
String? parentThumb,
String? parentRatingKey,
int? parentIndex,
int? index,
String? grandparentTheme,
int? viewOffset,
int? viewCount,
int? leafCount,
int? viewedLeafCount,
int? childCount,
List<PlexRole>? role,
String? audioLanguage,
String? subtitleLanguage,
int? playlistItemID,
int? playQueueItemID,
int? librarySectionID,
String? serverId,
String? serverName,
}) {
final copy = PlexMetadata(
ratingKey: ratingKey ?? this.ratingKey,
key: key ?? this.key,
guid: guid ?? this.guid,
studio: studio ?? this.studio,
type: type ?? this.type,
title: title ?? this.title,
contentRating: contentRating ?? this.contentRating,
summary: summary ?? this.summary,
rating: rating ?? this.rating,
audienceRating: audienceRating ?? this.audienceRating,
year: year ?? this.year,
thumb: thumb ?? this.thumb,
art: art ?? this.art,
duration: duration ?? this.duration,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumb: grandparentThumb ?? this.grandparentThumb,
grandparentArt: grandparentArt ?? this.grandparentArt,
grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey,
parentTitle: parentTitle ?? this.parentTitle,
parentThumb: parentThumb ?? this.parentThumb,
parentRatingKey: parentRatingKey ?? this.parentRatingKey,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentTheme: grandparentTheme ?? this.grandparentTheme,
viewOffset: viewOffset ?? this.viewOffset,
viewCount: viewCount ?? this.viewCount,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
role: role ?? this.role,
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
playlistItemID: playlistItemID ?? this.playlistItemID,
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
);
// Preserve clearLogo
copy._clearLogo = _clearLogo;
return copy;
}
// Extract clearLogo from Image array in raw JSON
void _extractClearLogo(Map<String, dynamic> json) {
if (!json.containsKey('Image')) return;
@@ -121,12 +248,21 @@ class PlexMetadata {
}
// Helper to get the poster (show poster for episodes/seasons, thumb otherwise)
String? get posterThumb {
// If useSeasonPoster is true, episodes will use season poster instead of series poster
String? posterThumb({bool useSeasonPoster = false}) {
final itemType = type.toLowerCase();
// For episodes and seasons, prefer grandparent thumb (show poster)
if ((itemType == 'episode' || itemType == 'season') &&
grandparentThumb != null) {
if (itemType == 'episode') {
// If season poster is enabled and available, use it
if (useSeasonPoster && parentThumb != null) {
return parentThumb!;
}
// Otherwise fall back to series poster, then item thumb
if (grandparentThumb != null) {
return grandparentThumb!;
}
} else if (itemType == 'season' && grandparentThumb != null) {
// For seasons, always use series poster
return grandparentThumb!;
}
return thumb;
+23 -1
View File
@@ -15,18 +15,21 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
title: json['title'] as String,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toInt(),
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
year: (json['year'] as num?)?.toInt(),
thumb: json['thumb'] as String?,
art: json['art'] as String?,
duration: (json['duration'] as num?)?.toInt(),
addedAt: (json['addedAt'] as num?)?.toInt(),
updatedAt: (json['updatedAt'] as num?)?.toInt(),
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumb: json['grandparentThumb'] as String?,
grandparentArt: json['grandparentArt'] as String?,
grandparentRatingKey: json['grandparentRatingKey'] as String?,
parentTitle: json['parentTitle'] as String?,
parentThumb: json['parentThumb'] as String?,
parentRatingKey: json['parentRatingKey'] as String?,
parentIndex: (json['parentIndex'] as num?)?.toInt(),
index: (json['index'] as num?)?.toInt(),
@@ -35,6 +38,15 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
viewCount: (json['viewCount'] as num?)?.toInt(),
leafCount: (json['leafCount'] as num?)?.toInt(),
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
childCount: (json['childCount'] as num?)?.toInt(),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
@@ -48,17 +60,20 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'year': instance.year,
'thumb': instance.thumb,
'art': instance.art,
'duration': instance.duration,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'grandparentTitle': instance.grandparentTitle,
'grandparentThumb': instance.grandparentThumb,
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
@@ -67,4 +82,11 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'viewCount': instance.viewCount,
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'childCount': instance.childCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
};
+146
View File
@@ -0,0 +1,146 @@
import 'package:json_annotation/json_annotation.dart';
import 'mixins/multi_server_fields.dart';
part 'plex_playlist.g.dart';
@JsonSerializable()
class PlexPlaylist with MultiServerFields {
final String ratingKey;
final String key;
final String type; // "playlist"
final String title;
final String? summary;
final bool smart;
final String playlistType; // video, audio, photo
final int? duration;
final int? leafCount; // Number of items in playlist
final String? composite; // Composite thumbnail image
final int? addedAt;
final int? updatedAt;
final int? lastViewedAt;
final int? viewCount;
final String? content; // For smart playlists - generator URI
final String? guid;
final String? thumb;
// Multi-server support fields (from MultiServerFields mixin)
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
PlexPlaylist({
required this.ratingKey,
required this.key,
required this.type,
required this.title,
this.summary,
required this.smart,
required this.playlistType,
this.duration,
this.leafCount,
this.composite,
this.addedAt,
this.updatedAt,
this.lastViewedAt,
this.viewCount,
this.content,
this.guid,
this.thumb,
this.serverId,
this.serverName,
});
/// Helper to get display image (composite or thumb)
String? get displayImage => composite ?? thumb;
/// Helper to get display title (consistent with PlexMetadata)
String get displayTitle => title;
/// Helper to determine if playlist is editable
bool get isEditable => !smart;
/// Get globally unique key across all servers
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
// Properties for MediaCard compatibility with PlexMetadata interface
/// Playlists are not "watched" in the traditional sense
bool get isWatched => false;
/// Playlists don't have resume positions
int? get viewOffset => null;
/// Playlists don't have parent/episode indices
int? get parentIndex => null;
int? get index => null;
/// Playlists don't have parent titles or subtitles
String? get parentTitle => null;
String? get displaySubtitle => null;
/// Playlists don't have year, rating, or content metadata
int? get year => null;
String? get contentRating => null;
double? get rating => null;
String? get studio => null;
/// Use leafCount as the equivalent of childCount
int? get childCount => leafCount;
/// Playlists don't track viewed leaf count
int? get viewedLeafCount => null;
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
_$PlexPlaylistFromJson(json);
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
/// Create a copy with optional field updates
PlexPlaylist copyWith({
String? ratingKey,
String? key,
String? type,
String? title,
String? summary,
bool? smart,
String? playlistType,
int? duration,
int? leafCount,
String? composite,
int? addedAt,
int? updatedAt,
int? lastViewedAt,
int? viewCount,
String? content,
String? guid,
String? thumb,
String? serverId,
String? serverName,
}) {
return PlexPlaylist(
ratingKey: ratingKey ?? this.ratingKey,
key: key ?? this.key,
type: type ?? this.type,
title: title ?? this.title,
summary: summary ?? this.summary,
smart: smart ?? this.smart,
playlistType: playlistType ?? this.playlistType,
duration: duration ?? this.duration,
leafCount: leafCount ?? this.leafCount,
composite: composite ?? this.composite,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
viewCount: viewCount ?? this.viewCount,
content: content ?? this.content,
guid: guid ?? this.guid,
thumb: thumb ?? this.thumb,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'plex_playlist.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
ratingKey: json['ratingKey'] as String,
key: json['key'] as String,
type: json['type'] as String,
title: json['title'] as String,
summary: json['summary'] as String?,
smart: json['smart'] as bool,
playlistType: json['playlistType'] as String,
duration: (json['duration'] as num?)?.toInt(),
leafCount: (json['leafCount'] as num?)?.toInt(),
composite: json['composite'] as String?,
addedAt: (json['addedAt'] as num?)?.toInt(),
updatedAt: (json['updatedAt'] as num?)?.toInt(),
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
viewCount: (json['viewCount'] as num?)?.toInt(),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
+29
View File
@@ -0,0 +1,29 @@
import 'package:json_annotation/json_annotation.dart';
part 'plex_role.g.dart';
@JsonSerializable()
class PlexRole {
final int? id;
final String? filter;
final String tag;
final String? tagKey;
final String? role;
final String? thumb;
final int? count;
PlexRole({
this.id,
this.filter,
required this.tag,
this.tagKey,
this.role,
this.thumb,
this.count,
});
factory PlexRole.fromJson(Map<String, dynamic> json) =>
_$PlexRoleFromJson(json);
Map<String, dynamic> toJson() => _$PlexRoleToJson(this);
}
+27
View File
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'plex_role.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlexRole _$PlexRoleFromJson(Map<String, dynamic> json) => PlexRole(
id: (json['id'] as num?)?.toInt(),
filter: json['filter'] as String?,
tag: json['tag'] as String,
tagKey: json['tagKey'] as String?,
role: json['role'] as String?,
thumb: json['thumb'] as String?,
count: (json['count'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexRoleToJson(PlexRole instance) => <String, dynamic>{
'id': instance.id,
'filter': instance.filter,
'tag': instance.tag,
'tagKey': instance.tagKey,
'role': instance.role,
'thumb': instance.thumb,
'count': instance.count,
};
-29
View File
@@ -1,29 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'plex_server_info.g.dart';
@JsonSerializable()
class PlexServerInfo {
final String name;
final String? host;
final int? port;
final String? machineIdentifier;
final String version;
final bool? owned;
final bool? https;
PlexServerInfo({
required this.name,
this.host,
this.port,
this.machineIdentifier,
required this.version,
this.owned,
this.https,
});
factory PlexServerInfo.fromJson(Map<String, dynamic> json) =>
_$PlexServerInfoFromJson(json);
Map<String, dynamic> toJson() => _$PlexServerInfoToJson(this);
}
-29
View File
@@ -1,29 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'plex_server_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlexServerInfo _$PlexServerInfoFromJson(Map<String, dynamic> json) =>
PlexServerInfo(
name: json['name'] as String,
host: json['host'] as String?,
port: (json['port'] as num?)?.toInt(),
machineIdentifier: json['machineIdentifier'] as String?,
version: json['version'] as String,
owned: json['owned'] as bool?,
https: json['https'] as bool?,
);
Map<String, dynamic> _$PlexServerInfoToJson(PlexServerInfo instance) =>
<String, dynamic>{
'name': instance.name,
'host': instance.host,
'port': instance.port,
'machineIdentifier': instance.machineIdentifier,
'version': instance.version,
'owned': instance.owned,
'https': instance.https,
};
+53
View File
@@ -0,0 +1,53 @@
class PlexSort {
final String key;
final String? descKey;
final String title;
final String? defaultDirection;
PlexSort({
required this.key,
this.descKey,
required this.title,
this.defaultDirection,
});
factory PlexSort.fromJson(Map<String, dynamic> json) {
return PlexSort(
key: json['key'] as String,
descKey: json['descKey'] as String?,
title: json['title'] as String,
defaultDirection: json['defaultDirection'] as String?,
);
}
/// Gets the full sort key with direction
/// If [descending] is true, returns the descKey or key:desc
/// Otherwise returns the key for ascending sort
String getSortKey({bool descending = false}) {
if (!descending) {
return key;
}
// Use descKey if available, otherwise append :desc to key
return descKey ?? '$key:desc';
}
/// Returns true if this sort's default direction is descending
bool get isDefaultDescending {
return defaultDirection?.toLowerCase() == 'desc';
}
@override
String toString() {
return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PlexSort && other.key == key;
}
@override
int get hashCode => key.hashCode;
}
+30
View File
@@ -0,0 +1,30 @@
import 'plex_media_info.dart';
import 'plex_media_version.dart';
/// Consolidated data model containing all information needed for video playback.
/// This model combines data from multiple Plex API endpoints to reduce redundant requests.
class PlexVideoPlaybackData {
/// Direct video URL for playback
final String? videoUrl;
/// Media information including audio/subtitle tracks and chapters
final PlexMediaInfo? mediaInfo;
/// Available media versions/qualities for this content
final List<PlexMediaVersion> availableVersions;
PlexVideoPlaybackData({
required this.videoUrl,
required this.mediaInfo,
required this.availableVersions,
});
/// Returns true if this playback data has a valid video URL
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
/// Returns true if media info is available
bool get hasMediaInfo => mediaInfo != null;
/// Returns true if there are multiple media versions available
bool get hasMultipleVersions => availableVersions.length > 1;
}
@@ -0,0 +1,169 @@
import 'package:dio/dio.dart';
import '../utils/app_logger.dart';
/// Maintains the list of endpoints we can cycle through when one fails.
class EndpointFailoverManager {
EndpointFailoverManager(List<String> urls) {
_setEndpoints(urls);
}
late List<String> _endpoints;
int _currentIndex = 0;
List<String> get endpoints => List.unmodifiable(_endpoints);
String get current => _endpoints[_currentIndex];
bool get hasFallback => _currentIndex < _endpoints.length - 1;
/// Move to the next endpoint, returning its URL or null if exhausted.
String? moveToNext() {
if (!hasFallback) return null;
_currentIndex++;
return _endpoints[_currentIndex];
}
/// Replace the endpoint list and optionally set the active endpoint.
void reset(List<String> urls, {String? currentBaseUrl}) {
_setEndpoints(urls);
if (currentBaseUrl != null) {
final index = _endpoints.indexOf(currentBaseUrl);
_currentIndex = index >= 0 ? index : 0;
} else {
_currentIndex = 0;
}
}
void _setEndpoints(List<String> urls) {
final sanitized = <String>[];
final seen = <String>{};
for (final url in urls) {
if (url.isEmpty || seen.contains(url)) continue;
seen.add(url);
sanitized.add(url);
}
if (sanitized.isEmpty) {
throw ArgumentError('At least one endpoint is required');
}
_endpoints = sanitized;
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1);
}
}
/// Dio interceptor that retries failed requests on the next available endpoint.
class EndpointFailoverInterceptor extends Interceptor {
EndpointFailoverInterceptor({
required Dio dio,
required this.endpointManager,
required Future<void> Function(String newBaseUrl) onEndpointSwitch,
}) : _dio = dio,
_onEndpointSwitch = onEndpointSwitch;
final Dio _dio;
final EndpointFailoverManager endpointManager;
final Future<void> Function(String newBaseUrl) _onEndpointSwitch;
bool _isSwitching = false;
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isSwitching ||
!_shouldAttemptFailover(err) ||
!endpointManager.hasFallback) {
handler.next(err);
return;
}
final failedEndpoint = endpointManager.current;
appLogger.w(
'Endpoint request failed, evaluating failover',
error: {
'endpoint': failedEndpoint,
'type': err.type.name,
'statusCode': err.response?.statusCode,
},
stackTrace: err.stackTrace,
);
final nextBaseUrl = endpointManager.moveToNext();
if (nextBaseUrl == null) {
appLogger.w(
'Endpoint failure but no fallback endpoints remain',
error: {'failedEndpoint': failedEndpoint},
);
handler.next(err);
return;
}
_isSwitching = true;
try {
appLogger.i(
'Switching Plex endpoint after request failure',
error: {
'from': failedEndpoint,
'to': nextBaseUrl,
'path': err.requestOptions.path,
},
);
await _onEndpointSwitch(nextBaseUrl);
final response = await _retryRequest(err.requestOptions);
appLogger.i(
'Endpoint failover retry succeeded',
error: {'newEndpoint': nextBaseUrl},
);
handler.resolve(response);
} on DioException catch (dioError) {
appLogger.w(
'Endpoint failover retry failed',
error: {
'newEndpoint': nextBaseUrl,
'type': dioError.type.name,
'statusCode': dioError.response?.statusCode,
},
stackTrace: dioError.stackTrace,
);
handler.next(dioError);
} catch (_) {
handler.next(err);
} finally {
_isSwitching = false;
}
}
bool _shouldAttemptFailover(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.connectionError) {
return true;
}
return false;
}
Future<Response<dynamic>> _retryRequest(RequestOptions requestOptions) {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
responseType: requestOptions.responseType,
contentType: requestOptions.contentType,
followRedirects: requestOptions.followRedirects,
receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError,
validateStatus: requestOptions.validateStatus,
sendTimeout: requestOptions.sendTimeout,
receiveTimeout: requestOptions.receiveTimeout,
extra: requestOptions.extra,
listFormat: requestOptions.listFormat,
);
return _dio.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
cancelToken: requestOptions.cancelToken,
onSendProgress: requestOptions.onSendProgress,
onReceiveProgress: requestOptions.onReceiveProgress,
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import '../services/storage_service.dart';
/// Provider for managing hidden library state across the app.
/// This ensures that when a library is hidden/unhidden in one screen,
/// all other screens are automatically updated.
class HiddenLibrariesProvider extends ChangeNotifier {
late StorageService _storageService;
Set<String> _hiddenLibraryKeys = {};
bool _isInitialized = false;
/// Get an unmodifiable copy of hidden library keys
Set<String> get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys);
/// Check if the provider has completed initialization
bool get isInitialized => _isInitialized;
HiddenLibrariesProvider() {
_initialize();
}
/// Initialize the provider by loading hidden libraries from storage
Future<void> _initialize() async {
_storageService = await StorageService.getInstance();
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
_isInitialized = true;
notifyListeners();
}
/// Hide a library by its key
/// Updates both in-memory state and persistent storage
Future<void> hideLibrary(String libraryKey) async {
if (!_hiddenLibraryKeys.contains(libraryKey)) {
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey);
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
notifyListeners();
}
}
/// Unhide a library by its key
/// Updates both in-memory state and persistent storage
Future<void> unhideLibrary(String libraryKey) async {
if (_hiddenLibraryKeys.contains(libraryKey)) {
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey);
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
notifyListeners();
}
}
/// Check if a specific library is hidden
bool isLibraryHidden(String libraryKey) {
return _hiddenLibraryKeys.contains(libraryKey);
}
/// Refresh hidden libraries from storage
/// Useful if storage was modified outside the provider
Future<void> refresh() async {
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
notifyListeners();
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import '../client/plex_client.dart';
import '../services/data_aggregation_service.dart';
import '../services/multi_server_manager.dart';
import '../services/plex_auth_service.dart';
import '../utils/app_logger.dart';
/// Provider for multi-server Plex connections
/// Manages multiple PlexClient instances and provides data aggregation
class MultiServerProvider extends ChangeNotifier {
final MultiServerManager _serverManager;
final DataAggregationService _aggregationService;
MultiServerProvider(this._serverManager, this._aggregationService) {
// Listen to server status changes
_serverManager.statusStream.listen((_) {
notifyListeners();
});
}
/// Get the multi-server manager
MultiServerManager get serverManager => _serverManager;
/// Get the data aggregation service
DataAggregationService get aggregationService => _aggregationService;
/// Get client for specific server
PlexClient? getClientForServer(String serverId) {
return _serverManager.getClient(serverId);
}
/// Get all online server IDs
List<String> get onlineServerIds => _serverManager.onlineServerIds;
/// Get all server IDs
List<String> get serverIds => _serverManager.serverIds;
/// Check if a server is online
bool isServerOnline(String serverId) {
return _serverManager.isServerOnline(serverId);
}
/// Get number of online servers
int get onlineServerCount => _serverManager.onlineServerIds.length;
/// Get number of total servers
int get totalServerCount => _serverManager.serverIds.length;
/// Check if any servers are connected
bool get hasConnectedServers => onlineServerCount > 0;
/// Update token for a specific server
void updateTokenForServer(String serverId, String newToken) {
final client = _serverManager.getClient(serverId);
if (client != null) {
client.updateToken(newToken);
appLogger.d('MultiServerProvider: Token updated for server $serverId');
notifyListeners();
} else {
appLogger.w(
'MultiServerProvider: Cannot update token - server $serverId not found',
);
}
}
/// Clear all server connections
void clearAllConnections() {
_serverManager.disconnectAll();
appLogger.d('MultiServerProvider: All connections cleared');
notifyListeners();
}
/// Reconnect all servers after a profile switch
/// Clears existing connections and connects to all provided servers
Future<int> reconnectWithServers(
List<PlexServer> servers, {
String? clientIdentifier,
}) async {
// Clear existing connections first
_serverManager.disconnectAll();
appLogger.d(
'MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers',
);
// Connect with new server tokens
final connectedCount = await _serverManager.connectToAllServers(
servers,
clientIdentifier: clientIdentifier,
);
appLogger.i(
'MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch',
);
notifyListeners();
return connectedCount;
}
/// Check server health for all connected servers
Future<void> checkServerHealth() async {
await _serverManager.checkServerHealth();
// notifyListeners() will be called automatically via status stream
}
@override
void dispose() {
_serverManager.dispose();
super.dispose();
}
}
+306
View File
@@ -0,0 +1,306 @@
import 'package:flutter/foundation.dart';
import '../models/plex_metadata.dart';
import '../models/play_queue_response.dart';
import '../client/plex_client.dart';
/// Playback mode types
///
/// All playback now uses Plex play queues.
enum PlaybackMode {
playQueue, // Play queue-based playback (sequential, shuffle, playlists, collections)
}
/// Result of trying to locate the current queue index.
class _IndexLookupResult {
final int? index;
final bool attemptedLoad;
final bool loadFailed;
const _IndexLookupResult({
this.index,
this.attemptedLoad = false,
this.loadFailed = false,
});
}
/// Manages playback state using Plex's play queue API.
/// This provider is session-only and does not persist across app restarts.
class PlaybackStateProvider with ChangeNotifier {
// Play queue state
int? _playQueueId;
int _playQueueTotalCount = 0;
bool _playQueueShuffled = false;
int? _currentPlayQueueItemID;
// Windowed items (loaded around current position)
List<PlexMetadata> _loadedItems = [];
final int _windowSize = 50; // Number of items to keep in memory
// Legacy state for backward compatibility
String? _contextKey; // The show/season/playlist ratingKey for this session
PlaybackMode? _playbackMode;
// Client reference for loading more items
PlexClient? _client;
/// Current playback mode (null if no queue active)
PlaybackMode? get playbackMode => _playbackMode;
/// Whether shuffle mode is currently active
bool get isShuffleActive => _playQueueShuffled;
/// Whether playlist/collection mode is currently active
bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue;
/// Whether any queue-based playback is active
bool get isQueueActive =>
_playQueueId != null && _playbackMode == PlaybackMode.playQueue;
/// The context key (show/season/playlist ratingKey) for the current session
String? get shuffleContextKey => _contextKey;
/// Current play queue ID
int? get playQueueId => _playQueueId;
/// Total number of items in the play queue
int get queueLength => _playQueueTotalCount;
/// Gets the current position in the queue (1-indexed)
int get currentPosition {
if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0;
final index = _loadedItems.indexWhere(
(item) => item.playQueueItemID == _currentPlayQueueItemID,
);
return index != -1 ? index + 1 : 0;
}
/// Set the client reference for loading more items
void setClient(PlexClient client) {
_client = client;
}
/// Update the current play queue item when playing a new item
void setCurrentItem(PlexMetadata metadata) {
if (_playbackMode == PlaybackMode.playQueue &&
metadata.playQueueItemID != null) {
_currentPlayQueueItemID = metadata.playQueueItemID;
notifyListeners();
}
}
/// Initialize playback from a play queue
/// Call this after creating a play queue via the API
Future<void> setPlaybackFromPlayQueue(
PlayQueueResponse playQueue,
String? contextKey, {
String? serverId,
String? serverName,
}) async {
_playQueueId = playQueue.playQueueID;
// Use size or items length as fallback if totalCount is null
_playQueueTotalCount =
playQueue.playQueueTotalCount ??
playQueue.size ??
(playQueue.items?.length ?? 0);
_playQueueShuffled = playQueue.playQueueShuffled;
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
// Items are already tagged with server info by PlexClient
_loadedItems = playQueue.items ?? [];
_contextKey = contextKey;
_playbackMode = PlaybackMode.playQueue;
notifyListeners();
}
/// Load more items from the play queue if needed
/// Returns true if more items were loaded
Future<bool> _ensureItemsLoaded(int targetPlayQueueItemID) async {
if (_client == null || _playQueueId == null) return false;
// Check if the target item is already loaded
final hasItem = _loadedItems.any(
(item) => item.playQueueItemID == targetPlayQueueItemID,
);
if (hasItem) return true;
// Load a window around the target item
try {
final response = await _client!.getPlayQueue(
_playQueueId!,
center: targetPlayQueueItemID.toString(),
window: _windowSize,
);
if (response != null && response.items != null) {
// Items are already tagged with server info by PlexClient
_loadedItems = response.items!;
// Use size or items length as fallback if totalCount is null
_playQueueTotalCount =
response.playQueueTotalCount ??
response.size ??
response.items!.length;
_playQueueShuffled = response.playQueueShuffled;
notifyListeners();
return true;
}
} catch (e) {
// Failed to load items
return false;
}
return false;
}
Future<_IndexLookupResult> _getCurrentIndex({
bool loadIfMissing = false,
}) async {
if (_playbackMode != PlaybackMode.playQueue ||
_loadedItems.isEmpty ||
_currentPlayQueueItemID == null) {
return const _IndexLookupResult();
}
var currentIndex = _loadedItems.indexWhere(
(item) => item.playQueueItemID == _currentPlayQueueItemID,
);
if (currentIndex != -1) {
return _IndexLookupResult(index: currentIndex);
}
if (!loadIfMissing || _client == null || _playQueueId == null) {
return const _IndexLookupResult();
}
final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!);
if (!loaded) {
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
}
currentIndex = _loadedItems.indexWhere(
(item) => item.playQueueItemID == _currentPlayQueueItemID,
);
if (currentIndex == -1) {
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
}
return _IndexLookupResult(index: currentIndex, attemptedLoad: true);
}
/// Gets the next item in the playback queue.
/// Returns null if queue is exhausted or current item is not in queue.
/// [loopQueue] - If true, restart from beginning when queue is exhausted
Future<PlexMetadata?> getNextEpisode(
String currentItemKey, {
bool loopQueue = false,
}) async {
if (_playbackMode != PlaybackMode.playQueue) {
// For sequential mode, let the video player handle next episode
return null;
}
final indexResult = await _getCurrentIndex(loadIfMissing: true);
if (indexResult.index == null) {
if (indexResult.loadFailed) {
clearShuffle();
}
return null;
}
final currentIndex = indexResult.index!;
// Check if there's a next item in the loaded window
if (currentIndex + 1 < _loadedItems.length) {
final nextItem = _loadedItems[currentIndex + 1];
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return nextItem;
}
// Check if we're at the end of the entire queue
if (currentIndex + 1 >= _playQueueTotalCount) {
if (loopQueue && _playQueueTotalCount > 0) {
// Loop back to beginning - load first item
if (_client != null && _playQueueId != null) {
final response = await _client!.getPlayQueue(_playQueueId!);
if (response != null &&
response.items != null &&
response.items!.isNotEmpty) {
// Items are already tagged with server info by PlexClient
_loadedItems = response.items!;
final firstItem = _loadedItems.first;
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return firstItem;
}
}
}
// At end of queue - return null but keep queue active so user can still go back
return null;
}
// Need to load next window
if (_client != null && _playQueueId != null) {
// Load next window centered on the item after current
final nextItemID = _loadedItems.last.playQueueItemID;
if (nextItemID != null) {
final loaded = await _ensureItemsLoaded(nextItemID + 1);
if (loaded) {
// Try again with newly loaded items
return getNextEpisode(currentItemKey, loopQueue: loopQueue);
}
}
}
return null;
}
/// Gets the previous item in the playback queue.
/// Returns null if at the beginning of the queue or current item is not in queue.
Future<PlexMetadata?> getPreviousEpisode(String currentItemKey) async {
if (_playbackMode != PlaybackMode.playQueue) {
// For sequential mode, let the video player handle previous episode
return null;
}
final currentIndex = (await _getCurrentIndex()).index;
if (currentIndex == null) return null;
// Check if there's a previous item in the loaded window
if (currentIndex > 0) {
final prevItem = _loadedItems[currentIndex - 1];
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
return prevItem;
}
// Check if we're at the beginning of the entire queue
if (currentIndex == 0) {
return null;
}
// Need to load previous window
if (_client != null && _playQueueId != null) {
final prevItemID = _loadedItems.first.playQueueItemID;
if (prevItemID != null && prevItemID > 0) {
final loaded = await _ensureItemsLoaded(prevItemID - 1);
if (loaded) {
return getPreviousEpisode(currentItemKey);
}
}
}
return null;
}
/// Clears the playback queue and exits queue mode
void clearShuffle() {
_playQueueId = null;
_playQueueTotalCount = 0;
_playQueueShuffled = false;
_currentPlayQueueItemID = null;
_loadedItems = [];
_contextKey = null;
_playbackMode = null;
notifyListeners();
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/foundation.dart';
/// Provider for tracking server-specific UI state
/// Manages which server is currently in context for detail views
class ServerStateProvider extends ChangeNotifier {
String? _currentServerId;
final Map<String, bool> _serverConnectionStates = {};
final Map<String, String?> _serverErrors = {};
/// Get the currently selected server ID (for detail views)
String? get currentServerId => _currentServerId;
/// Set the current server context (e.g., when viewing a library from a specific server)
void setCurrentServer(String? serverId) {
if (_currentServerId != serverId) {
_currentServerId = serverId;
notifyListeners();
}
}
/// Clear the current server selection
void clearCurrentServer() {
if (_currentServerId != null) {
_currentServerId = null;
notifyListeners();
}
}
/// Get connection state for a server
bool isServerConnected(String serverId) {
return _serverConnectionStates[serverId] ?? false;
}
/// Update connection state for a server
void updateServerConnectionState(String serverId, bool isConnected) {
if (_serverConnectionStates[serverId] != isConnected) {
_serverConnectionStates[serverId] = isConnected;
notifyListeners();
}
}
/// Get error message for a server (null if no error)
String? getServerError(String serverId) {
return _serverErrors[serverId];
}
/// Set error for a server
void setServerError(String serverId, String? error) {
_serverErrors[serverId] = error;
notifyListeners();
}
/// Clear error for a server
void clearServerError(String serverId) {
if (_serverErrors.containsKey(serverId)) {
_serverErrors.remove(serverId);
notifyListeners();
}
}
/// Clear all server errors
void clearAllServerErrors() {
if (_serverErrors.isNotEmpty) {
_serverErrors.clear();
notifyListeners();
}
}
/// Reset all state
void reset() {
_currentServerId = null;
_serverConnectionStates.clear();
_serverErrors.clear();
notifyListeners();
}
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class SettingsProvider extends ChangeNotifier {
late SettingsService _settingsService;
LibraryDensity _libraryDensity = LibraryDensity.normal;
ViewMode _viewMode = ViewMode.grid;
bool _useSeasonPoster = false;
bool _showHeroSection = true;
SettingsProvider() {
_initializeSettings();
}
Future<void> _initializeSettings() async {
_settingsService = await SettingsService.getInstance();
_libraryDensity = _settingsService.getLibraryDensity();
_viewMode = _settingsService.getViewMode();
_useSeasonPoster = _settingsService.getUseSeasonPoster();
_showHeroSection = _settingsService.getShowHeroSection();
notifyListeners();
}
LibraryDensity get libraryDensity => _libraryDensity;
ViewMode get viewMode => _viewMode;
bool get useSeasonPoster => _useSeasonPoster;
bool get showHeroSection => _showHeroSection;
Future<void> setLibraryDensity(LibraryDensity density) async {
if (_libraryDensity != density) {
_libraryDensity = density;
await _settingsService.setLibraryDensity(density);
notifyListeners();
}
}
Future<void> setViewMode(ViewMode mode) async {
if (_viewMode != mode) {
_viewMode = mode;
await _settingsService.setViewMode(mode);
notifyListeners();
}
}
Future<void> setUseSeasonPoster(bool value) async {
if (_useSeasonPoster != value) {
_useSeasonPoster = value;
await _settingsService.setUseSeasonPoster(value);
notifyListeners();
}
}
Future<void> setShowHeroSection(bool value) async {
if (_showHeroSection != value) {
_showHeroSection = value;
await _settingsService.setShowHeroSection(value);
notifyListeners();
}
}
String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
return 'Compact';
case LibraryDensity.normal:
return 'Normal';
case LibraryDensity.comfortable:
return 'Comfortable';
}
}
}
+80 -52
View File
@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../models/plex_home.dart';
import '../models/plex_home_user.dart';
@@ -6,6 +7,7 @@ import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../widgets/pin_entry_dialog.dart';
import 'plex_client_provider.dart';
class UserProfileProvider extends ChangeNotifier {
@@ -32,18 +34,24 @@ class UserProfileProvider extends ChangeNotifier {
StorageService? _storageService;
// Callback for data invalidation when switching profiles
VoidCallback? _onDataInvalidationRequested;
// Receives the list of servers with new profile tokens for reconnection
Future<void> Function(List<PlexServer>)? _onDataInvalidationRequested;
/// Set a callback to be called when profile switching requires data invalidation
void setDataInvalidationCallback(VoidCallback? callback) {
/// The callback receives the list of servers with the new profile's access tokens
void setDataInvalidationCallback(
Future<void> Function(List<PlexServer>)? callback,
) {
_onDataInvalidationRequested = callback;
}
/// Trigger data invalidation for all screens
void _invalidateAllData() {
/// Trigger data invalidation for all screens with the new profile's servers
Future<void> _invalidateAllData(List<PlexServer> servers) async {
if (_onDataInvalidationRequested != null) {
_onDataInvalidationRequested!();
appLogger.d('Data invalidation triggered for profile switch');
await _onDataInvalidationRequested!(servers);
appLogger.d(
'Data invalidation triggered for profile switch with ${servers.length} servers',
);
}
}
@@ -254,19 +262,45 @@ class UserProfileProvider extends ChangeNotifier {
_setLoading(true);
_clearError();
return await _attemptUserSwitch(user, context, clientProvider, null);
}
Future<bool> _attemptUserSwitch(
PlexHomeUser user,
BuildContext? context,
PlexClientProvider? clientProvider,
String? errorMessage,
) async {
try {
final currentToken = _storageService!.getPlexToken();
if (currentToken == null) {
throw Exception('No Plex.tv authentication token available');
}
// Check if user requires PIN
String? pin;
if (user.requiresPassword && context != null && context.mounted) {
pin = await showPinEntryDialog(
context,
user.displayName,
errorMessage: errorMessage,
);
// User cancelled the PIN dialog
if (pin == null) {
_setLoading(false);
return false;
}
}
final switchResponse = await _authService!.switchToUser(
user.uuid,
currentToken,
pin: pin,
);
// switchResponse.authToken is the new user's Plex.tv token
// We need to fetch servers with this token to get the proper server access token
// Fetch servers with this token to get the proper server access tokens
appLogger.d('Got new user Plex.tv token, fetching servers...');
final servers = await _authService!.fetchServers(
@@ -276,38 +310,14 @@ class UserProfileProvider extends ChangeNotifier {
throw Exception('No servers available for this user');
}
// Find the current server from storage
final currentServerData = _storageService!.getServerData();
if (currentServerData == null) {
throw Exception('No current server data found');
}
appLogger.d('Fetched ${servers.length} servers for new profile');
// Find matching server by name or client identifier
final currentServerName = currentServerData['name'] as String?;
final currentServerClientId =
currentServerData['clientIdentifier'] as String?;
final matchingServer = servers.firstWhere(
(server) =>
server.name == currentServerName ||
server.clientIdentifier == currentServerClientId,
orElse: () => servers.first, // Fallback to first server
);
appLogger.d(
'Found matching server: ${matchingServer.name}, getting access token',
);
// Update storage with new tokens
await _storageService!.updateCurrentUser(
user.uuid,
matchingServer
.accessToken, // Use server access token, not Plex.tv token
);
// Also save the new Plex.tv token for future profile operations
// Save the new Plex.tv token for future profile operations
await _storageService!.savePlexToken(switchResponse.authToken);
// Update current user UUID in storage
await _storageService!.saveCurrentUserUUID(user.uuid);
// Update current user
_currentUser = user;
@@ -323,31 +333,49 @@ class UserProfileProvider extends ChangeNotifier {
},
);
// Update PlexClient with proper server access token
if (clientProvider != null) {
try {
clientProvider.updateToken(matchingServer.accessToken);
appLogger.d(
'Updated PlexClient with server access token for user: ${user.displayName}',
);
} catch (e) {
appLogger.w('Failed to update PlexClient token', error: e);
}
}
notifyListeners();
// Invalidate all cached data for the new profile
// This will cause screens to refresh and rebuild widgets with the new token
_invalidateAllData();
// Invalidate all cached data and reconnect to all servers with new tokens
// The callback will handle server reconnection using the servers list
await _invalidateAllData(servers);
appLogger.d(
'Profile switch complete, all data and images should refresh with new token',
'Profile switch complete, all servers reconnected with new tokens',
);
appLogger.i('Successfully switched to user: ${user.displayName}');
return true;
} catch (e) {
// Check if it's a PIN validation error
if (e is DioException && e.response?.statusCode == 403) {
final errors = e.response?.data['errors'] as List?;
if (errors != null && errors.isNotEmpty) {
final errorCode = errors[0]['code'] as int?;
final errorMessage = errors[0]['message'] as String?;
// Error code 1041 means invalid PIN
if (errorCode == 1041) {
appLogger.w('Invalid PIN for user: ${user.displayName}');
_clearError(); // Clear any previous error state
// Retry with error message if context is still available
if (context != null && context.mounted) {
return await _attemptUserSwitch(
user,
context,
clientProvider,
errorMessage ?? 'Incorrect PIN. Please try again.',
);
}
// If context not available, return false without showing error
appLogger.d('Cannot retry PIN entry - context not available');
return false;
}
}
}
// Only show error for non-PIN validation errors
_setError('Failed to switch user: $e');
appLogger.e('Failed to switch to user: ${user.displayName}', error: e);
return false;
+7 -8
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
import 'licenses_screen.dart';
class AboutScreen extends StatefulWidget {
@@ -23,7 +24,7 @@ class _AboutScreenState extends State<AboutScreen> {
Future<void> _loadPackageInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appName = 'Plezy';
_appName = t.app.title;
_appVersion = packageInfo.version;
});
}
@@ -36,7 +37,7 @@ class _AboutScreenState extends State<AboutScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: const Text('About'), pinned: true),
CustomAppBar(title: Text(t.about.title), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -55,14 +56,14 @@ class _AboutScreenState extends State<AboutScreen> {
),
const SizedBox(height: 8),
Text(
'Version $appVersion',
t.about.versionLabel(version: appVersion),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 24),
Text(
'A beautiful Plex client for Flutter',
t.about.appDescription,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
@@ -76,10 +77,8 @@ class _AboutScreenState extends State<AboutScreen> {
Card(
child: ListTile(
leading: const Icon(Icons.description),
title: const Text('Open Source Licenses'),
subtitle: const Text(
'View licenses of third-party libraries',
),
title: Text(t.about.openSourceLicenses),
subtitle: Text(t.about.viewLicensesDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
+353 -108
View File
@@ -1,10 +1,17 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import 'server_selection_screen.dart';
import '../services/server_registry.dart';
import '../providers/multi_server_provider.dart';
import '../providers/plex_client_provider.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import 'main_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@@ -18,6 +25,8 @@ class _AuthScreenState extends State<AuthScreen> {
String? _errorMessage;
late PlexAuthService _authService;
bool _shouldCancelPolling = false;
bool _useQrFlow = false; // whether current auth attempt is QR based
String? _qrAuthUrl; // auth URL rendered as QR
@override
void initState() {
@@ -29,11 +38,85 @@ class _AuthScreenState extends State<AuthScreen> {
_authService = await PlexAuthService.create();
}
/// Connect to all available servers and navigate to main screen
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
if (!mounted) return;
setState(() {
_isAuthenticating = true;
_errorMessage = null;
});
try {
// Fetch all servers for this user
final servers = await _authService.fetchServers(plexToken);
if (servers.isEmpty) {
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.noServersFound;
});
return;
}
// Save all servers to registry and enable them
final storage = await StorageService.getInstance();
final registry = ServerRegistry(storage);
await registry.saveServers(servers);
// Enable all servers
final serverIds = servers.map((s) => s.clientIdentifier).toSet();
await registry.saveEnabledServerIds(serverIds);
// Connect to all servers
if (!mounted) return;
final multiServerProvider = context.read<MultiServerProvider>();
final connectedCount = await multiServerProvider.serverManager
.connectToAllServers(servers);
if (connectedCount == 0) {
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.allServerConnectionsFailed;
});
return;
}
// Get the first connected client for backward compatibility
if (!mounted) return;
final firstClient =
multiServerProvider.serverManager.onlineClients.values.first;
// Set it as the legacy client
final plexClientProvider = context.read<PlexClientProvider>();
plexClientProvider.setClient(firstClient);
// Navigate to main screen
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: firstClient),
),
);
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
});
}
}
Future<void> _startAuthentication() async {
setState(() {
_isAuthenticating = true;
_errorMessage = null;
_shouldCancelPolling = false;
// preserve _useQrFlow as chosen prior to calling
if (!_useQrFlow) {
_qrAuthUrl = null; // ensure stale QR cleared for browser flow
}
});
try {
@@ -45,12 +128,19 @@ class _AuthScreenState extends State<AuthScreen> {
// Construct auth URL
final authUrl = _authService.getAuthUrl(pinCode);
// Open browser (in-app for mobile, external for desktop)
final uri = Uri.parse(authUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
if (_useQrFlow) {
// Display QR instead of launching browser
setState(() {
_qrAuthUrl = authUrl;
});
} else {
throw Exception('Could not launch auth URL');
// Open browser (in-app for mobile, external for desktop)
final uri = Uri.parse(authUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
} else {
throw Exception(t.errors.couldNotLaunchUrl);
}
}
// Poll for authentication with cancellation support
@@ -67,38 +157,38 @@ class _AuthScreenState extends State<AuthScreen> {
if (token == null) {
setState(() {
_isAuthenticating = false;
_errorMessage = 'Authentication timed out. Please try again.';
_errorMessage = t.auth.authenticationTimeout;
});
return;
}
// Auto-close the in-app browser on mobile (no-op on desktop)
try {
await closeInAppWebView();
} catch (e) {
// Ignore errors - browser might already be closed or on desktop
if (!_useQrFlow) {
try {
await closeInAppWebView();
} catch (e) {
// Ignore errors - browser might already be closed or on desktop
}
}
// Store the token
final storage = await StorageService.getInstance();
await storage.savePlexToken(token);
// Navigate to server selection
// Clear QR URL after successful auth
setState(() {
_qrAuthUrl = null;
_useQrFlow = false;
});
// Connect to all servers and navigate to main screen
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ServerSelectionScreen(
authService: _authService,
plexToken: token,
),
),
);
await _connectToAllServersAndNavigate(token);
}
} catch (e) {
setState(() {
_isAuthenticating = false;
_errorMessage = 'Authentication failed: $e';
_errorMessage = t.errors.authenticationFailed(error: e);
});
}
}
@@ -107,6 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
setState(() {
_shouldCancelPolling = true;
_isAuthenticating = false;
_qrAuthUrl = null;
});
// Start new authentication after a brief delay to ensure cleanup
Future.delayed(const Duration(milliseconds: 100), _startAuthentication);
@@ -127,15 +218,15 @@ class _AuthScreenState extends State<AuthScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Debug: Enter Plex Token'),
title: Text(t.auth.debugEnterToken),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
hintText: 'Enter your Plex.tv token',
labelText: t.auth.plexTokenLabel,
hintText: t.auth.plexTokenHint,
errorText: errorMessage,
border: const OutlineInputBorder(),
),
@@ -147,14 +238,14 @@ class _AuthScreenState extends State<AuthScreen> {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(t.auth.cancel),
),
ElevatedButton(
onPressed: () async {
final token = tokenController.text.trim();
if (token.isEmpty) {
setDialogState(() {
errorMessage = 'Please enter a token';
errorMessage = t.errors.pleaseEnterToken;
});
return;
}
@@ -165,7 +256,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isValid = await _authService.verifyToken(token);
if (!isValid) {
setDialogState(() {
errorMessage = 'Invalid token';
errorMessage = t.errors.invalidToken;
});
return;
}
@@ -174,25 +265,18 @@ class _AuthScreenState extends State<AuthScreen> {
final storage = await StorageService.getInstance();
await storage.savePlexToken(token);
// Close dialog and navigate
// Close dialog and connect to all servers
if (mounted) {
navigator.pop();
navigator.pushReplacement(
MaterialPageRoute(
builder: (context) => ServerSelectionScreen(
authService: _authService,
plexToken: token,
),
),
);
await _connectToAllServersAndNavigate(token);
}
} catch (e) {
setDialogState(() {
errorMessage = 'Failed to verify token: $e';
errorMessage = t.errors.failedToVerifyToken(error: e);
});
}
},
child: const Text('Authenticate'),
child: Text(t.auth.authenticate),
),
],
);
@@ -204,83 +288,244 @@ class _AuthScreenState extends State<AuthScreen> {
@override
Widget build(BuildContext context) {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.of(context).size.width > 700;
return Scaffold(
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
'Plezy',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
if (_isAuthenticating) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
const Text(
'Waiting for authentication...\nPlease complete sign-in in your browser.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: const Text('Retry'),
),
] else ...[
ElevatedButton(
onPressed: _startAuthentication,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Sign in with Plex'),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
child: isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// First column - Logo and title (always visible)
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/plezy.png',
width: 120,
height: 120,
),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
),
child: const Text(
'Debug: Enter Token',
style: TextStyle(fontSize: 12),
const SizedBox(width: 48),
// Second column - All authentication content
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isAuthenticating) ...[
if (_useQrFlow && _qrAuthUrl != null) ...[
// QR code flow - hint text above QR code
Text(
t.auth.scanQRCodeInstruction,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: QrImageView(
data: _qrAuthUrl!,
size: 300,
version: QrVersions.auto,
backgroundColor: Colors.white,
),
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: Text(t.auth.retry),
),
] else ...[
// Browser auth flow - show spinner and waiting message
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: Text(t.auth.retry),
),
],
] else ...[
// Initial state buttons
ElevatedButton(
onPressed: _startAuthentication,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: Text(t.auth.signInWithPlex),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
setState(() {
_useQrFlow = true;
});
_startAuthentication();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: Text(t.auth.showQRCode),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
side: BorderSide(
color: Theme.of(context).colorScheme.outline
.withValues(alpha: 0.5),
),
),
child: Text(
t.auth.debugEnterToken,
style: TextStyle(fontSize: 12),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
],
],
],
),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
],
],
],
),
const SizedBox(height: 48),
if (_isAuthenticating) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _retryAuthentication,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
),
child: Text(t.auth.retry),
),
] else ...[
// add QR button here
ElevatedButton(
onPressed: _startAuthentication,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(t.auth.signInWithPlex),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
setState(() {
_useQrFlow = true;
});
_startAuthentication();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(t.auth.showQRCode),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Text(
t.auth.debugEnterToken,
style: TextStyle(fontSize: 12),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
],
],
],
),
),
),
);
@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/app_logger.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../i18n/strings.g.dart';
/// Abstract base class for screens displaying media lists (collections/playlists)
/// Provides common state management and playback functionality
abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
extends State<T>
with Refreshable, ItemUpdatable {
// State properties - concrete implementations to avoid duplication
List<PlexMetadata> items = [];
bool isLoading = false;
String? errorMessage;
@override
PlexClient get client => _getClientForMediaItem();
/// The media item being displayed (collection or playlist)
dynamic get mediaItem;
/// Title to display in app bar
String get title;
/// Message to show when list is empty
String get emptyMessage;
/// Optional icon to show when list is empty
IconData? get emptyIcon => null;
/// Get the correct PlexClient for this media item's server
PlexClient _getClientForMediaItem() {
// Try to get serverId from the media item
String? serverId;
// Check if mediaItem has serverId property
if (mediaItem is PlexMetadata) {
serverId = (mediaItem as PlexMetadata).serverId;
} else if (mediaItem != null) {
// For playlists or other types, use dynamic access
try {
final dynamic item = mediaItem;
serverId = item.serverId as String?;
} catch (_) {
// Ignore if serverId is not available
}
}
// If serverId is null, fall back to first available server
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception(t.errors.noClientAvailable);
}
serverId = multiServerProvider.onlineServerIds.first;
}
return context.getClientForServer(serverId);
}
@override
void initState() {
super.initState();
loadItems();
}
/// Load or reload the items (subclasses implement this)
Future<void> loadItems();
/// Play all items in the list
Future<void> playItems() => _playWithShuffle(false);
/// Shuffle play all items in the list
Future<void> shufflePlayItems() => _playWithShuffle(true);
/// Internal helper to play items with optional shuffle
Future<void> _playWithShuffle(bool shuffle) async {
if (items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(emptyMessage)));
}
return;
}
final client = _getClientForMediaItem();
await playCollectionOrPlaylist(
context: context,
client: client,
item: mediaItem,
shuffle: shuffle,
);
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
if (mounted) {
setState(() {
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
items[index] = updatedMetadata;
}
});
}
}
@override
void refresh() {
loadItems();
}
/// Build common error/loading/empty state slivers
/// Returns a list of slivers to display based on current state
List<Widget> buildStateSlivers() {
if (errorMessage != null) {
return [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loadItems,
child: Text(t.common.retry),
),
],
),
),
),
];
}
if (items.isEmpty && isLoading) {
return [
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
];
}
if (items.isEmpty) {
final icon = emptyIcon;
return [
SliverFillRemaining(
child: Center(
child: icon != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
emptyMessage,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
)
: Text(emptyMessage),
),
),
];
}
return [];
}
/// Build standard app bar actions (play, shuffle, delete)
/// Subclasses can override to customize actions
List<Widget> buildAppBarActions({
VoidCallback? onDelete,
String? deleteTooltip,
Color? deleteColor,
bool showDelete = true,
}) {
return [
// Play button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: t.discover.play,
onPressed: playItems,
),
// Shuffle button
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.shuffle),
tooltip: t.common.shuffle,
onPressed: shufflePlayItems,
),
// Delete button
if (showDelete && onDelete != null)
IconButton(
icon: const Icon(Icons.delete),
tooltip: deleteTooltip ?? t.common.delete,
onPressed: onDelete,
color: deleteColor ?? Colors.red,
),
];
}
}
/// Mixin that provides standard loadItems implementation for media lists
/// Handles the common pattern of fetching, tagging, and setting items
mixin StandardItemLoader<T extends StatefulWidget>
on BaseMediaListDetailScreen<T> {
/// Fetch items from the API (must be implemented by subclass)
Future<List<PlexMetadata>> fetchItems();
/// Get error message for failed load (can be overridden)
String getLoadErrorMessage(Object error) {
return 'Failed to load items: ${error.toString()}';
}
/// Get log message for successful load (can be overridden)
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items';
}
@override
Future<void> loadItems() async {
if (mounted) {
setState(() {
isLoading = true;
errorMessage = null;
});
}
try {
// Items are automatically tagged with server info by PlexClient
final newItems = await fetchItems();
if (mounted) {
setState(() {
items = newItems;
isLoading = false;
});
}
appLogger.d(getLoadSuccessMessage(newItems.length));
} catch (e) {
appLogger.e('Failed to load items', error: e);
if (mounted) {
setState(() {
errorMessage = getLoadErrorMessage(e);
isLoading = false;
});
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/plex_metadata.dart';
import '../providers/settings_provider.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/dialogs.dart';
import '../utils/app_logger.dart';
import 'base_media_list_detail_screen.dart';
/// Screen to display the contents of a collection
class CollectionDetailScreen extends StatefulWidget {
final PlexMetadata collection;
const CollectionDetailScreen({super.key, required this.collection});
@override
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
}
class _CollectionDetailScreenState
extends BaseMediaListDetailScreen<CollectionDetailScreen>
with StandardItemLoader<CollectionDetailScreen> {
@override
PlexMetadata get mediaItem => widget.collection;
@override
String get title => widget.collection.title;
@override
String get emptyMessage => t.collections.empty;
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getCollectionItems(widget.collection.ratingKey);
}
@override
String getLoadErrorMessage(Object error) {
return t.collections.failedToLoadItems(error: error.toString());
}
@override
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for collection: ${widget.collection.title}';
}
Future<void> _deleteCollection() async {
// Get library section ID from the collection or its items
int? sectionId = widget.collection.librarySectionID;
// If collection doesn't have it, try to get it from loaded items
if (sectionId == null && items.isNotEmpty) {
sectionId = items.first.librarySectionID;
}
if (sectionId == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.unknownLibrarySection)),
);
}
return;
}
// Show confirmation dialog
final confirmed = await showDeleteConfirmation(
context,
title: t.collections.deleteCollection,
message: t.collections.deleteConfirm(title: widget.collection.title),
);
if (confirmed != true) return;
if (!mounted) return;
try {
final success = await client.deleteCollection(
sectionId.toString(),
widget.collection.ratingKey,
);
if (!mounted) return;
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.collections.deleted)));
Navigator.pop(
context,
true,
); // Return true to indicate refresh needed
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed)));
}
}
} catch (e) {
appLogger.e('Failed to delete collection', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.collections.deleteFailedWithError(error: e.toString()),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(widget.collection.title),
pinned: true,
actions: buildAppBarActions(onDelete: _deleteCollection),
),
...buildStateSlivers(),
if (items.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
sliver: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
collectionId: widget.collection.ratingKey,
onListRefresh: loadItems,
);
}, childCount: items.length),
);
},
),
),
],
),
);
}
}
+453 -223
View File
@@ -4,20 +4,26 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../services/storage_service.dart';
import '../services/plex_auth_service.dart';
import '../models/plex_hub.dart';
import '../providers/multi_server_provider.dart';
import '../providers/server_state_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/playback_state_provider.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/user_avatar_widget.dart';
import '../widgets/horizontal_scroll_with_arrows.dart';
import '../widgets/hub_section.dart';
import 'profile_switch_screen.dart';
import 'server_selection_screen.dart';
import '../providers/user_profile_provider.dart';
import '../providers/settings_provider.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
import '../mixins/item_updatable.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import 'auth_screen.dart';
class DiscoverScreen extends StatefulWidget {
@@ -31,11 +37,24 @@ class DiscoverScreen extends StatefulWidget {
class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
@override
PlexClient get client => context.clientSafe;
PlexClient get client {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
return context.getClientForServer(
multiServerProvider.onlineServerIds.first,
);
}
List<PlexMetadata> _onDeck = [];
List<PlexMetadata> _recentlyAdded = [];
List<PlexHub> _hubs = [];
bool _isLoading = true;
String? _errorMessage;
final PageController _heroController = PageController();
@@ -45,12 +64,31 @@ class _DiscoverScreenState extends State<DiscoverScreen>
late AnimationController _indicatorAnimationController;
bool _isAutoScrollPaused = false;
/// Get the correct PlexClient for an item's server
PlexClient _getClientForItem(PlexMetadata? item) {
// Items should always have a serverId, but if not, fall back to first available server
final serverId = item?.serverId;
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
return context.getClientForServer(
multiServerProvider.onlineServerIds.first,
);
}
return context.getClientForServer(serverId);
}
@override
void initState() {
super.initState();
_indicatorAnimationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
duration: _heroAutoScrollDuration,
);
_loadContent();
_startAutoScroll();
@@ -69,9 +107,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isAutoScrollPaused) return;
_indicatorAnimationController.forward(from: 0.0);
_autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused)
_autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) {
if (_onDeck.isEmpty ||
!_heroController.hasClients ||
_isAutoScrollPaused) {
return;
}
// Validate current index is within bounds before calculating next page
if (_currentHeroIndex >= _onDeck.length) {
_currentHeroIndex = 0;
}
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
_heroController.animateToPage(
@@ -108,32 +154,98 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_startAutoScroll();
}
// Helper method to calculate visible dot range (max 5 dots)
({int start, int end}) _getVisibleDotRange() {
final totalDots = _onDeck.length;
if (totalDots <= 5) {
return (start: 0, end: totalDots - 1);
}
// Center the active dot when possible
final center = _currentHeroIndex;
int start = (center - 2).clamp(0, totalDots - 5);
int end = start + 4; // 5 dots total (0-4 inclusive)
return (start: start, end: end);
}
// Helper method to determine dot size based on position
double _getDotSize(int dotIndex, int start, int end) {
final totalDots = _onDeck.length;
// If we have 5 or fewer dots, all are full size (8px)
if (totalDots <= 5) {
return 8.0;
}
// First and last visible dots are smaller if there are more items beyond them
final isFirstVisible = dotIndex == start && start > 0;
final isLastVisible = dotIndex == end && end < totalDots - 1;
if (isFirstVisible || isLastVisible) {
return 5.0; // Smaller edge dots
}
return 8.0; // Normal size
}
Future<void> _loadContent() async {
appLogger.d('Loading discover content');
appLogger.d('Loading discover content from all servers');
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
appLogger.d('Fetching onDeck and recentlyAdded from Plex');
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
appLogger.d('Fetching onDeck and hubs from all Plex servers');
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
final onDeck = await client.getOnDeck();
final recentlyAdded = await client.getRecentlyAdded(limit: 20);
// Fetch on deck and hubs from all servers in parallel for optimal performance
final results = await Future.wait([
multiServerProvider.aggregationService.getOnDeckFromAllServers(
limit: 20,
),
multiServerProvider.aggregationService.getHubsFromAllServers(),
]);
final onDeck = results[0] as List<PlexMetadata>;
final allHubs = results[1] as List<PlexHub>;
// Filter out duplicate hubs that we already fetch separately
final filteredHubs = allHubs.where((hub) {
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
final title = hub.title.toLowerCase();
// Skip "Continue Watching" and "On Deck" hubs (we handle these separately)
return !hubId.contains('ondeck') &&
!hubId.contains('continue') &&
!title.contains('continue watching') &&
!title.contains('on deck');
}).toList();
appLogger.d(
'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items',
'Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers',
);
setState(() {
_onDeck = onDeck;
_recentlyAdded = recentlyAdded;
_hubs = filteredHubs;
_isLoading = false;
// Reset hero index to avoid sync issues
_currentHeroIndex = 0;
});
// Sync PageController to first page after data loads
if (_heroController.hasClients && onDeck.isNotEmpty) {
_heroController.jumpToPage(0);
}
appLogger.d('Discover content loaded successfully');
} catch (e) {
appLogger.e('Failed to load discover content', error: e);
@@ -144,13 +256,155 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
}
// Public method to refresh content
/// Refresh only the Continue Watching section in the background
/// This is called when returning to the home screen to avoid blocking UI
Future<void> _refreshContinueWatching() async {
appLogger.d('Refreshing Continue Watching in background from all servers');
try {
final multiServerProvider = context.read<MultiServerProvider>();
if (!multiServerProvider.hasConnectedServers) {
appLogger.w('No servers available for background refresh');
return;
}
final onDeck = await multiServerProvider.aggregationService
.getOnDeckFromAllServers(limit: 20);
if (mounted) {
setState(() {
_onDeck = onDeck;
// Reset hero index if needed
if (_currentHeroIndex >= onDeck.length) {
_currentHeroIndex = 0;
if (_heroController.hasClients && onDeck.isNotEmpty) {
_heroController.jumpToPage(0);
}
}
});
appLogger.d('Continue Watching refreshed successfully');
}
} catch (e) {
appLogger.w('Failed to refresh Continue Watching', error: e);
// Silently fail - don't show error to user for background refresh
}
}
// Public method to refresh content (for normal navigation)
@override
void refresh() {
appLogger.d('DiscoverScreen.refresh() called');
// Only refresh Continue Watching in background, not full screen reload
_refreshContinueWatching();
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content');
// Reload all content including On Deck and content hubs
_loadContent();
}
/// Get icon for hub based on its title
IconData _getHubIcon(String title) {
final lowerTitle = title.toLowerCase();
// Trending/Popular content
if (lowerTitle.contains('trending')) {
return Icons.trending_up;
}
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
return Icons.whatshot;
}
// Seasonal/Time-based
if (lowerTitle.contains('seasonal')) {
return Icons.calendar_month;
}
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
return Icons.new_releases;
}
if (lowerTitle.contains('recently released') ||
lowerTitle.contains('recent')) {
return Icons.schedule;
}
// Top/Rated content
if (lowerTitle.contains('top rated') ||
lowerTitle.contains('highest rated')) {
return Icons.star;
}
if (lowerTitle.contains('top ')) {
return Icons.military_tech;
}
// Genre-specific
if (lowerTitle.contains('thriller')) {
return Icons.warning_amber_rounded;
}
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
return Icons.mood;
}
if (lowerTitle.contains('action')) {
return Icons.flash_on;
}
if (lowerTitle.contains('drama')) {
return Icons.theater_comedy;
}
if (lowerTitle.contains('fantasy')) {
return Icons.auto_fix_high;
}
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
return Icons.rocket_launch;
}
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
return Icons.nights_stay;
}
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
return Icons.favorite_border;
}
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
return Icons.explore;
}
// Watchlist/Playlists
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
return Icons.playlist_play;
}
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
return Icons.visibility_off;
}
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
return Icons.visibility;
}
// Network/Studio
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
return Icons.tv;
}
// Actor/Director
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
return Icons.person;
}
// Year-based (80s, 90s, etc.)
if (lowerTitle.contains('80') ||
lowerTitle.contains('90') ||
lowerTitle.contains('00')) {
return Icons.history;
}
// Rediscover/Start Watching
if (lowerTitle.contains('rediscover') ||
lowerTitle.contains('start watching')) {
return Icons.play_arrow;
}
// Default icon for other hubs
return Icons.auto_awesome;
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
// Check and update in _onDeck list
@@ -161,50 +415,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_onDeck[onDeckIndex] = updatedMetadata;
}
// Check and update in _recentlyAdded list
final recentlyAddedIndex = _recentlyAdded.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (recentlyAddedIndex != -1) {
_recentlyAdded[recentlyAddedIndex] = updatedMetadata;
}
}
Future<void> _handleSwitchServer() async {
final storage = await StorageService.getInstance();
final plexToken = storage.getPlexToken();
if (plexToken == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No Plex token found. Please login again.'),
),
);
}
return;
}
try {
final authService = await PlexAuthService.create();
// Navigate to server selection screen
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ServerSelectionScreen(
authService: authService,
plexToken: plexToken,
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to initialize server selection: $e')),
);
// Check and update in hub items
for (final hub in _hubs) {
final itemIndex = hub.items.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (itemIndex != -1) {
hub.items[itemIndex] = updatedMetadata;
}
}
}
@@ -213,16 +430,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
title: Text(t.common.logout),
content: Text(t.messages.logoutConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Logout'),
child: Text(t.common.logout),
),
],
),
@@ -234,14 +451,19 @@ class _DiscoverScreenState extends State<DiscoverScreen>
context,
listen: false,
);
final plexClientProvider = Provider.of<PlexClientProvider>(
context,
listen: false,
);
final plexClientProvider = context.plexClient;
final multiServerProvider = context.read<MultiServerProvider>();
final serverStateProvider = context.read<ServerStateProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
final playbackStateProvider = context.read<PlaybackStateProvider>();
// Clear all user data and provider states
await userProfileProvider.logout();
plexClientProvider.clearClient();
multiServerProvider.clearAllConnections();
serverStateProvider.reset();
await hiddenLibrariesProvider.refresh();
playbackStateProvider.clearShuffle();
if (mounted) {
Navigator.of(context).pushAndRemoveUntil(
@@ -267,7 +489,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
controller: _scrollController,
slivers: [
DesktopSliverAppBar(
title: const Text('Discover'),
title: Text(t.discover.title),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
@@ -292,8 +514,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'switch_server') {
_handleSwitchServer();
} else if (value == 'logout') {
_handleLogout();
}
@@ -301,33 +521,23 @@ class _DiscoverScreenState extends State<DiscoverScreen>
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
const PopupMenuItem(
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
Icon(Icons.people),
SizedBox(width: 8),
Text('Switch Profile'),
Text(t.discover.switchProfile),
],
),
),
const PopupMenuItem(
value: 'switch_server',
child: Row(
children: [
Icon(Icons.swap_horiz),
SizedBox(width: 8),
Text('Switch Server'),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 8),
Text('Logout'),
Text(t.discover.logout),
],
),
),
@@ -357,7 +567,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
@@ -365,7 +575,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
if (!_isLoading && _errorMessage == null) ...[
// Hero Section (Continue Watching)
if (_onDeck.isNotEmpty) _buildHeroSection(),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
return _buildHeroSection();
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
},
),
// On Deck / Continue Watching
if (_onDeck.isNotEmpty) ...[
@@ -377,38 +594,32 @@ class _DiscoverScreenState extends State<DiscoverScreen>
const Icon(Icons.play_circle_outline),
const SizedBox(width: 8),
Text(
'Continue Watching',
t.discover.continueWatching,
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
),
_buildHorizontalList(_onDeck, isLarge: false),
_buildHorizontalList(
_onDeck,
isLarge: false,
isInContinueWatching: true,
),
],
// Recently Added
if (_recentlyAdded.isNotEmpty) ...[
// Recommendation Hubs (Trending, Top in Genre, etc.)
for (final hub in _hubs)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
const Icon(Icons.fiber_new),
const SizedBox(width: 8),
Text(
'Recently Added',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
child: HubSection(
hub: hub,
icon: _getHubIcon(hub.title),
onRefresh: updateItem,
),
),
_buildHorizontalList(_recentlyAdded, isLarge: false),
],
if (_onDeck.isEmpty && _recentlyAdded.isEmpty)
const SliverFillRemaining(
if (_onDeck.isEmpty && _hubs.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -419,10 +630,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
color: Colors.grey,
),
SizedBox(height: 16),
Text('No content available'),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(
'Add some media to your libraries',
t.discover.addMediaToLibraries,
style: TextStyle(color: Colors.grey),
),
],
@@ -448,10 +659,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
controller: _heroController,
itemCount: _onDeck.length,
onPageChanged: (index) {
setState(() {
_currentHeroIndex = index;
});
_resetAutoScrollTimer();
// Validate index is within bounds before updating
if (index >= 0 && index < _onDeck.length) {
setState(() {
_currentHeroIndex = index;
});
_resetAutoScrollTimer();
}
},
itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]);
@@ -479,62 +693,79 @@ class _DiscoverScreenState extends State<DiscoverScreen>
color: Colors.white,
size: 18,
semanticLabel:
'${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll',
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
),
),
// Spacer to separate indicators from button
const SizedBox(width: 8),
// Page indicators
...List.generate(_onDeck.length, (index) {
final isActive = _currentHeroIndex == index;
if (isActive) {
// Animated progress indicator for active page
return AnimatedBuilder(
animation: _indicatorAnimationController,
builder: (context, child) {
// Fill width animates from 8px to 24px
final fillWidth =
8.0 +
(16.0 * _indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 24,
height: 8,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
// Page indicators (limited to 5 dots)
...() {
final range = _getVisibleDotRange();
return List.generate(range.end - range.start + 1, (i) {
final index = range.start + i;
final isActive = _currentHeroIndex == index;
final dotSize = _getDotSize(
index,
range.start,
range.end,
);
if (isActive) {
// Animated progress indicator for active page
return AnimatedBuilder(
animation: _indicatorAnimationController,
builder: (context, child) {
// Fill width animates based on dot size
final maxWidth =
dotSize * 3; // 24px for normal, 15px for small
final fillWidth =
dotSize +
((maxWidth - dotSize) *
_indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: maxWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
dotSize / 2,
),
),
),
);
},
);
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
),
);
}
}),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
dotSize / 2,
),
),
),
),
);
},
);
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: dotSize,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(dotSize / 2),
),
);
}
});
}(),
],
),
),
@@ -552,20 +783,20 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Determine content type label for chip
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
? 'Movie'
: 'TV Show';
? t.discover.movie
: t.discover.tvShow;
// Build semantic label for hero item
final heroLabel = isEpisode
? "${heroItem.grandparentTitle}, ${heroItem.title}"
: heroItem.title;
return Semantics(
label: "media-hero-${heroItem.ratingKey}",
identifier: "media-hero-${heroItem.ratingKey}",
label: heroLabel,
button: true,
hint: "Tap to play ${heroItem.title}",
hint: t.accessibility.tapToPlay,
child: GestureDetector(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
navigateToVideoPlayer(context, metadata: heroItem);
},
@@ -609,16 +840,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Opacity(opacity: value, child: child),
);
},
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
);
}
child: Builder(
builder: (context) {
final client = _getClientForItem(heroItem);
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
heroItem.art ?? heroItem.grandparentArt,
@@ -682,12 +906,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container();
}
child: Builder(
builder: (context) {
final client = _getClientForItem(heroItem);
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
heroItem.clearLogo,
@@ -793,9 +1014,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
[
contentTypeLabel,
if (heroItem.rating != null)
'${(heroItem.rating! / 10).toStringAsFixed(1)}',
'${heroItem.rating!.toStringAsFixed(1)}',
if (heroItem.contentRating != null)
heroItem.contentRating!,
formatContentRating(heroItem.contentRating!),
if (heroItem.year != null)
heroItem.year.toString(),
].join(''),
@@ -887,10 +1108,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return InkWell(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Playing: ${heroItem.title}');
navigateToVideoPlayer(context, metadata: heroItem);
},
@@ -928,7 +1145,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
const SizedBox(width: 8),
Text(
'$minutesLeft min left',
t.discover.minutesLeft(minutes: minutesLeft),
style: const TextStyle(
color: Colors.black,
fontSize: 14,
@@ -936,8 +1153,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
),
] else
const Text(
'Play',
Text(
t.discover.play,
style: TextStyle(
color: Colors.black,
fontSize: 14,
@@ -953,45 +1170,58 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Widget _buildHorizontalList(
List<PlexMetadata> items, {
bool isLarge = false,
bool isInContinueWatching = false,
}) {
return SliverToBoxAdapter(
child: LayoutBuilder(
builder: (context, constraints) {
// Responsive card width based on screen size
// Match Libraries screen sizing (190px baseline)
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth > 1600
? 220.0
: screenWidth > 1200
? 200.0
: screenWidth > 800
? 160.0
: 130.0;
? 190.0
: 160.0;
// MediaCard has 8px padding on all sides (16px total horizontally)
// So actual poster width is cardWidth - 16
final posterWidth = cardWidth - 16;
// 2:3 poster aspect ratio (height is 1.5x width)
final cardHeight = cardWidth * 1.5;
final posterHeight = posterWidth * 1.5;
// Container height = poster + padding + spacing + text
// 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding
final containerHeight = cardHeight + 46;
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
final containerHeight = posterHeight + 46;
return SizedBox(
height: containerHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: MediaCard(
key: Key(item.ratingKey),
item: item,
width: cardWidth,
height: cardHeight,
onRefresh: updateItem,
),
);
},
child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: MediaCard(
key: Key(item.ratingKey),
item: item,
width: cardWidth,
height: posterHeight,
onRefresh: updateItem,
onRemoveFromContinueWatching: isInContinueWatching
? _refreshContinueWatching
: null,
forceGridMode: true,
isInContinueWatching: isInContinueWatching,
),
);
},
),
),
);
},
+314
View File
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_hub.dart';
import '../models/plex_metadata.dart';
import '../models/plex_sort.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/sort_bottom_sheet.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
/// Screen to display full content of a recommendation hub
class HubDetailScreen extends StatefulWidget {
final PlexHub hub;
const HubDetailScreen({super.key, required this.hub});
@override
State<HubDetailScreen> createState() => _HubDetailScreenState();
}
class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
PlexClient get client => _getClientForHub();
List<PlexMetadata> _items = [];
List<PlexMetadata> _filteredItems = [];
List<PlexSort> _sortOptions = [];
PlexSort? _selectedSort;
bool _isSortDescending = false;
bool _isLoading = false;
String? _errorMessage;
/// Get the correct PlexClient for this hub's server
PlexClient _getClientForHub() {
return context.getClientForServer(widget.hub.serverId!);
}
@override
void initState() {
super.initState();
// Start with items already loaded in the hub
_items = widget.hub.items;
_filteredItems = widget.hub.items;
// Load more items if available
if (widget.hub.more) {
_loadMoreItems();
}
// Load sorts based on the library type
_loadSorts();
}
Future<void> _loadSorts() async {
try {
final client = _getClientForHub();
// Get the library key from the hub key
// Hub keys can have various formats:
// - /hubs/sections/1/...
// - /library/sections/1/all?...
final hubKey = widget.hub.hubKey;
appLogger.d('Hub key: $hubKey');
// Try different patterns
RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey);
match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey);
match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey);
if (match != null) {
final sectionId = match.group(1)!;
appLogger.d('Loading sorts for section: $sectionId');
// Load sorts for this library
final sorts = await client.getLibrarySorts(sectionId);
appLogger.d('Loaded ${sorts.length} sorts');
setState(() {
_sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions();
// Don't set a default sort - let items stay in original order
});
} else {
appLogger.w('Could not extract section ID from hub key: $hubKey');
// Provide default sort options even if we can't get library-specific ones
setState(() {
_sortOptions = _getDefaultSortOptions();
// Don't set a default sort - let items stay in original order
});
}
} catch (e) {
appLogger.e('Failed to load sorts', error: e);
// Provide default sort options on error
setState(() {
_sortOptions = _getDefaultSortOptions();
// Don't set a default sort - let items stay in original order
});
}
}
List<PlexSort> _getDefaultSortOptions() {
return [
PlexSort(
key: 'titleSort',
title: t.hubDetail.title,
defaultDirection: 'asc',
),
PlexSort(
key: 'year',
descKey: 'year:desc',
title: t.hubDetail.releaseYear,
defaultDirection: 'desc',
),
PlexSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: t.hubDetail.dateAdded,
defaultDirection: 'desc',
),
PlexSort(
key: 'rating',
descKey: 'rating:desc',
title: t.hubDetail.rating,
defaultDirection: 'desc',
),
];
}
void _applySort() {
setState(() {
_filteredItems = List.from(_items);
// Apply sorting
if (_selectedSort != null) {
final sortKey = _selectedSort!.key;
_filteredItems.sort((a, b) {
int comparison = 0;
switch (sortKey) {
case 'titleSort':
case 'title':
comparison = a.title.compareTo(b.title);
break;
case 'addedAt':
comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0);
break;
case 'originallyAvailableAt':
case 'year':
comparison = (a.year ?? 0).compareTo(b.year ?? 0);
break;
case 'rating':
comparison = (a.rating ?? 0).compareTo(b.rating ?? 0);
break;
default:
comparison = a.title.compareTo(b.title);
}
return _isSortDescending ? -comparison : comparison;
});
}
});
}
void _showSortBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
isSortDescending: _isSortDescending,
onSortChanged: (sort, descending) {
setState(() {
_selectedSort = sort;
_isSortDescending = descending;
});
_applySort();
},
onClear: () {
setState(() {
// Reset to no sorting (original order)
_selectedSort = null;
_isSortDescending = false;
});
_applySort();
},
),
);
}
Future<void> _loadMoreItems() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final client = _getClientForHub();
// Fetch items from the hub, tagged with server info at the source
final items = await client.getHubContent(widget.hub.hubKey);
setState(() {
_items = items;
_filteredItems = items;
_isLoading = false;
});
// Apply any existing sort
_applySort();
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
} catch (e) {
appLogger.e('Failed to load hub content', error: e);
setState(() {
_errorMessage = t.messages.errorLoading(error: e.toString());
_isLoading = false;
});
}
}
void _handleItemRefresh(String ratingKey) {
// Refresh the specific item in the list
setState(() {
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
// The item will be refreshed by the MediaCard itself
appLogger.d('Item refresh requested for: $ratingKey');
}
});
}
@override
void refresh() {
_loadMoreItems();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(widget.hub.title),
pinned: true,
actions: [
IconButton(
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
onPressed: _showSortBottomSheet,
),
],
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadMoreItems,
child: Text(t.common.retry),
),
],
),
),
)
else if (_filteredItems.isEmpty && _isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_filteredItems.isEmpty)
SliverFillRemaining(
child: Center(child: Text(t.hubDetail.noItemsFound)),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context,
context.watch<SettingsProvider>().libraryDensity,
16,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return MediaCard(
item: _filteredItems[index],
onRefresh: _handleItemRefresh,
);
}, childCount: _filteredItems.length),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../models/plex_library.dart';
import '../../utils/app_logger.dart';
import '../../mixins/library_tab_state.dart';
import '../../mixins/refreshable.dart';
import '../../widgets/content_state_builder.dart';
/// Base class for library tab screens that provides common state management
/// and lifecycle handling for tabs that display library content.
///
/// Type parameter T: The type of items this tab displays
///
/// Subclasses must implement:
/// - [loadData]: Load data from the Plex API
/// - [buildContent]: Build the UI for displaying loaded items
///
/// Optional overrides:
/// - [emptyIcon]: Icon to show when there are no items
/// - [emptyMessage]: Message to show when there are no items
/// - [errorContext]: Context for error messages (defaults to "content")
/// - [getRefreshStream]: Stream to listen for refresh events
abstract class BaseLibraryTab<T> extends StatefulWidget {
final PlexLibrary library;
final String? viewMode;
final String? density;
const BaseLibraryTab({
super.key,
required this.library,
this.viewMode,
this.density,
});
}
/// State mixin that provides the common implementation for library tabs
/// This preserves AutomaticKeepAliveClientMixin functionality
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
extends State<W>
with AutomaticKeepAliveClientMixin, Refreshable, LibraryTabStateMixin {
@override
bool get wantKeepAlive => true;
@override
PlexLibrary get library => widget.library;
@override
void refresh() {
loadItems();
}
// State management
List<T> _items = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
// Getters for subclasses
List<T> get items => _items;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
@override
void initState() {
super.initState();
loadItems();
// Subscribe to refresh stream if provided
final refreshStream = getRefreshStream();
if (refreshStream != null) {
_refreshSubscription = refreshStream.listen((_) {
if (mounted) {
loadItems();
}
});
}
}
@override
void dispose() {
_refreshSubscription?.cancel();
super.dispose();
}
@override
void didUpdateWidget(W oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
loadItems();
}
}
/// Load items from the API
/// This is the main data loading function that subclasses must implement
Future<List<T>> loadData();
/// Build the content widget given the loaded items
/// This is called by ContentStateBuilder when items are available
Widget buildContent(List<T> items);
/// Icon to display when there are no items (empty state)
IconData get emptyIcon;
/// Message to display when there are no items (empty state)
String get emptyMessage;
/// Context string for error messages (e.g., "playlists", "collections")
String get errorContext;
/// Optional refresh stream to listen for external refresh events
/// Return null if no refresh stream is needed
Stream<void>? getRefreshStream() => null;
/// Load items with error handling and state management
Future<void> loadItems() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final loadedItems = await loadData();
if (!mounted) return;
setState(() {
_items = loadedItems;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
appLogger.e('Error loading $errorContext', error: e);
setState(() {
_errorMessage = 'Failed to load $errorContext: ${e.toString()}';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return ContentStateBuilder<T>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _items,
emptyIcon: emptyIcon,
emptyMessage: emptyMessage,
onRetry: loadItems,
builder: (items) =>
RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
);
}
}
@@ -0,0 +1,584 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
import '../../client/plex_client.dart';
import '../../models/plex_library.dart';
import '../../models/plex_metadata.dart';
import '../../models/plex_filter.dart';
import '../../models/plex_sort.dart';
import '../../providers/settings_provider.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/grid_size_calculator.dart';
import '../../widgets/media_card.dart';
import '../../widgets/folder_tree_view.dart';
import '../../widgets/filters_bottom_sheet.dart';
import '../../widgets/sort_bottom_sheet.dart';
import '../../widgets/empty_state_widget.dart';
import '../../widgets/error_state_widget.dart';
import '../../services/storage_service.dart';
import '../../services/settings_service.dart' show ViewMode;
import '../../mixins/item_updatable.dart';
import '../../mixins/library_tab_state.dart';
import '../../mixins/refreshable.dart';
import '../../i18n/strings.g.dart';
/// Browse tab for library screen
/// Shows library items with grouping, filtering, and sorting
class LibraryBrowseTab extends StatefulWidget {
final PlexLibrary library;
final String? viewMode;
final String? density;
const LibraryBrowseTab({
super.key,
required this.library,
this.viewMode,
this.density,
});
@override
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
}
class _LibraryBrowseTabState extends State<LibraryBrowseTab>
with
AutomaticKeepAliveClientMixin,
ItemUpdatable,
Refreshable,
LibraryTabStateMixin {
@override
bool get wantKeepAlive => true;
@override
PlexLibrary get library => widget.library;
@override
PlexClient get client => getClientForLibrary();
@override
void refresh() {
_loadContent();
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
setState(() {
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
_items[index] = updatedMetadata;
}
});
}
List<PlexMetadata> _items = [];
List<PlexFilter> _filters = [];
List<PlexSort> _sortOptions = [];
bool _isLoading = false;
String? _errorMessage;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
// Pagination state
int _currentPage = 0;
bool _hasMoreItems = true;
CancelToken? _cancelToken;
int _requestId = 0;
static const int _pageSize = 500;
@override
void initState() {
super.initState();
_loadContent();
}
@override
void didUpdateWidget(LibraryBrowseTab oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
_loadContent();
}
}
@override
void dispose() {
_cancelToken?.cancel();
super.dispose();
}
Future<void> _loadContent() async {
// Cancel any pending request
_cancelToken?.cancel();
_cancelToken = CancelToken();
final currentRequestId = ++_requestId;
// Extract context dependencies before async gap - use server-specific client
final client = getClientForLibrary();
setState(() {
_isLoading = true;
_errorMessage = null;
_items = [];
_currentPage = 0;
_hasMoreItems = true;
});
try {
final storage = await StorageService.getInstance();
// Load filters and sorts for this library
final filters = await client.getLibraryFilters(widget.library.key);
final sorts = await client.getLibrarySorts(widget.library.key);
// Load saved preferences
final savedFilters = storage.getLibraryFilters(
sectionId: widget.library.globalKey,
);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(
widget.library.globalKey,
);
// Check if request was cancelled
if (currentRequestId != _requestId) return;
setState(() {
_filters = filters;
_sortOptions = sorts;
_selectedFilters = Map.from(savedFilters);
_selectedGrouping = savedGrouping ?? _getDefaultGrouping();
// Restore sort
if (savedSort != null) {
final sortKey = savedSort['key'] as String?;
if (sortKey != null) {
final sort = sorts.where((s) => s.key == sortKey).firstOrNull;
if (sort != null) {
_selectedSort = sort;
_isSortDescending = (savedSort['descending'] as bool?) ?? false;
}
}
}
});
// Load items
await _loadItems();
} catch (e) {
if (currentRequestId != _requestId) return;
setState(() {
_errorMessage = _getErrorMessage(e);
_isLoading = false;
});
}
}
Future<void> _loadItems({bool loadMore = false}) async {
if (loadMore && _isLoading) return;
if (!loadMore) {
_currentPage = 0;
_hasMoreItems = true;
}
if (!_hasMoreItems) return;
final currentRequestId = _requestId;
_cancelToken?.cancel();
_cancelToken = CancelToken();
setState(() {
_isLoading = true;
if (!loadMore) {
_items = [];
}
});
try {
// Use server-specific client for this library
final client = getClientForLibrary();
// Build filter params
final filterParams = Map<String, String>.from(_selectedFilters);
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
}
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(
descending: _isSortDescending,
);
}
// Items are automatically tagged with server info by PlexClient
final items = await client.getLibraryContent(
widget.library.key,
start: _currentPage * _pageSize,
size: _pageSize,
filters: filterParams,
cancelToken: _cancelToken,
);
if (currentRequestId != _requestId) return;
setState(() {
if (loadMore) {
_items.addAll(items);
} else {
_items = items;
}
_hasMoreItems = items.length >= _pageSize;
_currentPage++;
_isLoading = false;
});
} catch (e) {
if (currentRequestId != _requestId) return;
setState(() {
_errorMessage = _getErrorMessage(e);
_isLoading = false;
});
}
}
String _getDefaultGrouping() {
final type = widget.library.type.toLowerCase();
if (type == 'show') {
return 'shows';
} else if (type == 'movie') {
return 'movies';
}
return 'all';
}
String _getGroupingTypeId() {
switch (_selectedGrouping) {
case 'movies':
return '1';
case 'shows':
return '2';
case 'seasons':
return '3';
case 'episodes':
return '4';
default:
return '';
}
}
List<String> _getGroupingOptions() {
final type = widget.library.type.toLowerCase();
if (type == 'show') {
return ['shows', 'seasons', 'episodes', 'folders'];
} else if (type == 'movie') {
return ['movies', 'folders'];
}
// All library types support folder browsing
return ['all', 'folders'];
}
String _getGroupingLabel(String grouping) {
switch (grouping) {
case 'movies':
return t.libraries.groupings.movies;
case 'shows':
return t.libraries.groupings.shows;
case 'seasons':
return t.libraries.groupings.seasons;
case 'episodes':
return t.libraries.groupings.episodes;
case 'folders':
return t.libraries.groupings.folders;
default:
return t.libraries.groupings.all;
}
}
String _getErrorMessage(dynamic error) {
if (error is DioException) {
return mapDioErrorToMessage(error, context: t.libraries.content);
}
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
}
void _showGroupingBottomSheet() {
showModalBottomSheet(
context: context,
builder: (context) {
return ListView(
shrinkWrap: true,
children: _getGroupingOptions().map((grouping) {
return RadioListTile<String>(
title: Text(_getGroupingLabel(grouping)),
value: grouping,
// ignore: deprecated_member_use
groupValue: _selectedGrouping,
// ignore: deprecated_member_use
onChanged: (value) async {
if (value != null) {
setState(() {
_selectedGrouping = value;
});
final storage = await StorageService.getInstance();
await storage.saveLibraryGrouping(
widget.library.globalKey,
value,
);
if (!context.mounted) return;
Navigator.pop(context);
_loadItems();
}
},
);
}).toList(),
);
},
);
}
void _showFiltersBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => FiltersBottomSheet(
filters: _filters,
selectedFilters: _selectedFilters,
serverId: widget.library.serverId!,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(
filters,
sectionId: widget.library.globalKey,
);
_loadItems();
},
),
);
}
void _showSortBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
isSortDescending: _isSortDescending,
onSortChanged: (sort, descending) {
setState(() {
_selectedSort = sort;
_isSortDescending = descending;
});
StorageService.getInstance().then((storage) {
storage.saveLibrarySort(
widget.library.globalKey,
sort.key,
descending: descending,
);
});
_loadItems();
},
),
);
}
Widget _buildFilterChip({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return Column(
children: [
// Filter bar with chips
Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Grouping chip
_buildFilterChip(
icon: Icons.category,
label: _getGroupingLabel(_selectedGrouping),
onPressed: _showGroupingBottomSheet,
),
const SizedBox(width: 8),
// Filters chip
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
_buildFilterChip(
icon: Icons.filter_alt,
label: _selectedFilters.isEmpty
? t.libraries.filters
: t.libraries.filtersWithCount(
count: _selectedFilters.length,
),
onPressed: _showFiltersBottomSheet,
),
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
const SizedBox(width: 8),
// Sort chip
if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders')
_buildFilterChip(
icon: Icons.sort,
label: _selectedSort?.title ?? t.libraries.sort,
onPressed: _showSortBottomSheet,
),
],
),
),
),
// Content
Expanded(child: _buildContent()),
],
);
}
Widget _buildContent() {
// Show folder tree view when in folders mode
if (_selectedGrouping == 'folders') {
return FolderTreeView(
libraryKey: widget.library.key,
serverId: widget.library.serverId,
onRefresh: updateItem,
);
}
if (_isLoading && _items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null && _items.isEmpty) {
return ErrorStateWidget(
message: _errorMessage!,
icon: Icons.error_outline,
onRetry: _loadContent,
retryLabel: t.common.retry,
);
}
if (_items.isEmpty) {
return EmptyStateWidget(
message: t.libraries.thisLibraryIsEmpty,
icon: Icons.folder_open,
);
}
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 300 &&
_hasMoreItems &&
!_isLoading) {
_loadItems(loadMore: true);
}
return false;
},
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
);
} else {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
);
}
},
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../models/plex_metadata.dart';
import '../../utils/library_refresh_notifier.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/adaptive_media_grid.dart';
import 'base_library_tab.dart';
/// Collections tab for library screen
/// Shows collections for the current library
class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
const LibraryCollectionsTab({
super.key,
required super.library,
super.viewMode,
super.density,
});
@override
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
}
class _LibraryCollectionsTabState
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> {
@override
IconData get emptyIcon => Icons.collections;
@override
String get emptyMessage => t.libraries.noCollections;
@override
String get errorContext => t.collections.title;
@override
Stream<void>? getRefreshStream() =>
LibraryRefreshNotifier().collectionsStream;
@override
Future<List<PlexMetadata>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
// Collections are automatically tagged with server info by PlexClient
return await client.getLibraryCollections(widget.library.key);
}
@override
Widget buildContent(List<PlexMetadata> items) {
return AdaptiveMediaGrid(items: items, onRefresh: loadItems);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/plex_playlist.dart';
import '../../providers/settings_provider.dart';
import '../../utils/library_refresh_notifier.dart';
import '../../services/settings_service.dart' show ViewMode;
import '../../utils/grid_size_calculator.dart';
import '../../widgets/media_card.dart';
import '../../i18n/strings.g.dart';
import 'base_library_tab.dart';
/// Playlists tab for library screen
/// Shows playlists that contain items from the current library
class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
const LibraryPlaylistsTab({
super.key,
required super.library,
super.viewMode,
super.density,
});
@override
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
}
class _LibraryPlaylistsTabState
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> {
@override
IconData get emptyIcon => Icons.playlist_play;
@override
String get emptyMessage => t.playlists.noPlaylists;
@override
String get errorContext => t.playlists.title;
@override
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
@override
Future<List<PlexPlaylist>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
// Playlists are automatically tagged with server info by PlexClient
return await client.getLibraryPlaylists(
sectionId: widget.library.key,
playlistType: 'video',
);
}
@override
Widget buildContent(List<PlexPlaylist> items) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
);
},
);
} else {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
itemCount: items.length,
itemBuilder: (context, index) {
final playlist = items[index];
return MediaCard(
key: Key(playlist.ratingKey),
item: playlist,
onListRefresh: loadItems,
);
},
);
}
},
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../models/plex_hub.dart';
import '../../widgets/hub_section.dart';
import '../../i18n/strings.g.dart';
import 'base_library_tab.dart';
/// Recommended tab for library screen
/// Shows library-specific hubs and recommendations
class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
const LibraryRecommendedTab({super.key, required super.library});
@override
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
}
class _LibraryRecommendedTabState
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab> {
@override
IconData get emptyIcon => Icons.recommend;
@override
String get emptyMessage => t.libraries.noRecommendations;
@override
String get errorContext => t.libraries.tabs.recommended;
@override
Future<List<PlexHub>> loadData() async {
// Use server-specific client for this library
final client = getClientForLibrary();
// Hubs are now tagged with server info at the source
return await client.getLibraryHubs(widget.library.key, limit: 12);
}
@override
Widget buildContent(List<PlexHub> items) {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(hub: hub, icon: _getHubIcon(hub));
},
);
}
IconData _getHubIcon(PlexHub hub) {
final title = hub.title.toLowerCase();
if (title.contains('continue watching') || title.contains('on deck')) {
return Icons.play_circle;
} else if (title.contains('recently') || title.contains('new')) {
return Icons.fiber_new;
} else if (title.contains('popular') || title.contains('trending')) {
return Icons.trending_up;
} else if (title.contains('top') || title.contains('rated')) {
return Icons.star;
} else if (title.contains('recommended')) {
return Icons.thumb_up;
} else if (title.contains('unwatched')) {
return Icons.visibility_off;
} else if (title.contains('genre')) {
return Icons.category;
}
return Icons.movie;
}
}
+10 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
class MergedLicenseEntry {
final String packageName;
@@ -73,7 +74,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Licenses'), pinned: true),
CustomAppBar(title: Text(t.screens.licenses), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -92,7 +93,9 @@ class _LicensesScreenState extends State<LicensesScreen> {
),
subtitle: mergedLicense.licenseEntries.length > 1
? Text(
'${mergedLicense.licenseEntries.length} licenses',
t.licenses.licensesCount(
count: mergedLicense.licenseEntries.length,
),
)
: null,
trailing: const Icon(Icons.chevron_right),
@@ -145,7 +148,7 @@ class _LicenseDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Related Packages',
t.licenses.relatedPackages,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
@@ -177,8 +180,10 @@ class _LicenseDetailScreen extends StatelessWidget {
children: [
Text(
isMultipleLicenses
? 'License ${index + 1}'
: 'License',
? t.licenses.licenseNumber(
number: index + 1,
)
: t.licenses.license,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
+306
View File
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../widgets/desktop_app_bar.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@override
State<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
List<LogEntry> _logs = [];
@override
void initState() {
super.initState();
_loadLogs();
}
void _loadLogs() {
setState(() {
_logs = MemoryLogOutput.getLogs();
});
}
String _formatTime(DateTime time) {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
final second = time.second.toString().padLeft(2, '0');
final millisecond = time.millisecond.toString().padLeft(3, '0');
return '$hour:$minute:$second.$millisecond';
}
void _clearLogs() {
setState(() {
MemoryLogOutput.clearLogs();
_logs = [];
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.logsCleared)));
}
void _copyAllLogs() {
final buffer = StringBuffer();
bool isFirst = true;
for (final log in _logs.reversed) {
if (!isFirst) {
buffer.write('\n');
}
isFirst = false;
buffer.write(
'[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}',
);
if (log.error != null) {
buffer.write('\nError: ${log.error}');
}
if (log.stackTrace != null) {
buffer.write('\nStack trace:\n${log.stackTrace}');
}
}
Clipboard.setData(ClipboardData(text: buffer.toString()));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.logsCopied)));
}
Color _getLevelColor(Level level) {
switch (level) {
case Level.error:
case Level.fatal:
return Colors.red;
case Level.warning:
return Colors.orange;
case Level.info:
return Colors.blue;
case Level.debug:
case Level.trace:
return Colors.grey;
default:
return Colors.grey;
}
}
IconData _getLevelIcon(Level level) {
switch (level) {
case Level.error:
case Level.fatal:
return Icons.error;
case Level.warning:
return Icons.warning;
case Level.info:
return Icons.info;
case Level.debug:
case Level.trace:
return Icons.bug_report;
default:
return Icons.circle;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(t.screens.logs),
pinned: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLogs,
tooltip: t.common.refresh,
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
tooltip: t.logs.copyLogs,
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _logs.isNotEmpty ? _clearLogs : null,
tooltip: t.logs.clearLogs,
),
],
),
if (_logs.isEmpty)
SliverFillRemaining(
child: Center(child: Text(t.messages.noLogsAvailable)),
)
else
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final log = _logs[index];
return _LogEntryCard(
log: log,
formatTime: _formatTime,
levelColor: _getLevelColor(log.level),
levelIcon: _getLevelIcon(log.level),
);
}, childCount: _logs.length),
),
),
],
),
);
}
}
class _LogEntryCard extends StatefulWidget {
final LogEntry log;
final String Function(DateTime) formatTime;
final Color levelColor;
final IconData levelIcon;
const _LogEntryCard({
required this.log,
required this.formatTime,
required this.levelColor,
required this.levelIcon,
});
@override
State<_LogEntryCard> createState() => _LogEntryCardState();
}
class _LogEntryCardState extends State<_LogEntryCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final hasErrorOrStackTrace =
widget.log.error != null || widget.log.stackTrace != null;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: InkWell(
onTap: hasErrorOrStackTrace
? () => setState(() => _isExpanded = !_isExpanded)
: null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(widget.levelIcon, color: widget.levelColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.log.level.name.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: widget.levelColor,
fontSize: 12,
),
),
const SizedBox(width: 8),
Text(
widget.formatTime(widget.log.timestamp),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color
?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
widget.log.message,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
if (hasErrorOrStackTrace)
Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
color: Theme.of(
context,
).iconTheme.color?.withValues(alpha: 0.6),
),
],
),
if (_isExpanded && hasErrorOrStackTrace) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
if (widget.log.error != null) ...[
Text(
t.logs.error,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
widget.log.error.toString(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
if (widget.log.stackTrace != null) ...[
const SizedBox(height: 12),
Text(
t.logs.stackTrace,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
widget.log.stackTrace.toString(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
],
],
),
),
),
);
}
}
+62 -24
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../main.dart';
import '../mixins/refreshable.dart';
import '../providers/multi_server_provider.dart';
import '../providers/server_state_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import 'discover_screen.dart';
import 'libraries_screen.dart';
import 'search_screen.dart';
@@ -90,25 +98,55 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
}
/// Invalidate all cached data across all screens when profile is switched
void _invalidateAllScreens() {
appLogger.d('Invalidating all screen data due to profile switch');
/// Receives the list of servers with new profile tokens for reconnection
Future<void> _invalidateAllScreens(List<PlexServer> servers) async {
appLogger.d(
'Invalidating all screen data due to profile switch with ${servers.length} servers',
);
// Refresh discover screen
// Get all providers
final multiServerProvider = context.read<MultiServerProvider>();
final serverStateProvider = context.read<ServerStateProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
final playbackStateProvider = context.read<PlaybackStateProvider>();
// Reconnect to all servers with new profile tokens
if (servers.isNotEmpty) {
final storage = await StorageService.getInstance();
final clientId = storage.getClientIdentifier();
final connectedCount = await multiServerProvider.reconnectWithServers(
servers,
clientIdentifier: clientId,
);
appLogger.d(
'Reconnected to $connectedCount/${servers.length} servers after profile switch',
);
}
// Reset other provider states
serverStateProvider.reset();
hiddenLibrariesProvider.refresh();
playbackStateProvider.clearShuffle();
appLogger.d('Cleared all provider states for profile switch');
// Full refresh discover screen (reload all content for new profile)
final discoverState = _discoverKey.currentState;
if (discoverState != null && discoverState is Refreshable) {
(discoverState as Refreshable).refresh();
if (discoverState != null) {
(discoverState as dynamic).fullRefresh();
}
// Refresh libraries screen
// Full refresh libraries screen (clear filters and reload for new profile)
final librariesState = _librariesKey.currentState;
if (librariesState != null && librariesState is Refreshable) {
(librariesState as Refreshable).refresh();
if (librariesState != null) {
(librariesState as dynamic).fullRefresh();
}
// Refresh search screen
// Full refresh search screen (clear search for new profile)
final searchState = _searchKey.currentState;
if (searchState != null && searchState is Refreshable) {
(searchState as Refreshable).refresh();
if (searchState != null) {
(searchState as dynamic).fullRefresh();
}
}
@@ -127,26 +165,26 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
_onDiscoverBecameVisible();
}
},
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
),
NavigationDestination(
icon: Icon(Icons.video_library_outlined),
selectedIcon: Icon(Icons.video_library),
label: 'Libraries',
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
),
NavigationDestination(
icon: Icon(Icons.search),
selectedIcon: Icon(Icons.search),
label: 'Search',
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
],
),
+438 -130
View File
@@ -1,15 +1,21 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/media_context_menu.dart';
import '../providers/playback_state_provider.dart';
import '../theme/theme_helper.dart';
import '../utils/app_logger.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../theme/theme_helper.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/horizontal_scroll_with_arrows.dart';
import '../widgets/media_context_menu.dart';
import 'season_detail_screen.dart';
class MediaDetailScreen extends StatefulWidget {
@@ -43,17 +49,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
super.dispose();
}
/// Get the correct PlexClient for this metadata's server
PlexClient _getClientForMetadata(BuildContext context) {
return context.getClientForServer(widget.metadata.serverId!);
}
Future<void> _loadFullMetadata() async {
setState(() {
_isLoadingMetadata = true;
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
// Fetch full metadata with clearLogo and OnDeck episode
final result = await client.getMetadataWithImagesAndOnDeck(
@@ -63,9 +71,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
if (metadata != null) {
// Preserve serverId from original metadata
final metadataWithServerId = metadata.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
);
final onDeckWithServerId = onDeckEpisode?.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
);
setState(() {
_fullMetadata = metadata;
_onDeckEpisode = onDeckEpisode;
_fullMetadata = metadataWithServerId;
_onDeckEpisode = onDeckWithServerId;
_isLoadingMetadata = false;
});
@@ -104,15 +122,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
final seasons = await client.getChildren(widget.metadata.ratingKey);
// Preserve serverId for each season
final seasonsWithServerId = seasons
.map(
(season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
),
)
.toList();
setState(() {
_seasons = seasons;
_seasons = seasonsWithServerId;
_isLoadingSeasons = false;
});
} catch (e) {
@@ -126,26 +150,38 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
/// This preserves scroll position and only updates watch-related data
Future<void> _updateWatchState() async {
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
final metadata = await client.getMetadataWithImages(
widget.metadata.ratingKey,
);
if (metadata != null) {
// Preserve serverId from original metadata
final metadataWithServerId = metadata.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
);
// For shows, also refetch seasons to update their watch counts
List<PlexMetadata>? updatedSeasons;
if (metadata.type.toLowerCase() == 'show') {
updatedSeasons = await client.getChildren(widget.metadata.ratingKey);
final seasons = await client.getChildren(widget.metadata.ratingKey);
// Preserve serverId for each season
updatedSeasons = seasons
.map(
(season) => season.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
),
)
.toList();
}
// Single setState to minimize rebuilds - scroll position is preserved by controller
setState(() {
_fullMetadata = metadata;
_fullMetadata = metadataWithServerId;
if (updatedSeasons != null) {
_seasons = updatedSeasons;
}
@@ -159,12 +195,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
Future<void> _playFirstEpisode() async {
try {
// Extract context dependencies before async operations
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
// If seasons aren't loaded yet, wait for them or load them
if (_seasons.isEmpty && !_isLoadingSeasons) {
@@ -180,7 +212,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No seasons found')));
).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound)));
}
return;
}
@@ -193,22 +225,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (episodes.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No episodes found in first season')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
}
return;
}
// Play the first episode
final firstEpisode = episodes.first;
// Preserve serverId for the episode
final episodeWithServerId = firstEpisode.copyWith(
serverId: widget.metadata.serverId,
serverName: widget.metadata.serverName,
);
if (mounted) {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Playing first episode: ${firstEpisode.title}');
await navigateToVideoPlayer(context, metadata: firstEpisode);
// Client already retrieved earlier in the method
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
await navigateToVideoPlayer(context, metadata: episodeWithServerId);
appLogger.d('Returned from playback, refreshing metadata');
// Refresh metadata when returning from video player
_loadFullMetadata();
@@ -216,7 +250,101 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading first episode: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
/// Handle shuffle play using play queues
Future<void> _handleShufflePlayWithQueue(
BuildContext context,
PlexMetadata metadata,
) async {
final client = _getClientForMetadata(context);
final playbackState = context.read<PlaybackStateProvider>();
final itemType = metadata.type.toLowerCase();
try {
// Show loading indicator
if (context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
// Determine the rating key for the play queue
String showRatingKey;
if (itemType == 'show') {
showRatingKey = metadata.ratingKey;
} else if (itemType == 'season') {
// For seasons, we need the show's rating key
// The season's parentRatingKey should point to the show
if (metadata.parentRatingKey == null) {
throw Exception('Season is missing parentRatingKey');
}
showRatingKey = metadata.parentRatingKey!;
} else {
throw Exception('Shuffle play only works for shows and seasons');
}
// Create a shuffled play queue for the show
final playQueue = await client.createShowPlayQueue(
showRatingKey: showRatingKey,
shuffle: 1,
);
// Close loading indicator
if (context.mounted) {
Navigator.pop(context);
}
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
}
return;
}
// Initialize playback state with the play queue
await playbackState.setPlaybackFromPlayQueue(
playQueue,
showRatingKey,
serverId: metadata.serverId,
serverName: metadata.serverName,
);
// Set the client for the playback state provider
playbackState.setClient(client);
// Navigate to the first episode in the shuffled queue
final firstEpisode = playQueue.items!.first.copyWith(
serverId: metadata.serverId,
serverName: metadata.serverName,
);
if (context.mounted) {
await navigateToVideoPlayer(context, metadata: firstEpisode);
// Refresh metadata when returning from video player
_loadFullMetadata();
}
} catch (e) {
// Close loading indicator if it's still open
if (context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
@@ -259,16 +387,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
children: [
// Background Art
if (metadata.art != null)
Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
);
}
Builder(
builder: (context) {
final client = _getClientForMetadata(context);
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(metadata.art),
fit: BoxFit.cover,
@@ -325,21 +446,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Text(
metadata.title,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
child: Builder(
builder: (context) {
final client = _getClientForMetadata(
context,
);
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
metadata.clearLogo,
@@ -461,7 +572,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
borderRadius: BorderRadius.circular(6),
),
child: Text(
metadata.contentRating!,
formatContentRating(
metadata.contentRating!,
),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
@@ -482,7 +595,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
borderRadius: BorderRadius.circular(6),
),
child: Text(
_formatDuration(metadata.duration!),
formatDurationTextual(metadata.duration!),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
@@ -490,6 +603,70 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
),
),
if (metadata.rating != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'${(metadata.rating! * 10).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (metadata.audienceRating != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'${(metadata.audienceRating! * 10).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
@@ -521,10 +698,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Otherwise, play the first episode of the first season
if (metadata.type.toLowerCase() == 'show') {
if (_onDeckEpisode != null) {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d(
'Playing on deck episode: ${_onDeckEpisode!.title}',
);
@@ -542,10 +715,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
await _playFirstEpisode();
}
} else {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Playing: ${metadata.title}');
// For movies or episodes, play directly
await navigateToVideoPlayer(
@@ -573,19 +742,37 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
),
const SizedBox(width: 12),
// Shuffle button (only for shows and seasons)
if (metadata.type.toLowerCase() == 'show' ||
metadata.type.toLowerCase() == 'season') ...[
IconButton.filledTonal(
onPressed: () async {
await _handleShufflePlayWithQueue(
context,
metadata,
);
},
icon: const Icon(Icons.shuffle),
tooltip: t.tooltips.shufflePlay,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
maximumSize: const Size(48, 48),
),
),
const SizedBox(width: 12),
],
IconButton.filledTonal(
onPressed: () async {
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
final client = _getClientForMetadata(context);
await client.markAsWatched(metadata.ratingKey);
if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Marked as watched'),
SnackBar(
content: Text(t.messages.markedAsWatched),
),
);
// Update watch state without full rebuild
@@ -594,13 +781,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(
content: Text(
t.messages.errorLoading(
error: e.toString(),
),
),
),
);
}
}
},
icon: const Icon(Icons.check),
tooltip: 'Mark as watched',
tooltip: t.tooltips.markAsWatched,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
@@ -611,16 +804,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
IconButton.filledTonal(
onPressed: () async {
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
final client = _getClientForMetadata(context);
await client.markAsUnwatched(metadata.ratingKey);
if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Marked as unwatched'),
SnackBar(
content: Text(t.messages.markedAsUnwatched),
),
);
// Update watch state without full rebuild
@@ -629,13 +820,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(
content: Text(
t.messages.errorLoading(
error: e.toString(),
),
),
),
);
}
}
},
icon: const Icon(Icons.remove_done),
tooltip: 'Mark as unwatched',
tooltip: t.tooltips.markAsUnwatched,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
@@ -650,7 +847,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Summary
if (metadata.summary != null) ...[
Text(
'Overview',
t.discover.overview,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -668,7 +865,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Seasons (for TV shows)
if (isShow) ...[
Text(
'Seasons',
t.discover.seasons,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -686,7 +883,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No seasons found',
t.messages.noSeasonsFound,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.grey),
@@ -709,13 +906,135 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
const SizedBox(height: 24),
],
// Cast
if (metadata.role != null && metadata.role!.isNotEmpty) ...[
Text(
t.discover.cast,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.separated(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: metadata.role!.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 12),
itemBuilder: (context, index) {
final actor = metadata.role![index];
return SizedBox(
width: 120,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: actor.thumb != null
? CachedNetworkImage(
imageUrl: actor.thumb!,
width: 120,
height: 120,
fit: BoxFit.cover,
placeholder: (context, url) =>
Container(
width: 120,
height: 120,
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.person),
),
),
errorWidget:
(
context,
url,
error,
) => Container(
width: 120,
height: 120,
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.person),
),
),
)
: Container(
width: 120,
height: 120,
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.person),
),
),
),
const SizedBox(height: 8),
SizedBox(
height: 84,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
actor.tag,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (actor.role != null) ...[
const SizedBox(height: 2),
Text(
actor.role!,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
);
},
),
),
),
const SizedBox(height: 24),
],
// Additional info
if (metadata.studio != null) ...[
_buildInfoRow('Studio', metadata.studio!),
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow('Rating', metadata.contentRating!),
_buildInfoRow(
t.discover.rating,
formatContentRating(metadata.contentRating!),
),
const SizedBox(height: 12),
],
],
@@ -731,7 +1050,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
return Card(
clipBehavior: Clip.antiAlias,
child: MediaContextMenu(
metadata: season,
item: season,
onRefresh: (ratingKey) {
_watchStateChanged = true;
_updateWatchState();
@@ -762,19 +1081,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (season.thumb != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
width: 80,
height: 120,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
);
}
child: Builder(
builder: (context) {
final client = _getClientForMetadata(context);
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(season.thumb),
width: 80,
@@ -826,7 +1135,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
const SizedBox(height: 4),
if (season.leafCount != null)
Text(
'${season.leafCount} episodes',
t.discover.episodeCount(
count: season.leafCount.toString(),
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey),
),
@@ -854,7 +1165,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
const SizedBox(height: 4),
Text(
'${season.viewedLeafCount}/${season.leafCount} watched',
t.discover.watchedProgress(
watched: season.viewedLeafCount.toString(),
total: season.leafCount.toString(),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
@@ -895,18 +1209,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
);
}
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
String _getPlayButtonLabel(PlexMetadata metadata) {
// For TV shows
if (metadata.type.toLowerCase() == 'show') {
@@ -917,21 +1219,27 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Check if episode has been partially watched (viewOffset > 0)
if (episode.viewOffset != null && episode.viewOffset! > 0) {
return 'Resume S$seasonNum, E$episodeNum';
return t.discover.resumeEpisode(
season: seasonNum.toString(),
episode: episodeNum.toString(),
);
} else {
return 'Play S$seasonNum, E$episodeNum';
return t.discover.playEpisode(
season: seasonNum.toString(),
episode: episodeNum.toString(),
);
}
} else {
// No on deck episode, will play first episode
return 'Play S1, E1';
return t.discover.playEpisode(season: '1', episode: '1');
}
}
// For movies or episodes, check if partially watched
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
return 'Resume';
return t.discover.resume;
}
return 'Play';
return t.discover.play;
}
}
+349
View File
@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_playlist.dart';
import '../models/plex_metadata.dart';
import '../providers/settings_provider.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/grid_size_calculator.dart';
import '../widgets/media_card.dart';
import '../widgets/playlist_item_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
import '../utils/dialogs.dart';
import 'base_media_list_detail_screen.dart';
/// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget {
final PlexPlaylist playlist;
const PlaylistDetailScreen({super.key, required this.playlist});
@override
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState
extends BaseMediaListDetailScreen<PlaylistDetailScreen>
with StandardItemLoader<PlaylistDetailScreen> {
@override
dynamic get mediaItem => widget.playlist;
@override
String get title => widget.playlist.title;
@override
String get emptyMessage => t.playlists.emptyPlaylist;
@override
IconData get emptyIcon => Icons.playlist_play;
@override
Future<List<PlexMetadata>> fetchItems() async {
return await client.getPlaylist(widget.playlist.ratingKey);
}
@override
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
}
/// Get the correct PlexClient for this playlist's server
PlexClient _getClientForPlaylist() {
return context.getClientForServer(widget.playlist.serverId!);
}
Future<void> _deletePlaylist() async {
final confirmed = await showDeleteConfirmation(
context,
title: t.playlists.deleteConfirm,
message: t.playlists.deleteMessage(name: widget.playlist.title),
);
if (confirmed == true && mounted) {
final success = await client.deletePlaylist(widget.playlist.ratingKey);
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
Navigator.pop(context); // Return to playlists screen
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
}
}
}
}
Future<void> _onReorder(int oldIndex, int newIndex) async {
// Adjust newIndex if moving down in the list
if (newIndex > oldIndex) {
newIndex--;
}
// Can't reorder if indices are the same
if (oldIndex == newIndex) return;
final movedItem = items[oldIndex];
// Check if item has playlistItemID (required for reordering)
if (movedItem.playlistItemID == null) {
appLogger.e('Cannot reorder: item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
return;
}
// Determine the "after" item ID
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
// Otherwise, use the playlistItemID of the item before the new position
final int afterPlaylistItemId;
if (newIndex == 0) {
afterPlaylistItemId = 0; // Move to top
} else {
final afterItem = items[newIndex - 1];
if (afterItem.playlistItemID == null) {
appLogger.e('Cannot reorder: after item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
return;
}
afterPlaylistItemId = afterItem.playlistItemID!;
}
appLogger.d(
'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)',
);
// Optimistically update UI
setState(() {
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
// Call API to persist the change
final success = await client.movePlaylistItem(
playlistId: widget.playlist.ratingKey,
playlistItemId: movedItem.playlistItemID!,
afterPlaylistItemId: afterPlaylistItemId,
);
if (!success) {
// Revert on failure
appLogger.e('Failed to reorder playlist item, reverting UI');
if (mounted) {
setState(() {
final item = items.removeAt(newIndex);
items.insert(oldIndex, item);
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
}
}
Future<void> _removeItem(int index) async {
final item = items[index];
// Check if item has playlistItemID (required for removal)
if (item.playlistItemID == null) {
appLogger.e('Cannot remove: item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
}
return;
}
appLogger.d(
'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist',
);
// Optimistically update UI
setState(() {
items.removeAt(index);
});
// Call API to persist the change
final success = await client.removeFromPlaylist(
playlistId: widget.playlist.ratingKey,
playlistItemId: item.playlistItemID.toString(),
);
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
} else {
// Revert on failure
appLogger.e('Failed to remove playlist item, reverting UI');
setState(() {
items.insert(index, item);
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
}
}
}
Future<void> _playFromItem(int index) async {
if (items.isEmpty || index < 0 || index >= items.length) return;
try {
final client = _getClientForPlaylist();
final selectedItem = items[index];
// Create play queue from playlist, starting at the selected item
final playQueue = await client.createPlayQueue(
playlistID: int.parse(widget.playlist.ratingKey),
type: 'video',
key: selectedItem.key,
);
if (playQueue == null ||
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.failedToCreatePlayQueue)),
);
}
return;
}
if (!mounted) return;
// Set play queue in provider
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setClient(client);
await playbackState.setPlaybackFromPlayQueue(
playQueue,
widget.playlist.ratingKey,
);
// Navigate to selected item (should be first in the queue response)
if (mounted) {
await navigateToVideoPlayer(context, metadata: playQueue.items!.first);
}
} catch (e) {
appLogger.e('Failed to play from item', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.failedPlayback(
action: t.discover.play,
error: e.toString(),
),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.playlist.title,
style: const TextStyle(fontSize: 16),
),
if (widget.playlist.smart)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.auto_awesome,
size: 12,
color: Colors.blue[300],
),
const SizedBox(width: 4),
Text(
t.playlists.smartPlaylist,
style: TextStyle(
fontSize: 11,
color: Colors.blue[300],
fontWeight: FontWeight.normal,
),
),
],
),
],
),
pinned: true,
actions: buildAppBarActions(
onDelete: widget.playlist.smart ? null : _deletePlaylist,
deleteTooltip: t.playlists.delete,
showDelete: !widget.playlist.smart,
),
),
...buildStateSlivers(),
if (items.isNotEmpty)
if (widget.playlist.smart)
// Smart playlists: Use grid view (cannot be reordered)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
GridSizeCalculator.getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return MediaCard(item: items[index], onRefresh: updateItem);
}, childCount: items.length),
),
)
else
// Regular playlists: Use reorderable list view
SliverReorderableList(
itemBuilder: (context, index) {
final item = items[index];
return PlaylistItemCard(
key: ValueKey(item.playlistItemID ?? item.ratingKey),
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
canReorder: !widget.playlist.smart,
);
},
itemCount: items.length,
onReorder: _onReorder,
),
],
),
);
}
}
+6 -3
View File
@@ -5,6 +5,7 @@ import '../providers/user_profile_provider.dart';
import '../utils/provider_extensions.dart';
import '../widgets/profile_list_tile.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
class ProfileSwitchScreen extends StatelessWidget {
const ProfileSwitchScreen({super.key});
@@ -16,7 +17,7 @@ class ProfileSwitchScreen extends StatelessWidget {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Switch Profile')),
CustomAppBar(title: Text(t.screens.switchProfile)),
SliverFillRemaining(
child: Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
@@ -41,7 +42,7 @@ class ProfileSwitchScreen extends StatelessWidget {
onPressed: () {
userProvider.refreshCurrentUser();
},
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
@@ -115,7 +116,9 @@ class ProfileSwitchScreen extends StatelessWidget {
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to switch to ${user.displayName}'),
content: Text(
t.errors.failedToSwitchProfile(displayName: user.displayName),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
+91 -48
View File
@@ -1,12 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/provider_extensions.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -15,11 +21,7 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => context.clientSafe;
class _SearchScreenState extends State<SearchScreen> with Refreshable {
final _searchController = TextEditingController();
List<PlexMetadata> _searchResults = [];
bool _isSearching = false;
@@ -83,13 +85,18 @@ class _SearchScreenState extends State<SearchScreen>
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
final results = await client.search(query);
// Search across all connected servers
final results = await multiServerProvider.aggregationService
.searchAcrossServers(query);
if (mounted) {
setState(() {
_searchResults = results;
@@ -102,9 +109,9 @@ class _SearchScreenState extends State<SearchScreen>
setState(() {
_isSearching = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.errors.searchFailed(error: e))),
);
}
}
}
@@ -117,13 +124,25 @@ class _SearchScreenState extends State<SearchScreen>
}
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere(
(item) => item.ratingKey == ratingKey,
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d(
'SearchScreen.fullRefresh() called - clearing search and reloading',
);
if (index != -1) {
_searchResults[index] = updatedMetadata;
// Clear search results and search text for new profile
_searchController.clear();
setState(() {
_searchResults.clear();
_isSearching = false;
_hasSearched = false;
_lastSearchedQuery = '';
});
}
void updateItem(String ratingKey) {
// Trigger a refresh of the search to get updated metadata
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
@@ -133,13 +152,13 @@ class _SearchScreenState extends State<SearchScreen>
body: SafeArea(
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(title: const Text('Search'), floating: true),
DesktopSliverAppBar(title: Text(t.screens.search), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: _searchController,
hintText: 'Search movies, shows, music...',
hintText: t.search.hint,
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
@@ -168,14 +187,14 @@ class _SearchScreenState extends State<SearchScreen>
Icon(Icons.search, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Search your media',
t.search.searchYourMedia,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Enter a title, actor, or keyword',
t.search.enterTitleActorOrKeyword,
style: TextStyle(color: Colors.grey.shade600),
),
],
@@ -195,14 +214,14 @@ class _SearchScreenState extends State<SearchScreen>
),
const SizedBox(height: 16),
Text(
'No results found',
t.messages.noResultsFound,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Try a different search term',
t.search.tryDifferentTerm,
style: TextStyle(color: Colors.grey.shade600),
),
],
@@ -210,24 +229,48 @@ class _SearchScreenState extends State<SearchScreen>
),
)
else
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}, childCount: _searchResults.length),
),
} else {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context,
settingsProvider.libraryDensity,
32,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}
},
),
],
),
+26 -41
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/duration_formatter.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_context_menu.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
class SeasonDetailScreen extends StatefulWidget {
final PlexMetadata season;
@@ -22,17 +22,28 @@ class SeasonDetailScreen extends StatefulWidget {
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable {
late final PlexClient _client;
@override
PlexClient get client => context.clientSafe;
PlexClient get client => _client;
List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false;
bool _watchStateChanged = false;
/// Get the correct PlexClient for this season's server
PlexClient _getClientForSeason(BuildContext context) {
return context.getClientForServer(widget.season.serverId!);
}
@override
void initState() {
super.initState();
_loadEpisodes();
// Initialize the client once in initState
WidgetsBinding.instance.addPostFrameCallback((_) {
_client = _getClientForSeason(context);
_loadEpisodes();
});
}
Future<void> _loadEpisodes() async {
@@ -41,13 +52,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
// Episodes are automatically tagged with server info by PlexClient
final episodes = await _client.getChildren(widget.season.ratingKey);
final episodes = await client.getChildren(widget.season.ratingKey);
setState(() {
_episodes = episodes;
_isLoadingEpisodes = false;
@@ -100,7 +107,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
),
const SizedBox(height: 16),
Text(
'No episodes found',
t.messages.noEpisodesFoundGeneral,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: tokens(context).textMuted,
),
@@ -131,7 +138,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
: 0.0;
return MediaContextMenu(
metadata: episode,
item: episode,
onRefresh: updateItem,
onTap: () async {
await navigateToVideoPlayer(context, metadata: episode);
@@ -163,21 +170,10 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
child: AspectRatio(
aspectRatio: 16 / 9,
child: episode.thumb != null
? Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.movie, size: 40),
),
);
}
? Builder(
builder: (context) {
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(
imageUrl: _client.getThumbnailUrl(
episode.thumb,
),
filterQuality: FilterQuality.medium,
@@ -329,7 +325,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
children: [
if (episode.duration != null)
Text(
_formatDuration(episode.duration!),
formatDurationTimestamp(
Duration(milliseconds: episode.duration!),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
@@ -349,7 +347,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
),
),
Text(
'Watched',
'${t.discover.watched}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
@@ -368,17 +366,4 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
),
);
}
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
}
-242
View File
@@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../services/server_connection_service.dart';
import '../widgets/server_list_tile.dart';
import '../widgets/desktop_app_bar.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import 'main_screen.dart';
class ServerSelectionScreen extends StatefulWidget {
final PlexAuthService authService;
final String plexToken;
const ServerSelectionScreen({
super.key,
required this.authService,
required this.plexToken,
});
@override
State<ServerSelectionScreen> createState() => _ServerSelectionScreenState();
}
class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
List<PlexServer>? _servers;
bool _isLoading = true;
String? _errorMessage;
String? _currentServerUrl;
@override
void initState() {
super.initState();
_loadCurrentServerUrl();
_loadServers();
}
Future<void> _loadCurrentServerUrl() async {
try {
final storage = await StorageService.getInstance();
setState(() {
_currentServerUrl = storage.getServerUrl();
});
} catch (e) {
appLogger.w('Failed to load current server URL', error: e);
}
}
Future<void> _loadServers() async {
try {
final servers = await widget.authService.fetchServers(widget.plexToken);
setState(() {
_servers = servers;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load servers: $e';
_isLoading = false;
});
}
}
Future<void> _selectServer(PlexServer server) async {
// Show loading dialog
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Connecting to server...'),
],
),
),
);
}
try {
// Get client identifier and storage
final storage = await StorageService.getInstance();
final clientId =
storage.getClientIdentifier() ?? widget.authService.clientIdentifier;
// Get current profile context to maintain profile across server switch
final currentUserUUID = storage.getCurrentUserUUID();
PlexServer serverWithCorrectToken = server;
// If we have a current profile, get a profile-specific token for this server
if (currentUserUUID != null) {
try {
// Switch to the current profile on the new server to get the correct token
final switchResponse = await widget.authService.switchToUser(
currentUserUUID,
widget.plexToken,
);
// Get servers with the profile's Plex.tv token to get profile-specific server tokens
final servers = await widget.authService.fetchServers(
switchResponse.authToken,
);
// Find the matching server with the profile-specific token
final matchingServer = servers.firstWhere(
(s) =>
s.name == server.name ||
s.clientIdentifier == server.clientIdentifier,
orElse: () => server, // Fallback to original server
);
serverWithCorrectToken = matchingServer;
appLogger.d(
'Got profile-specific token for server ${server.name} and user UUID $currentUserUUID',
);
} catch (e) {
appLogger.w(
'Failed to get profile-specific token, using default server token',
error: e,
);
// Continue with original server token as fallback
}
}
// Connect using the server with the correct token
final result = await ServerConnectionService.connectToServer(
serverWithCorrectToken,
clientIdentifier: clientId,
plexToken: widget.plexToken,
);
// Close loading dialog
if (mounted) {
Navigator.pop(context);
}
// Handle result
if (result.isSuccess) {
// Set client in provider before navigation (same pattern as auto-login)
if (mounted) {
context.plexClient.setClient(result.client!);
// Navigate to main app and clear navigation stack
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: result.client!),
),
(route) => false, // Remove all routes
);
}
} else {
// Show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error ?? 'Connection failed')),
);
}
}
} catch (e) {
// Close loading dialog on error
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to connect to server: $e')),
);
}
appLogger.e('Server selection failed', error: e);
}
}
bool _isCurrentServer(PlexServer server) {
if (_currentServerUrl == null) return false;
// Check all server connections to see if any match the current server URL
for (final connection in server.connections) {
if (connection.uri == _currentServerUrl) {
return true;
}
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Select Server')),
SliverFillRemaining(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadServers,
child: const Text('Retry'),
),
],
),
)
: _servers == null || _servers!.isEmpty
? const Center(child: Text('No servers found'))
: ListView.builder(
itemCount: _servers!.length,
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final server = _servers![index];
final isCurrentServer = _isCurrentServer(server);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Card(
child: ServerListTile(
server: server,
isCurrentServer: isCurrentServer,
onTap: () => _selectServer(server),
),
),
);
},
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import '../i18n/strings.g.dart';
import '../services/settings_service.dart';
import '../widgets/desktop_app_bar.dart';
class SubtitleStylingScreen extends StatefulWidget {
const SubtitleStylingScreen({super.key});
@override
State<SubtitleStylingScreen> createState() => _SubtitleStylingScreenState();
}
// Composable widget for slider sections
class _StylingSliderSection extends StatelessWidget {
final String label;
final int value;
final double min;
final double max;
final int divisions;
final ValueChanged<double> onChanged;
final ValueChanged<double>? onChangeEnd;
final String Function(int)? valueFormatter;
const _StylingSliderSection({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.onChanged,
this.onChangeEnd,
this.valueFormatter,
});
@override
Widget build(BuildContext context) {
final formattedValue = valueFormatter?.call(value) ?? value.toString();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label), Text(formattedValue)],
),
const SizedBox(height: 8),
Row(
children: [
Text(
valueFormatter?.call(min.toInt()) ?? min.toInt().toString(),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: value.toDouble(),
min: min,
max: max,
divisions: divisions,
label: formattedValue,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
),
Text(
valueFormatter?.call(max.toInt()) ?? max.toInt().toString(),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
),
);
}
}
// Composable widget for color picker tiles
class _ColorSettingTile extends StatelessWidget {
final String label;
final String currentColor;
final VoidCallback onTap;
final Color Function(String) hexToColor;
const _ColorSettingTile({
required this.label,
required this.currentColor,
required this.onTap,
required this.hexToColor,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: hexToColor(currentColor),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
),
title: Text(label),
subtitle: Text(currentColor),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
late SettingsService _settingsService;
bool _isLoading = true;
int _fontSize = 55;
String _textColor = '#FFFFFF';
int _borderSize = 3;
String _borderColor = '#000000';
String _backgroundColor = '#000000';
int _backgroundOpacity = 0;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
_settingsService = await SettingsService.getInstance();
setState(() {
_fontSize = _settingsService.getSubtitleFontSize();
_textColor = _settingsService.getSubtitleTextColor();
_borderSize = _settingsService.getSubtitleBorderSize();
_borderColor = _settingsService.getSubtitleBorderColor();
_backgroundColor = _settingsService.getSubtitleBackgroundColor();
_backgroundOpacity = _settingsService.getSubtitleBackgroundOpacity();
_isLoading = false;
});
}
Color _hexToColor(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
String _colorToHex(Color color) {
return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'
.toUpperCase();
}
Future<void> _showColorPicker(
String title,
String currentColor,
Function(String) onColorSelected,
) async {
Color initialColor = _hexToColor(currentColor);
final Color selectedColor = await showColorPickerDialog(
context,
initialColor,
title: Text(title),
barrierColor: Colors.black54,
width: 40,
height: 40,
spacing: 0,
runSpacing: 0,
borderRadius: 4,
wheelDiameter: 165,
enableOpacity: false,
showColorCode: true,
colorCodeHasColor: true,
pickersEnabled: const <ColorPickerType, bool>{
ColorPickerType.both: false,
ColorPickerType.primary: true,
ColorPickerType.accent: false,
ColorPickerType.wheel: true,
ColorPickerType.custom: false,
},
actionButtons: const ColorPickerActionButtons(
okButton: true,
closeButton: true,
dialogActionButtons: false,
),
);
final hexColor = _colorToHex(selectedColor);
onColorSelected(hexColor);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildStylingCard(),
const SizedBox(height: 24),
]),
),
),
],
),
);
}
Widget _buildStylingCard() {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
t.subtitlingStyling.stylingOptions,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
// Font Size Slider
_StylingSliderSection(
label: t.subtitlingStyling.fontSize,
value: _fontSize,
min: 30,
max: 80,
divisions: 50,
onChanged: (value) {
setState(() {
_fontSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleFontSize(_fontSize);
},
),
const Divider(),
// Text Color
_ColorSettingTile(
label: t.subtitlingStyling.textColor,
currentColor: _textColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (
color,
) {
setState(() {
_textColor = color;
});
_settingsService.setSubtitleTextColor(color);
});
},
),
const Divider(),
// Border Size Slider
_StylingSliderSection(
label: t.subtitlingStyling.borderSize,
value: _borderSize,
min: 0,
max: 5,
divisions: 5,
onChanged: (value) {
setState(() {
_borderSize = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBorderSize(_borderSize);
},
),
const Divider(),
// Border Color
_ColorSettingTile(
label: t.subtitlingStyling.borderColor,
currentColor: _borderColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (
color,
) {
setState(() {
_borderColor = color;
});
_settingsService.setSubtitleBorderColor(color);
});
},
),
const Divider(),
// Background Opacity Slider
_StylingSliderSection(
label: t.subtitlingStyling.backgroundOpacity,
value: _backgroundOpacity,
min: 0,
max: 100,
divisions: 20,
valueFormatter: (value) => '$value%',
onChanged: (value) {
setState(() {
_backgroundOpacity = value.toInt();
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBackgroundOpacity(_backgroundOpacity);
},
),
const Divider(),
// Background Color
_ColorSettingTile(
label: t.subtitlingStyling.backgroundColor,
currentColor: _backgroundColor,
hexToColor: _hexToColor,
onTap: () {
_showColorPicker(
t.subtitlingStyling.backgroundColor,
_backgroundColor,
(color) {
setState(() {
_backgroundColor = color;
});
_settingsService.setSubtitleBackgroundColor(color);
},
);
},
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
import 'dart:async';
import '../client/plex_client.dart';
import '../models/plex_hub.dart';
import '../models/plex_library.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
import 'multi_server_manager.dart';
import 'plex_auth_service.dart';
/// Service for aggregating data from multiple Plex servers
class DataAggregationService {
final MultiServerManager _serverManager;
DataAggregationService(this._serverManager);
/// Fetch libraries from all online servers
/// Libraries are automatically tagged with server info by PlexClient
Future<List<PlexLibrary>> getLibrariesFromAllServers() async {
return _perServer<PlexLibrary>(
operationName: 'fetching libraries',
operation: (serverId, client, server) async {
return await client.getLibraries();
},
);
}
/// Fetch "On Deck" (Continue Watching) from all servers and merge by recency
/// Items are automatically tagged with server info by PlexClient
Future<List<PlexMetadata>> getOnDeckFromAllServers({int? limit}) async {
final allOnDeck = await _perServer<PlexMetadata>(
operationName: 'fetching on deck',
operation: (serverId, client, server) async {
return await client.getOnDeck();
},
);
// Sort by most recently viewed
// Use lastViewedAt (when item was last viewed), falling back to updatedAt/addedAt if not available
allOnDeck.sort((a, b) {
final aTime = a.lastViewedAt ?? a.updatedAt ?? a.addedAt ?? 0;
final bTime = b.lastViewedAt ?? b.updatedAt ?? b.addedAt ?? 0;
return bTime.compareTo(aTime); // Descending (most recent first)
});
// Apply limit if specified
final result = limit != null && limit < allOnDeck.length
? allOnDeck.sublist(0, limit)
: allOnDeck;
appLogger.i('Fetched ${result.length} on deck items from all servers');
return result;
}
/// Fetch recommendation hubs from all servers
Future<List<PlexHub>> getHubsFromAllServers({int? limit}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for fetching hubs');
return [];
}
appLogger.d('Fetching hubs from ${clients.length} servers');
final allHubs = <PlexHub>[];
// Fetch from all servers in parallel
final hubFutures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
try {
// Get libraries for this server
final libraries = await client.getLibraries();
// Filter to only visible movie/show libraries
final visibleLibraries = libraries.where((library) {
if (library.type != 'movie' && library.type != 'show') {
return false;
}
if (library.hidden != null && library.hidden != 0) {
return false;
}
return true;
}).toList();
// Fetch hubs from all libraries in parallel
final libraryHubFutures = visibleLibraries.map((library) async {
try {
// Hubs are now tagged with server info at the source
return await client.getLibraryHubs(library.key);
} catch (e) {
appLogger.w(
'Failed to fetch hubs for library ${library.title}: $e',
);
return <PlexHub>[];
}
});
final libraryHubResults = await Future.wait(libraryHubFutures);
// Flatten all library hubs
final serverHubs = <PlexHub>[];
for (final hubs in libraryHubResults) {
serverHubs.addAll(hubs);
}
return serverHubs;
} catch (e, stackTrace) {
appLogger.e(
'Failed to fetch hubs from server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return <PlexHub>[];
}
});
final results = await Future.wait(hubFutures);
// Flatten results
for (final hubs in results) {
allHubs.addAll(hubs);
}
// Apply limit if specified
final result = limit != null && limit < allHubs.length
? allHubs.sublist(0, limit)
: allHubs;
appLogger.i('Fetched ${result.length} hubs from all servers');
return result;
}
/// Search across all online servers
/// Results are automatically tagged with server info by PlexClient
Future<List<PlexMetadata>> searchAcrossServers(
String query, {
int? limit,
}) async {
if (query.trim().isEmpty) {
return [];
}
final allResults = await _perServer<PlexMetadata>(
operationName: 'searching for "$query"',
operation: (serverId, client, server) async {
return await client.search(query);
},
);
// Apply limit if specified
final result = limit != null && limit < allResults.length
? allResults.sublist(0, limit)
: allResults;
appLogger.i('Found ${result.length} search results across all servers');
return result;
}
/// Get libraries for a specific server
Future<List<PlexLibrary>> getLibrariesForServer(String serverId) async {
final client = _serverManager.getClient(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId');
return [];
}
try {
// Libraries are automatically tagged with server info by PlexClient
return await client.getLibraries();
} catch (e, stackTrace) {
appLogger.e(
'Failed to fetch libraries for server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return [];
}
}
/// Group libraries by server
Map<String, List<PlexLibrary>> groupLibrariesByServer(
List<PlexLibrary> libraries,
) {
final grouped = <String, List<PlexLibrary>>{};
for (final library in libraries) {
final serverId = library.serverId;
if (serverId != null) {
grouped.putIfAbsent(serverId, () => []).add(library);
}
}
return grouped;
}
// Private helper methods
/// Higher-order helper for per-server fan-out operations
///
/// Iterates over all online clients, executes the operation for each server,
/// handles errors, updates server status, and aggregates results.
///
/// Type parameter `T` is the item type returned by the operation
/// [operationName] is used for logging (e.g., "fetching libraries")
/// [operation] is the async function to run per server, returning `List<T>`
Future<List<T>> _perServer<T>({
required String operationName,
required Future<List<T>> Function(
String serverId,
PlexClient client,
PlexServer? server,
)
operation,
}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for $operationName');
return [];
}
appLogger.d('$operationName from ${clients.length} servers');
final allResults = <T>[];
// Execute operation on all servers in parallel
final Iterable<Future<List<T>>> futures = clients.entries.map((
entry,
) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
try {
return await operation(serverId, client, server);
} catch (e, stackTrace) {
appLogger.e(
'Failed $operationName from server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
return <T>[];
}
});
final List<List<T>> results = await Future.wait<List<T>>(futures);
// Flatten results
for (final items in results) {
allResults.addAll(items);
}
return allResults;
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
/// Result of loading adjacent episodes
class AdjacentEpisodes {
final PlexMetadata? next;
final PlexMetadata? previous;
AdjacentEpisodes({this.next, this.previous});
bool get hasNext => next != null;
bool get hasPrevious => previous != null;
}
/// Manages episode navigation for TV show playback.
///
/// Handles:
/// - Loading next/previous episodes from play queues
/// - Navigating between episodes while preserving track selections
/// - Supporting both sequential and shuffle playback modes
///
/// All episode navigation uses Plex play queues for consistent behavior.
class EpisodeNavigationService {
/// Load the next and previous episodes for the current episode
///
/// Returns null for episodes if:
/// - Not applicable (e.g., movie content)
/// - Next episode doesn't exist (end of season/series)
/// - Previous episode doesn't exist (first episode)
Future<AdjacentEpisodes> loadAdjacentEpisodes({
required BuildContext context,
required PlexClient client,
required PlexMetadata metadata,
}) async {
try {
final playbackState = context.read<PlaybackStateProvider>();
// All episode navigation now uses play queues (sequential, shuffle, playlists)
// If no queue is active, navigation is not available
if (!playbackState.isQueueActive) {
return AdjacentEpisodes();
}
// Use the play queue for next/previous navigation
final next = await playbackState.getNextEpisode(
metadata.ratingKey,
loopQueue: false,
);
final previous = await playbackState.getPreviousEpisode(
metadata.ratingKey,
);
final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential';
appLogger.d(
'$mode mode - Next: ${next?.title}, Previous: ${previous?.title}',
);
return AdjacentEpisodes(next: next, previous: previous);
} catch (e) {
// Non-critical: Failed to load next/previous episode metadata
appLogger.d('Could not load adjacent episodes', error: e);
return AdjacentEpisodes();
}
}
/// Navigate to the next or previous episode
///
/// Preserves the current audio track, subtitle track, and playback rate
/// selections when transitioning between episodes.
Future<void> navigateToEpisode({
required BuildContext context,
required PlexMetadata episode,
required Player? player,
bool usePushReplacement = true,
}) async {
if (!context.mounted) return;
// Capture current player state before navigation
AudioTrack? currentAudioTrack;
SubtitleTrack? currentSubtitleTrack;
double? currentPlaybackRate;
if (player != null) {
currentAudioTrack = player.state.track.audio;
currentSubtitleTrack = player.state.track.subtitle;
currentPlaybackRate = player.state.rate;
appLogger.d(
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack.id}, Subtitle: ${currentSubtitleTrack.id}, Rate: ${currentPlaybackRate}x',
);
}
// Navigate to the new episode
if (context.mounted) {
navigateToVideoPlayer(
context,
metadata: episode,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredPlaybackRate: currentPlaybackRate,
usePushReplacement: usePushReplacement,
);
}
}
}
+20 -13
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'settings_service.dart';
import '../utils/player_utils.dart';
class KeyboardShortcutsService {
static KeyboardShortcutsService? _instance;
@@ -10,6 +11,8 @@ class KeyboardShortcutsService {
Map<String, String> _shortcuts =
{}; // Legacy string shortcuts for backward compatibility
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
int _seekTimeSmall = 10; // Default, loaded from settings
int _seekTimeLarge = 30; // Default, loaded from settings
KeyboardShortcutsService._();
@@ -28,6 +31,8 @@ class KeyboardShortcutsService {
_shortcuts = _settingsService
.getKeyboardShortcuts(); // Keep for legacy compatibility
_hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
}
Map<String, String> get shortcuts => Map.from(_shortcuts);
@@ -61,6 +66,8 @@ class KeyboardShortcutsService {
Future<void> refreshFromStorage() async {
_hotkeys = await _settingsService.getKeyboardHotkeys();
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
}
Future<void> resetToDefaults() async {
@@ -241,32 +248,32 @@ class KeyboardShortcutsService {
case 'volume_up':
final newVolume = (player.state.volume + 10).clamp(0.0, 100.0);
player.setVolume(newVolume);
_settingsService.setVolume(newVolume);
break;
case 'volume_down':
final newVolume = (player.state.volume - 10).clamp(0.0, 100.0);
player.setVolume(newVolume);
_settingsService.setVolume(newVolume);
break;
case 'seek_forward':
final newPosition = player.state.position + const Duration(seconds: 10);
player.seek(newPosition);
seekWithClamping(player, Duration(seconds: _seekTimeSmall));
break;
case 'seek_backward':
final newPosition = player.state.position - const Duration(seconds: 10);
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
seekWithClamping(player, Duration(seconds: -_seekTimeSmall));
break;
case 'seek_forward_large':
final newPosition = player.state.position + const Duration(seconds: 30);
player.seek(newPosition);
seekWithClamping(player, Duration(seconds: _seekTimeLarge));
break;
case 'seek_backward_large':
final newPosition = player.state.position - const Duration(seconds: 30);
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
seekWithClamping(player, Duration(seconds: -_seekTimeLarge));
break;
case 'fullscreen_toggle':
onToggleFullscreen?.call();
break;
case 'mute_toggle':
player.setVolume(player.state.volume > 0 ? 0.0 : 100.0);
final newVolume = player.state.volume > 0 ? 0.0 : 100.0;
player.setVolume(newVolume);
_settingsService.setVolume(newVolume);
break;
case 'subtitle_toggle':
onToggleSubtitles?.call();
@@ -307,13 +314,13 @@ class KeyboardShortcutsService {
case 'volume_down':
return 'Volume Down';
case 'seek_forward':
return 'Seek Forward';
return 'Seek Forward (${_seekTimeSmall}s)';
case 'seek_backward':
return 'Seek Backward';
return 'Seek Backward (${_seekTimeSmall}s)';
case 'seek_forward_large':
return 'Seek Forward (Large)';
return 'Seek Forward (${_seekTimeLarge}s)';
case 'seek_backward_large':
return 'Seek Backward (Large)';
return 'Seek Backward (${_seekTimeLarge}s)';
case 'fullscreen_toggle':
return 'Toggle Fullscreen';
case 'mute_toggle':
+193
View File
@@ -0,0 +1,193 @@
import 'dart:async';
import 'package:os_media_controls/os_media_controls.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback.
///
/// Handles:
/// - Metadata updates (title, artwork, etc.)
/// - Playback state updates (playing/paused, position, speed)
/// - Control event streaming (play, pause, next, previous, seek)
/// - Position update throttling to prevent excessive API calls
class MediaControlsManager {
/// Stream of control events from OS media controls
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
/// Last time position was updated (for throttling)
DateTime? _lastPositionUpdate;
/// Throttle interval for position updates (default: 1 second)
final Duration throttleInterval;
MediaControlsManager({this.throttleInterval = const Duration(seconds: 1)});
/// Update media metadata displayed in OS media controls
///
/// This includes title, artist, artwork, and duration.
Future<void> updateMetadata({
required PlexMetadata metadata,
PlexClient? client,
Duration? duration,
}) async {
try {
// Build artwork URL if client is available
String? artworkUrl;
if (client != null && metadata.thumb != null) {
try {
artworkUrl = client.getThumbnailUrl(metadata.thumb!);
appLogger.d('Artwork URL for media controls: $artworkUrl');
} catch (e) {
appLogger.w('Failed to build artwork URL', error: e);
}
}
// Update OS media controls
await OsMediaControls.setMetadata(
MediaMetadata(
title: metadata.title,
artist: _buildArtist(metadata),
artworkUrl: artworkUrl,
duration: duration,
),
);
appLogger.d('Updated media controls metadata: ${metadata.title}');
} catch (e) {
appLogger.w('Failed to update media controls metadata', error: e);
}
}
/// Update playback state in OS media controls
///
/// Updates the current playing state, position, and playback speed.
/// Position updates are throttled to avoid excessive API calls.
Future<void> updatePlaybackState({
required bool isPlaying,
required Duration position,
required double speed,
bool force = false,
}) async {
try {
// Throttle position updates unless forced
if (!force) {
final now = DateTime.now();
if (_lastPositionUpdate != null) {
final timeSinceLastUpdate = now.difference(_lastPositionUpdate!);
if (timeSinceLastUpdate < throttleInterval) {
return; // Skip this update
}
}
_lastPositionUpdate = now;
}
// Only update if playing (avoid excessive updates when paused)
if (isPlaying) {
await OsMediaControls.setPlaybackState(
MediaPlaybackState(
state: PlaybackState.playing,
position: position,
speed: speed,
),
);
} else {
await OsMediaControls.setPlaybackState(
MediaPlaybackState(
state: PlaybackState.paused,
position: position,
speed: speed,
),
);
}
} catch (e) {
appLogger.w('Failed to update media controls playback state', error: e);
}
}
/// Enable or disable next/previous track controls
///
/// This should be called based on content type and playback mode.
/// For example:
/// - Episodes: Enable both if there are adjacent episodes
/// - Playlist items: Enable based on playlist position
/// - Movies: Usually disabled
Future<void> setControlsEnabled({
bool canGoNext = false,
bool canGoPrevious = false,
}) async {
try {
final controls = <MediaControl>[];
if (canGoPrevious) controls.add(MediaControl.previous);
if (canGoNext) controls.add(MediaControl.next);
if (controls.isNotEmpty) {
await OsMediaControls.enableControls(controls);
appLogger.d(
'Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext',
);
} else {
await OsMediaControls.disableControls([
MediaControl.previous,
MediaControl.next,
]);
appLogger.d('Media controls disabled');
}
} catch (e) {
appLogger.w('Failed to set media controls enabled state', error: e);
}
}
/// Clear all media controls
///
/// Should be called when playback stops or screen is disposed.
Future<void> clear() async {
try {
await OsMediaControls.clear();
_lastPositionUpdate = null;
appLogger.d('Media controls cleared');
} catch (e) {
appLogger.w('Failed to clear media controls', error: e);
}
}
/// Dispose resources
void dispose() {
_lastPositionUpdate = null;
}
/// Build artist string from metadata
///
/// For episodes: "Show Name - Season X Episode Y"
/// For movies: Director or studio
/// For other content: Fallback to year or empty
String _buildArtist(PlexMetadata metadata) {
if (metadata.type.toLowerCase() == 'episode') {
final parts = <String>[];
// Add show name
if (metadata.grandparentTitle != null) {
parts.add(metadata.grandparentTitle!);
}
// Add season/episode info
if (metadata.parentIndex != null && metadata.index != null) {
parts.add('S${metadata.parentIndex} E${metadata.index}');
} else if (metadata.parentTitle != null) {
parts.add(metadata.parentTitle!);
}
return parts.join('');
} else if (metadata.type.toLowerCase() == 'movie') {
// For movies, use director or studio
// Note: These fields may need to be added to PlexMetadata model
if (metadata.year != null) {
return metadata.year.toString();
}
}
return '';
}
}
+459
View File
@@ -0,0 +1,459 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../client/plex_client.dart';
import '../config/plex_config.dart';
import '../utils/app_logger.dart';
import 'plex_auth_service.dart';
import 'storage_service.dart';
/// Manages multiple Plex server connections simultaneously
class MultiServerManager {
/// Map of serverId (clientIdentifier) to PlexClient instances
final Map<String, PlexClient> _clients = {};
/// Map of serverId to server info
final Map<String, PlexServer> _servers = {};
/// Map of serverId to online status
final Map<String, bool> _serverStatus = {};
/// Stream controller for server status changes
final _statusController = StreamController<Map<String, bool>>.broadcast();
/// Stream of server status changes
Stream<Map<String, bool>> get statusStream => _statusController.stream;
/// Connectivity subscription for network monitoring
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
/// Map of serverId to active optimization futures
final Map<String, Future<void>> _activeOptimizations = {};
/// Get all registered server IDs
List<String> get serverIds => _servers.keys.toList();
/// Get all online server IDs
List<String> get onlineServerIds =>
_serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
/// Get all offline server IDs
List<String> get offlineServerIds =>
_serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
/// Get client for specific server
PlexClient? getClient(String serverId) => _clients[serverId];
/// Get server info for specific server
PlexServer? getServer(String serverId) => _servers[serverId];
/// Get all online clients
Map<String, PlexClient> get onlineClients {
final result = <String, PlexClient>{};
for (final serverId in onlineServerIds) {
final client = _clients[serverId];
if (client != null) {
result[serverId] = client;
}
}
return result;
}
/// Get all servers
Map<String, PlexServer> get servers => Map.unmodifiable(_servers);
/// Check if a server is online
bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false;
/// Connect to all available servers in parallel
/// Returns the number of successfully connected servers
Future<int> connectToAllServers(
List<PlexServer> servers, {
String? clientIdentifier,
Duration timeout = const Duration(seconds: 10),
Function(String serverId, PlexClient client)? onServerConnected,
Function(String serverId, Object error)? onServerFailed,
}) async {
if (servers.isEmpty) {
appLogger.w('No servers to connect to');
return 0;
}
appLogger.i('Connecting to ${servers.length} servers...');
// Use provided client ID or generate a unique one for this app instance
final effectiveClientId =
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
// Create connection tasks for all servers
final connectionFutures = servers.map((server) async {
final serverId = server.clientIdentifier;
try {
appLogger.d('Attempting connection to server: ${server.name}');
// Find best working connection for this server
PlexConnection? workingConnection;
await for (final connection in server.findBestWorkingConnection()) {
workingConnection = connection;
// Use first working connection (could wait for optimal, but that's slower)
break;
}
if (workingConnection == null) {
throw Exception('No working connection found');
}
final baseUrl = workingConnection.uri;
appLogger.d('Connected to ${server.name} at $baseUrl');
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Create PlexClient with the working connection and failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: cachedEndpoint ?? baseUrl,
);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
clientIdentifier: effectiveClientId,
);
final client = PlexClient(
config,
serverId: serverId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerEndpoint(serverId, newUrl);
appLogger.i(
'Updated endpoint for ${server.name} after failover: $newUrl',
);
},
);
// Save the initial endpoint
await storage.saveServerEndpoint(serverId, baseUrl);
// Store the client and server info
_clients[serverId] = client;
_servers[serverId] = server;
_serverStatus[serverId] = true;
onServerConnected?.call(serverId, client);
appLogger.i('Successfully connected to ${server.name}');
return serverId;
} catch (e, stackTrace) {
appLogger.e(
'Failed to connect to ${server.name}',
error: e,
stackTrace: stackTrace,
);
// Mark as offline
_servers[serverId] = server;
_serverStatus[serverId] = false;
onServerFailed?.call(serverId, e);
return null;
}
});
// Wait for all connections with timeout
final results = await Future.wait(
connectionFutures.map(
(f) => f.timeout(
timeout,
onTimeout: () {
appLogger.w('Server connection timed out');
return null;
},
),
),
);
// Count successful connections
final successCount = results.where((id) => id != null).length;
// Notify listeners of status change
_statusController.add(Map.from(_serverStatus));
appLogger.i(
'Connected to $successCount/${servers.length} servers successfully',
);
// Start network monitoring if we have any connected servers
if (successCount > 0) {
startNetworkMonitoring();
}
return successCount;
}
/// Add a single server connection
Future<bool> addServer(PlexServer server, {String? clientIdentifier}) async {
final serverId = server.clientIdentifier;
final effectiveClientId =
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
try {
appLogger.d('Adding server: ${server.name}');
// Find best working connection
PlexConnection? workingConnection;
await for (final connection in server.findBestWorkingConnection()) {
workingConnection = connection;
break;
}
if (workingConnection == null) {
throw Exception('No working connection found');
}
final baseUrl = workingConnection.uri;
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Create PlexClient with failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: cachedEndpoint ?? baseUrl,
);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
clientIdentifier: effectiveClientId,
);
final client = PlexClient(
config,
serverId: serverId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerEndpoint(serverId, newUrl);
appLogger.i(
'Updated endpoint for ${server.name} after failover: $newUrl',
);
},
);
// Save the initial endpoint
await storage.saveServerEndpoint(serverId, baseUrl);
// Store
_clients[serverId] = client;
_servers[serverId] = server;
_serverStatus[serverId] = true;
// Notify
_statusController.add(Map.from(_serverStatus));
appLogger.i('Successfully added server: ${server.name}');
return true;
} catch (e, stackTrace) {
appLogger.e(
'Failed to add server ${server.name}',
error: e,
stackTrace: stackTrace,
);
_servers[serverId] = server;
_serverStatus[serverId] = false;
_statusController.add(Map.from(_serverStatus));
return false;
}
}
/// Remove a server connection
void removeServer(String serverId) {
_clients.remove(serverId);
_servers.remove(serverId);
_serverStatus.remove(serverId);
_statusController.add(Map.from(_serverStatus));
appLogger.i('Removed server: $serverId');
}
/// Update server status (used for health monitoring)
void updateServerStatus(String serverId, bool isOnline) {
if (_serverStatus[serverId] != isOnline) {
_serverStatus[serverId] = isOnline;
_statusController.add(Map.from(_serverStatus));
appLogger.d('Server $serverId status changed to: $isOnline');
}
}
/// Test connection health for all servers
Future<void> checkServerHealth() async {
appLogger.d('Checking health for ${_clients.length} servers');
final healthChecks = _clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
try {
// Simple ping by fetching server identity
await client.getServerIdentity();
updateServerStatus(serverId, true);
} catch (e) {
appLogger.w('Server $serverId health check failed: $e');
updateServerStatus(serverId, false);
}
});
await Future.wait(healthChecks);
}
/// Start monitoring network connectivity for all servers
void startNetworkMonitoring() {
if (_connectivitySubscription != null) {
appLogger.d('Network monitoring already active');
return;
}
appLogger.i('Starting network monitoring for all servers');
final connectivity = Connectivity();
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
(results) {
final status = results.isNotEmpty
? results.first
: ConnectivityResult.none;
if (status == ConnectivityResult.none) {
appLogger.w(
'Connectivity lost, pausing optimization until network returns',
);
return;
}
appLogger.d(
'Connectivity change detected, re-optimizing all servers',
error: {
'status': status.name,
'interfaces': results.map((r) => r.name).toList(),
'serverCount': _servers.length,
},
);
// Re-optimize all servers
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
},
onError: (error, stackTrace) {
appLogger.w(
'Connectivity listener error',
error: error,
stackTrace: stackTrace,
);
},
);
}
/// Stop monitoring network connectivity
void stopNetworkMonitoring() {
_connectivitySubscription?.cancel();
_connectivitySubscription = null;
appLogger.i('Stopped network monitoring');
}
/// Re-optimize all connected servers
void _reoptimizeAllServers({required String reason}) {
for (final entry in _servers.entries) {
final serverId = entry.key;
final server = entry.value;
// Skip if server is offline
if (!isServerOnline(serverId)) {
continue;
}
// Skip if optimization already running for this server
if (_activeOptimizations.containsKey(serverId)) {
appLogger.d(
'Optimization already running for ${server.name}, skipping',
error: {'reason': reason},
);
continue;
}
// Run optimization
_activeOptimizations[serverId] =
_reoptimizeServer(
serverId: serverId,
server: server,
reason: reason,
).whenComplete(() {
_activeOptimizations.remove(serverId);
});
}
}
/// Re-optimize connection for a specific server
Future<void> _reoptimizeServer({
required String serverId,
required PlexServer server,
required String reason,
}) async {
final storage = await StorageService.getInstance();
final client = _clients[serverId];
try {
appLogger.d(
'Starting connection optimization for ${server.name}',
error: {'reason': reason},
);
await for (final connection in server.findBestWorkingConnection()) {
final newUrl = connection.uri;
// Check if this is actually a better connection than current
if (client != null && client.config.baseUrl == newUrl) {
appLogger.d(
'Already using optimal endpoint for ${server.name}: $newUrl',
);
continue;
}
// Save the new endpoint
await storage.saveServerEndpoint(serverId, newUrl);
// If client has endpoint failover, it will automatically switch
// Otherwise, we might need to recreate the client (but failover should handle it)
appLogger.i(
'Updated optimal endpoint for ${server.name}: $newUrl',
error: {'type': connection.displayType},
);
}
} catch (e, stackTrace) {
appLogger.w(
'Connection optimization failed for ${server.name}',
error: e,
stackTrace: stackTrace,
);
}
}
/// Disconnect all servers
void disconnectAll() {
appLogger.i('Disconnecting all servers');
stopNetworkMonitoring();
_clients.clear();
_servers.clear();
_serverStatus.clear();
_activeOptimizations.clear();
_statusController.add({});
}
/// Dispose resources
void dispose() {
disconnectAll();
_statusController.close();
}
}
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../client/plex_client.dart';
import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
import '../utils/app_logger.dart';
import '../i18n/strings.g.dart';
import 'track_selection_service.dart';
/// Service responsible for initializing video playback
///
/// Handles the complex process of:
/// 1. Fetching video playback data from the Plex server
/// 2. Building external subtitle tracks
/// 3. Opening media in the player
/// 4. Adding external subtitles to the player
/// 5. Selecting and applying audio/subtitle tracks
/// 6. Seeking to resume position
/// 7. Starting playback
class PlaybackInitializationService {
final Player player;
final PlexClient client;
final BuildContext context;
PlaybackInitializationService({
required this.player,
required this.client,
required this.context,
});
/// Start playback for the given metadata
///
/// Returns a PlaybackInitializationResult with available versions and other data
Future<PlaybackInitializationResult> startPlayback({
required PlexMetadata metadata,
required int selectedMediaIndex,
required PlexUserProfile? profileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
double? preferredPlaybackRate,
}) async {
try {
// Get consolidated playback data (URL, media info, and versions) in a single API call
final playbackData = await client.getVideoPlaybackData(
metadata.ratingKey,
mediaIndex: selectedMediaIndex,
);
if (!playbackData.hasValidVideoUrl) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
final videoUrl = playbackData.videoUrl!;
final mediaInfo = playbackData.mediaInfo;
// Build list of external subtitle tracks for media_kit
final externalSubtitles = _buildExternalSubtitles(mediaInfo);
// Open video (without external subtitles in Media constructor)
await player.open(Media(videoUrl), play: false);
// Wait for media to be ready (duration > 0)
await _waitForMediaReady();
// Add external subtitle tracks without auto-selecting them
if (externalSubtitles.isNotEmpty) {
await _addExternalSubtitles(externalSubtitles);
}
// Select and apply tracks BEFORE seeking to resume position
// This prevents track changes from resetting the playback position on Android
final trackSelectionService = TrackSelectionService(
player: player,
profileSettings: profileSettings,
metadata: metadata,
);
await trackSelectionService.selectAndApplyTracks(
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredPlaybackRate: preferredPlaybackRate,
);
// Set up playback position if resuming (AFTER track selection)
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
appLogger.d('Resuming playback at ${metadata.viewOffset} ms');
final resumePosition = Duration(milliseconds: metadata.viewOffset!);
await player.seek(resumePosition);
}
// Start playback after seeking
await player.play();
// Return result with available versions for UI updates
return PlaybackInitializationResult(
availableVersions: playbackData.availableVersions,
);
} catch (e) {
if (e is PlaybackException) {
rethrow;
}
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
}
}
/// Build list of external subtitle tracks from media info
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
if (mediaInfo == null) {
return externalSubtitles;
}
final externalTracks = mediaInfo.subtitleTracks
.where((PlexSubtitleTrack track) => track.isExternal)
.toList();
if (externalTracks.isNotEmpty) {
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
}
for (final plexTrack in externalTracks) {
try {
// Skip if no auth token is available
final token = client.config.token;
if (token == null) {
appLogger.w('No auth token available for external subtitles');
continue;
}
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
// Skip if URL couldn't be constructed
if (url == null) continue;
externalSubtitles.add(
SubtitleTrack.uri(
url,
title:
plexTrack.displayTitle ??
plexTrack.language ??
'Track ${plexTrack.id}',
language: plexTrack.languageCode,
),
);
} catch (e) {
// Silent fallback - log error but continue with other subtitles
appLogger.w(
'Failed to add external subtitle track ${plexTrack.id}',
error: e,
);
}
}
return externalSubtitles;
}
/// Wait for media to be ready (duration > 0)
Future<void> _waitForMediaReady() async {
int attempts = 0;
while (player.state.duration.inMilliseconds == 0 && attempts < 100) {
await Future.delayed(const Duration(milliseconds: 100));
attempts++;
}
}
/// Add external subtitle tracks to the player without auto-selecting them
Future<void> _addExternalSubtitles(
List<SubtitleTrack> externalSubtitles,
) async {
appLogger.d(
'Adding ${externalSubtitles.length} external subtitle(s) to player',
);
final nativePlayer = player.platform as dynamic;
for (final subtitleTrack in externalSubtitles) {
try {
// Use mpv's sub-add with 'auto' flag to avoid auto-selection
await nativePlayer.command([
'sub-add',
subtitleTrack.id,
'auto',
subtitleTrack.title ?? 'external',
subtitleTrack.language ?? 'auto',
]);
} catch (e) {
appLogger.w(
'Failed to add external subtitle: ${subtitleTrack.title}',
error: e,
);
}
}
}
}
/// Result of playback initialization
class PlaybackInitializationResult {
final List<dynamic> availableVersions;
PlaybackInitializationResult({required this.availableVersions});
}
/// Exception thrown when playback initialization fails
class PlaybackException implements Exception {
final String message;
PlaybackException(this.message);
@override
String toString() => message;
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
/// Tracks playback progress and reports it to the Plex server.
///
/// Handles:
/// - Periodic timeline updates during playback
/// - Resume position tracking
/// - State change reporting (playing, paused, stopped)
class PlaybackProgressTracker {
final PlexClient client;
final PlexMetadata metadata;
final Player player;
/// Timer for periodic progress updates
Timer? _progressTimer;
/// Update interval (default: 10 seconds)
final Duration updateInterval;
PlaybackProgressTracker({
required this.client,
required this.metadata,
required this.player,
this.updateInterval = const Duration(seconds: 10),
});
/// Start tracking playback progress
///
/// Begins periodic timeline updates to the Plex server.
void startTracking() {
if (_progressTimer != null) {
appLogger.w('Progress tracking already started');
return;
}
_progressTimer = Timer.periodic(updateInterval, (timer) {
if (player.state.playing) {
_sendProgress('playing');
}
});
appLogger.d(
'Started progress tracking (interval: ${updateInterval.inSeconds}s)',
);
}
/// Stop tracking playback progress
///
/// Cancels the periodic timer.
void stopTracking() {
_progressTimer?.cancel();
_progressTimer = null;
appLogger.d('Stopped progress tracking');
}
/// Send progress update to Plex server
///
/// [state] can be 'playing', 'paused', or 'stopped'
Future<void> sendProgress(String state) async {
await _sendProgress(state);
}
Future<void> _sendProgress(String state) async {
try {
final position = player.state.position;
final duration = player.state.duration;
// Don't send progress if no duration (not ready)
if (duration.inMilliseconds == 0) {
return;
}
// Build timeline query parameters
final queryParams = {
'ratingKey': metadata.ratingKey,
'key': '/library/metadata/${metadata.ratingKey}',
'state': state,
'time': position.inMilliseconds.toString(),
'duration': duration.inMilliseconds.toString(),
};
// Add playQueueItemID if available (for playlist/shuffle playback)
if (metadata.playQueueItemID != null) {
queryParams['playQueueItemID'] = metadata.playQueueItemID.toString();
}
// Send timeline update
await client
.updateProgress(
metadata.ratingKey,
time: position.inMilliseconds,
state: state,
duration: duration.inMilliseconds,
)
.catchError((error) {
// Silent handling - don't interrupt playback for progress errors
appLogger.d(
'Failed to update progress (non-critical)',
error: error,
);
});
appLogger.d(
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
);
} catch (e) {
appLogger.d('Failed to send progress update (non-critical)', error: e);
}
}
/// Dispose resources
void dispose() {
stopTracking();
}
}

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