Compare commits

...

65 Commits

Author SHA1 Message Date
Gauvain
8718244f40 fix(settings): drop redundant mb-4 on buffer/VO groups (page gap handles spacing) 2026-06-04 12:37:27 +02:00
Gauvain
2245c84d2c fix(settings): more compact 48px rows; grow only when a description is present 2026-06-04 12:34:47 +02:00
Gauvain
ba0b646576 fix(settings): uniform 56px row height and consistent group spacing on Playback & Controls 2026-06-04 12:31:59 +02:00
Gauvain
bf6010b3c7 refactor(settings): rebuild Playback & Controls interior with typed rows + translate Chromecast 2026-06-04 12:16:13 +02:00
Gauvain
8071e76fc6 feat(settings): add typed switch/select/stepper setting rows 2026-06-04 12:11:21 +02:00
Gauvain
8a88706776 chore(ts): pin @types/node to 24 to match the CI/runtime Node version 2026-06-04 11:58:35 +02:00
Gauvain
db9540f90a chore(ts): typecheck test files via @types/bun (drop test exclude) 2026-06-04 11:51:22 +02:00
Gauvain
9f3014a430 fix(settings): address CodeRabbit review (guard clipboard copy, reuse getUserImageUrl, hoist search title) 2026-06-04 11:40:40 +02:00
Gauvain
da47ad0502 refactor(settings): restore expo-ui bottom sheet and direct clipboard (drop stale-build workarounds) 2026-06-04 00:36:44 +02:00
Gauvain
059c8bb854 feat(settings): populate deep-search index from sub-page audit 2026-06-04 00:23:22 +02:00
Gauvain
583fac6938 fix(settings): lazy-load clipboard so the Account screen opens without a native rebuild 2026-06-04 00:16:40 +02:00
Gauvain
f6333b49d3 fix(settings): use gorhom bottom sheet for QuickConnect (expo-ui needs a native rebuild) 2026-06-04 00:15:50 +02:00
Gauvain
f56b2bc4eb fix(settings): guard empty-token copy, search platform filter and clear button 2026-06-04 00:00:08 +02:00
Gauvain
ac5dcbe751 feat(settings): gate notifications page behind OS permission 2026-06-03 23:59:17 +02:00
Gauvain
752d1179bc feat(i18n): add settings-redo English keys (sections, account, notifications) 2026-06-03 23:51:47 +02:00
Gauvain
6a38c393e6 feat(settings): add profile hero and settings search 2026-06-03 23:48:22 +02:00
Gauvain
e476e0b4d9 feat(settings): add accent-insensitive search filter with tests 2026-06-03 23:41:40 +02:00
Gauvain
e783227ba6 feat(settings): add Account and Notifications pages, gate download notifications 2026-06-03 23:36:39 +02:00
Gauvain
a0e6b31500 fix(settings): correct QuickConnect sheet ref wiring and reset PIN on open 2026-06-03 23:32:44 +02:00
Gauvain
4e3f5a72e0 feat(settings): catalog-driven index + migrate QuickConnect to expo-ui sheet 2026-06-03 23:27:46 +02:00
Gauvain
25c0663d2f feat(settings): add data-driven settings catalog 2026-06-03 23:22:33 +02:00
Gauvain
81e97740f7 feat(settings): add SettingsSection component 2026-06-03 23:20:37 +02:00
Gauvain
1349ad7dc0 feat(settings): add SettingsRow component 2026-06-03 23:19:21 +02:00
Gauvain
4df9690d22 fix(list): unify ListItem row height and padding 2026-06-03 23:17:45 +02:00
Gauvain
3dfae1a1b4 feat(settings): add notifications, landing-tab and wifi-only setting keys 2026-06-03 23:16:14 +02:00
Gauvain
c7e3ceeefb chore(settings): add expo-clipboard for token copy 2026-06-03 23:14:41 +02:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Kylian Ronfleux--Corail
7d16e7d5c7 fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 22:37:16 +02:00
Alex
ceb9b5a1ae fix(downloads): poster images not showing for downloaded content (#1652)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 19:47:14 +02:00
Gauvain
1144ff5049 fix(player): correct prev/next episode buttons at boundaries & for missing episodes (#1649) 2026-06-02 19:12:21 +02:00
Alex
4d508a4315 fix(platform-dropdown-component): Auto-size iOS trigger wrapper to pr… (#1650) 2026-06-02 18:40:47 +02:00
Alex
915a4febbb fix(player): respect safe area in chapter list and refine UX (#1653) 2026-06-02 22:31:49 +10:00
Alex
88163eb531 fix(mpv): release audio session on player exit so other apps' audio resumes (#1651)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
2026-06-02 19:20:56 +10:00
Fredrik Burmester
46bd2a784e fix(tv): keep focus on search field instead of jumping to results grid (#1637)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🚀 Release (EAS build + submit) / 🔐 Approve release (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 Android TV APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🍎 iOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 tvOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android AAB (push) Has been cancelled
🚀 Release (EAS build + submit) / 📦 Draft GitHub Release (push) Has been cancelled
2026-06-01 21:50:52 +02:00
Fredrik Burmester
0a36fdfbec fix: icon alignment library header
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-01 19:55:55 +02:00
Fredrik Burmester
45d1f752d6 fix: header left button icon alignment 2026-06-01 19:46:07 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
lance chant
338fb9713b fix: qr code scanning not working ios (#1619)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:54 +02:00
lance chant
939fd2512d fix: max episodes count (#1554)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:34 +02:00
github-actions[bot]
32c99de874 feat: New Crowdin Translations (#1460)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 10:33:17 +02:00
Fredrik Burmester
c232e433bf fix(ci): grant actions:read to github-release job (#1634) 2026-06-01 10:31:05 +02:00
Fredrik Burmester
07e2faff07 fix(tv): only add user-management entitlement for tvOS builds (#1633) 2026-06-01 10:28:21 +02:00
Fredrik Burmester
8507699cdd feat(eas): force bun on EAS via custom build configs + 5-build release workflow (#1632) 2026-06-01 10:24:52 +02:00
Gauvain
21fb056586 fix(i18n): make two hardcoded titles translatable (#1627) 2026-06-01 09:46:27 +02:00
Gauvain
1d79b513f3 fix(item): dedupe top people sections by id (#1623) 2026-06-01 09:37:45 +02:00
Gauvain
863dffd944 fix(chapters): keep landscape when opening chapter list on iOS (#1624) 2026-06-01 09:37:35 +02:00
lance chant
6aa0868bfd fix: fixed a runtime issue for android (#1628)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 09:35:19 +02:00
Felix Schneider
6b7ee0514f feat(i18n): add new translations for action sheet options (#1475)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 23:45:45 +02:00
Fredrik Burmester
c663bd0413 fix(jellyseerr): correct RequestModal ref type to fix typecheck
advancedReqModalRef was typed as BottomSheetModal but RequestModal's
forwardRef expects BottomSheetModalMethods, causing a TS2322 error
that broke the Security & Quality Gate typecheck on develop.
2026-05-31 22:10:15 +02:00
Alex
52e6f56220 fix(auth): clear stored user on logout to prevent empty home on relaunch (#1622) 2026-05-31 21:52:41 +02:00
Fredrik Burmester
c981f59a50 fix(downloads): repair bottom sheets on iOS and restore downloads delete sheet
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Bump @gorhom/bottom-sheet 5.2.8 -> 5.2.14, which fixes BottomSheetModal
present() silently no-opping under Reanimated 4 / New Architecture (SDK 56).
Affected every sheet app-wide: present() was called with a valid ref but
nothing rendered (not even the backdrop). Verified on the iOS simulator that
the download options sheet now opens.

Also restore the downloads-page delete sheet (delete movies/series/other/all)
that was accidentally dropped in the Expo 54 rewrite (#1174), which left an
orphaned trigger button and underscore-silenced handlers.
2026-05-31 14:58:51 +02:00
Alex
62fc6f9a70 fix(progress-bar): Fix progress bar not reporting watch times (#1611)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 22:12:13 +10:00
Fredrik Burmester
eb8dd51b4e feat(ci): EAS build + auto-submit release workflow for main (#1616) 2026-05-31 13:23:02 +02:00
Fredrik Burmester
ea5a999f21 fix(deps): declare react-native-tab-view and material-top-tabs (#1617) 2026-05-31 12:40:56 +02:00
Felix Schneider
dffcdef945 feat(i18n): Add translation for "ends at" (#1474)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 12:10:22 +02:00
Fredrik Burmester
fa1c3f3947 chore(eas): pin appleTeamId and ascAppId in submit profiles
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Avoid the interactive Apple team picker and app-existence lookup on
submit by pinning the Individual team (MWD5K362T8) and ASC App ID.
2026-05-31 11:22:59 +02:00
Fredrik Burmester
2761de5a74 chore(eas): use remote app version source with autoIncrement
Switch cli.appVersionSource to remote and enable autoIncrement on all
production profiles so EAS bumps the build number every release instead
of resetting to 1. Remove the dead android.versionCode from app.json and
the unused EAS Update channel (no expo-updates installed).
2026-05-31 11:22:59 +02:00
Fredrik Burmester
feca1d7e9c fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) 2026-05-31 10:50:06 +02:00
Fredrik Burmester
6b6bfd1a89 fix(player): remove white blob artifacts on vertical sliders 2026-05-31 10:48:24 +02:00
Fredrik Burmester
d585b20f49 chore: version 2026-05-31 09:44:05 +02:00
Fredrik Burmester
692ccfdb2c fix(tvos): add arm64 UIRequiredDeviceCapabilities to Top Shelf extension
App Store Connect rejected TestFlight submissions because the Top Shelf
extension binary has a 64-bit slice but did not declare arm64 under
UIRequiredDeviceCapabilities in its Info.plist.
2026-05-31 09:42:09 +02:00
Fredrik Burmester
86e39c444c fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) 2026-05-31 09:37:08 +02:00
Fredrik Burmester
ed7928b4d3 Merge remote-tracking branch 'origin/feat/tv-interface' into develop
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-30 22:47:33 +02:00
lance chant
27dc7b5664 fix: android pip (#1605)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 22:45:19 +02:00
Alex
a205c75895 chore(mpv-player): Update to MPVKIT 0.41 (#1604) 2026-05-30 22:45:07 +02:00
117 changed files with 8756 additions and 1974 deletions

View File

@@ -0,0 +1,25 @@
# Custom EAS Build config for Android phone APK (downloadable artifact).
# Same bun-forcing flow as android-production.yml, but builds an APK
# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
# Referenced from eas.json: build.production-apk.android.config
build:
name: Android phone APK (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
- run:
name: Prebuild (Android, bun)
command: bunx expo prebuild --platform android --no-install
- eas/configure_android_version
- eas/inject_android_credentials
- eas/run_gradle:
inputs:
command: :app:assembleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,27 @@
# Custom EAS Build config for Android TV APK (downloadable artifact).
# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
# eas.json) so prebuild generates the TV variant. Builds an APK for
# sideloading onto Android TV devices.
# Referenced from eas.json: build.production-apk-tv.android.config
build:
name: Android TV APK (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
- run:
name: Prebuild (Android TV, bun)
command: bunx expo prebuild --platform android --no-install
- eas/configure_android_version
- eas/inject_android_credentials
- eas/run_gradle:
inputs:
command: :app:assembleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,38 @@
# Custom EAS Build config for Android (production AAB).
#
# Why this exists: EAS's managed build can't detect Bun's text lockfile
# (bun.lock) and falls back to yarn, which breaks our install. The managed
# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
# manager detected based on your project", so we replace them with explicit
# `bun` commands. Everything else uses EAS's built-in functions so we still
# get remote versioning, credentials, and artifact upload.
#
# Referenced from eas.json: build.production.android.config = android-production.yml
build:
name: Android production (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
# android/ is gitignored, so generate native code fresh. --no-install
# because deps are already installed above; bunx keeps it on bun.
- run:
name: Prebuild (Android, bun)
command: bunx expo prebuild --platform android --no-install
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
# in eas.json) into the freshly prebuilt android/ project.
- eas/configure_android_version
# Injects the remote Android keystore / signing config.
- eas/inject_android_credentials
# Build the Play Store app bundle (.aab).
- eas/run_gradle:
inputs:
command: :app:bundleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,44 @@
# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
#
# Shared by both the iPhone profile (production) and the tvOS profile
# (production_tv). The profile decides the rest:
# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
# credentialsSource: local (EAS can't manage tvOS creds remotely).
# - production uses remote-managed iOS credentials.
#
# Like the Android configs, this replaces eas/install_node_modules and
# eas/prebuild (both auto-detect the wrong package manager) with explicit
# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
build:
name: iOS/tvOS App Store (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
- eas/resolve_apple_team_id_from_credentials:
id: resolve_team
# android/ + ios/ are gitignored, so generate native code fresh.
# EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
# skips JS + pod install; we install pods explicitly below with bun deps.
- run:
name: Prebuild (iOS/tvOS, bun)
command: bunx expo prebuild --platform ios --no-install
- run:
name: Install CocoaPods
working_directory: ./ios
command: pod install
- eas/configure_ios_credentials
- eas/configure_ios_version
- eas/generate_gymfile_from_template:
inputs:
credentials: ${ eas.job.secrets.buildCredentials }
- eas/run_fastlane
- eas/find_and_upload_build_artifacts

216
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
name: 🚀 Release (EAS build + submit)
# On merge to main (gated by the `production` GitHub Environment approval),
# build all targets on EAS in parallel via custom bun build configs:
# 1. iOS phone → App Store (auto-submit)
# 2. tvOS → App Store (auto-submit)
# 3. Android AAB → Google Play (auto-submit)
# 4. Android phone APK→ downloadable artifact
# 5. Android TV APK → downloadable artifact
# Note: EAS queues builds based on your plan's concurrency; parallel jobs
# here just submit them — EAS may still run them serially.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
workflow_dispatch:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
environment: production
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: 🍎 iOS
platform: ios
profile: production
submit: true
- name: 📺 tvOS
platform: ios
profile: production_tv
submit: true
- name: 🤖 Android AAB
platform: android
profile: production
submit: true
- name: 🤖 Android APK
platform: android
profile: production-apk
submit: false
artifact_name: streamyfin-android-phone-apk
- name: 📺 Android TV APK
platform: android
profile: production-apk-tv
submit: false
artifact_name: streamyfin-android-tv-apk
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv'
env:
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
run: |
mkdir -p certs profiles
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# Android Play submit needs the Google Play service account JSON.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android' && matrix.submit
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
# App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
else
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# ── Submit builds: cloud build + auto-submit to the store ──
- name: 🚀 Build & submit (${{ matrix.name }})
if: matrix.submit
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive \
--wait
# ── Artifact builds: cloud build, then download + upload the APK ──
- name: 🏗️ Build artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--non-interactive \
--wait \
--json > build-result.json
URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
echo "Downloading artifact: $URL"
curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
- name: 📤 Upload APK artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}.apk
retention-days: 14
# Draft a GitHub Release with the two APKs attached. The tag comes from the
# merged-in app version (app.json → expo.version), NOT the auto-incremented
# build number — so cutting a release is a deliberate version bump via PR.
github-release:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
show-progress: false
- name: 📦 Download APK artifacts from this run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p apks
gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
ls -la apks
- name: 📝 Draft release (tag = app.json version, not auto-bumped)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -e "console.log(require('./app.json').expo.version)")
TAG="v$VERSION"
echo "Release tag from merged app version: $TAG"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG exists — updating APK assets"
gh release upload "$TAG" apks/*.apk --clobber
else
echo "Creating draft release $TAG"
gh release create "$TAG" \
--draft \
--generate-notes \
--title "$TAG" \
apks/*.apk
fi

9
.gitignore vendored
View File

@@ -18,6 +18,9 @@ web-build/
/androidmobile /androidmobile
/androidtv /androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds # Module-specific Builds
modules/mpv-player/android/build modules/mpv-player/android/build
modules/player/android modules/player/android
@@ -73,6 +76,12 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/ .claude/
.agents/skills/** .agents/skills/**
skills-lock.json skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.54.0", "version": "0.54.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -36,7 +36,6 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"versionCode": 93,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -144,8 +143,8 @@
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit-GPL", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
} }
] ]
], ],

View File

@@ -243,6 +243,28 @@ export default function IndexLayout() {
headerLeft: () => <HeaderBackButton />, headerLeft: () => <HeaderBackButton />,
}} }}
/> />
<Stack.Screen
name='settings/account/page'
options={{
title: t("home.settings.account.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/notifications/page'
options={{
title: t("home.settings.notifications.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}

View File

@@ -1,4 +1,9 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet"; import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<Pressable <Pressable
onPress={bottomSheetModalRef.current?.present} onPress={() => bottomSheetModalRef.current?.present()}
className='px-2' className='px-2'
> >
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
} }
}, [showMigration]); }, [showMigration]);
const _deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
toast.success( toast.success(
@@ -127,7 +133,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const _deleteShows = () => const deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
@@ -138,7 +144,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
}); });
const _deleteOtherMedia = () => const deleteOtherMedia = () =>
Promise.all( Promise.all(
otherMedia otherMedia
.filter((item) => item.item.Type) .filter((item) => item.item.Type)
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
), ),
); );
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return ( return (
<OfflineModeProvider isOffline={true}> <OfflineModeProvider isOffline={true}>
<ScrollView <ScrollView
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
)} )}
</View> </View>
</ScrollView> </ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</OfflineModeProvider> </OfflineModeProvider>
); );
} }

View File

@@ -1,38 +1,44 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useEffect, useRef, useState } from "react";
import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { SettingsHero } from "@/components/settings/index/SettingsHero";
import { SettingsRow } from "@/components/settings/index/SettingsRow";
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
import { SettingsSection } from "@/components/settings/index/SettingsSection";
import {
SETTINGS_CATALOG,
type SettingsTarget,
} from "@/components/settings/index/settingsCatalog";
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
import {
QuickConnectSheet,
type QuickConnectSheetRef,
} from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
// TV-specific settings component
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
// Mobile settings component
function SettingsMobile() { function SettingsMobile() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const navigation = useNavigation(); const navigation = useNavigation();
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
const [query, setQuery] = useState("");
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
const results = useSettingsSearch(query);
const searching = query.trim().length > 0;
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity <TouchableOpacity onPress={() => logout()}>
onPress={() => {
logout();
}}
>
<Text className='text-red-600 px-2'> <Text className='text-red-600 px-2'>
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
@@ -41,96 +47,95 @@ function SettingsMobile() {
}); });
}, []); }, []);
const handleTarget = (target: SettingsTarget) => {
if (target.type === "action") {
if (target.action === "quickConnect") {
quickConnectRef.current?.present();
}
return;
}
router.push(target.route as any);
};
return ( return (
<ScrollView <ScrollView
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
keyboardShouldPersistTaps='handled'
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingTop: 8,
paddingBottom: 32,
}} }}
> >
<View {!searching && (
className='p-4 flex flex-col' <SettingsHero
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} onPress={() => router.push("/settings/account/page" as any)}
> />
<View className='mb-4'> )}
<UserInfo /> <SettingsSearchBar value={query} onChange={setQuery} />
</View>
<QuickConnect className='mb-4' /> {searching ? (
<SettingsSection title={t("home.settings.search_results")}>
{results.length === 0 ? (
<View className='px-4 py-3'>
<Text className='text-[#9899A1]'>
{t("home.settings.search_no_results")}
</Text>
</View>
) : (
results.map((r, i) => (
<SettingsRow
key={r.id}
title={r.title}
icon={r.icon}
value={r.subtitle}
onPress={() => handleTarget(r.target)}
isLast={i === results.length - 1}
/>
))
)}
</SettingsSection>
) : (
<>
<View className='mx-3 mb-5'>
<AppLanguageSelector />
</View>
{SETTINGS_CATALOG.map((section) => {
const entries = section.entries.filter(
(e) => !e.platforms || e.platforms.includes(os),
);
if (entries.length === 0) return null;
return (
<SettingsSection key={section.id} title={t(section.titleKey)}>
{entries.map((e, i) => (
<SettingsRow
key={e.id}
title={t(e.titleKey)}
icon={e.icon}
onPress={() => handleTarget(e.target)}
isLast={i === entries.length - 1}
/>
))}
</SettingsSection>
);
})}
<SettingsSection>
<View className='p-3'>
<StorageSettings />
</View>
</SettingsSection>
</>
)}
<View className='mb-4'> <QuickConnectSheet ref={quickConnectRef} />
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
onPress={() =>
router.push("/(auth)/(tabs)/(home)/companion-login")
}
title={t("pairing.pair_with_phone")}
textColor='blue'
/>
</ListGroup>
</View>
<View className='mb-4'>
<AppLanguageSelector />
</View>
<View className='mb-4'>
<ListGroup title={t("home.settings.categories.title")}>
<ListItem
onPress={() => router.push("/settings/playback-controls/page")}
showArrow
title={t("home.settings.playback_controls.title")}
/>
<ListItem
onPress={() => router.push("/settings/audio-subtitles/page")}
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/music/page")}
showArrow
title={t("home.settings.music.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow
title={t("home.settings.appearance.title")}
/>
<ListItem
onPress={() => router.push("/settings/plugins/page")}
showArrow
title={t("home.settings.plugins.plugins_title")}
/>
<ListItem
onPress={() => router.push("/settings/intro/page")}
showArrow
title={t("home.settings.intro.title")}
/>
<ListItem
onPress={() => router.push("/settings/network/page")}
showArrow
title={t("home.settings.network.title")}
/>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={t("home.settings.logs.logs_title")}
/>
</ListGroup>
</View>
<StorageSettings />
</View>
</ScrollView> </ScrollView>
); );
} }
export default function settings() { export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) { if (Platform.isTV && SettingsTV) {
return <SettingsTV />; return <SettingsTV />;
} }
return <SettingsMobile />; return <SettingsMobile />;
} }

View File

@@ -0,0 +1,60 @@
import * as Application from "expo-application";
import { setStringAsync } from "expo-clipboard";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useState } from "react";
import { Alert, ScrollView } from "react-native";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function AccountPage() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [revealed, setRevealed] = useState(false);
const success = useHaptic("success");
const version = Application.nativeApplicationVersion ?? "N/A";
const token = api?.accessToken ?? "";
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
const copyToken = async () => {
if (!token) return;
try {
await setStringAsync(token);
success();
Alert.alert(t("home.settings.account.copied"));
} catch {
Alert.alert(t("home.settings.account.copy_failed"));
}
};
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<ListGroup title={t("home.settings.user_info.user_info_title")}>
<ListItem
title={t("home.settings.user_info.user")}
value={user?.Name}
/>
<ListItem
title={t("home.settings.user_info.server")}
value={api?.basePath}
/>
<ListItem
title={t("home.settings.user_info.token")}
value={revealed ? token : masked}
onPress={() => setRevealed((r) => !r)}
/>
<ListItem
title={t("home.settings.account.copy_token")}
textColor='blue'
onPress={copyToken}
/>
<ListItem
title={t("home.settings.user_info.app_version")}
value={version}
/>
</ListGroup>
</ScrollView>
);
}

View File

@@ -0,0 +1,68 @@
import { Ionicons } from "@expo/vector-icons";
import * as Notifications from "expo-notifications";
import { t } from "i18next";
import { useEffect, useState } from "react";
import { Linking, ScrollView, Switch, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
export default function NotificationsPage() {
const { settings, updateSettings } = useSettings();
const [perm, setPerm] =
useState<Notifications.NotificationPermissionsStatus | null>(null);
useEffect(() => {
Notifications.getPermissionsAsync().then(setPerm);
}, []);
const requestPermission = async () => {
const res = await Notifications.requestPermissionsAsync();
setPerm(res);
// Only bounce to system settings when the OS will not prompt again.
if (!res.granted && res.canAskAgain === false) {
Linking.openSettings();
}
};
if (!settings || perm === null) return null;
if (!perm.granted) {
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
<Text className='text-white text-lg font-semibold mt-4 text-center'>
{t("home.settings.notifications.disabled_title")}
</Text>
<Text className='text-[#9899A1] text-center mt-2'>
{t("home.settings.notifications.disabled_description")}
</Text>
<Button color='purple' className='mt-6' onPress={requestPermission}>
{t("home.settings.notifications.enable_button")}
</Button>
</View>
);
}
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<ListGroup title={t("home.settings.notifications.events_title")}>
<ListItem title={t("home.settings.notifications.master")}>
<Switch
value={settings.notificationsEnabled}
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
/>
</ListItem>
<ListItem title={t("home.settings.notifications.downloads")}>
<Switch
value={settings.notifyDownloads}
disabled={!settings.notificationsEnabled}
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
/>
</ListItem>
</ListGroup>
</ScrollView>
);
}

View File

@@ -20,18 +20,16 @@ export default function PlaybackControlsPage() {
}} }}
> >
<View <View
className='p-4 flex flex-col' className='p-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<View className='mb-4'> <MediaProvider>
<MediaProvider> <MediaToggles />
<MediaToggles className='mb-4' /> <GestureControls />
<GestureControls className='mb-4' /> <PlaybackControlsSettings />
<PlaybackControlsSettings /> <MpvBufferSettings />
<MpvBufferSettings /> <MpvVoSettings />
<MpvVoSettings /> </MediaProvider>
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />} {!Platform.isTV && <ChromecastSettings />}
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
}; };
const handleRefreshFromServer = useCallback(async () => { const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true); const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values // Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl); setUrl(newUrl);

View File

@@ -6,6 +6,7 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen} open={dropdownOpen}
onOpenChange={setDropdownOpen} onOpenChange={setDropdownOpen}
trigger={ trigger={
<View className='pl-1.5'> <View>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}

View File

@@ -40,6 +40,8 @@ const Layout = () => {
keyboardDismissMode='none' keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE, fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600", fontWeight: "600",

View File

@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
}; };
if (itemId) { if (itemId) {
setItem(null);
setDownloadedItem(null);
// Clear the previous episode's stream so the loader gate stays closed
// until the new item's stream resolves (avoids a stale MPV source frame).
setStream(null);
fetchItemData(); fetchItemData();
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
return null; return null;
} }
// Ensure item matches the current itemId to avoid race conditions
if (item.Id !== itemId) {
setStreamStatus({ isLoading: false, isError: false });
return null;
}
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {
@@ -825,12 +837,10 @@ export default function DirectPlayerPage() {
], ],
); );
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback( const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => { (e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent; const { isActive } = e.nativeEvent;
setIsPipMode(isActive); setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) { if (isActive) {
_setShowControls(false); _setShowControls(false);
} }
@@ -848,6 +858,9 @@ export default function DirectPlayerPage() {
// Memoize video ref functions to prevent unnecessary re-renders // Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => { const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.(); return videoRef.current?.startPictureInPicture?.();
}, []); }, []);
@@ -1253,6 +1266,7 @@ export default function DirectPlayerPage() {
nowPlayingMetadata={nowPlayingMetadata} nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);

View File

@@ -11,9 +11,10 @@
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14", "@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5", "@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -30,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",
@@ -83,6 +85,7 @@
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
@@ -101,8 +104,10 @@
"@biomejs/biome": "2.4.16", "@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3", "@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6", "@react-native-tvos/config-tv": "0.1.6",
"@types/bun": "^1.3.14",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/node": "^24",
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
@@ -114,11 +119,6 @@
}, },
}, },
}, },
"patchedDependencies": {
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
},
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -368,7 +368,7 @@
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="], "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -542,6 +542,10 @@
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="], "@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="], "@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
@@ -586,6 +590,8 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
@@ -602,7 +608,7 @@
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
@@ -742,6 +748,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -954,6 +962,8 @@
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
"expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
@@ -1594,6 +1604,8 @@
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
@@ -1824,7 +1836,7 @@
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="], "ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
@@ -2006,6 +2018,10 @@
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], "@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -2024,6 +2040,8 @@
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2122,6 +2140,8 @@
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
@@ -2224,6 +2244,14 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2232,6 +2260,8 @@
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], "ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -2288,6 +2318,8 @@
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -2336,6 +2368,14 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], "@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],

View File

@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55) // @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// fills its parent and reports its own size via setStyleSize, so it can't // the trigger sizes the wrapper while the Host overlays the real Menu.
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return ( return (
<View style={triggerSize ?? { opacity: 0 }}> <View>
{/* Hidden measurer: lays the trigger out off-flow to capture its <View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger} {trigger}
</View> </View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>

View File

@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { import {
type ChapterEntry, type ChapterEntry,
chapterStartsMs, chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose, onClose,
}: ChapterListProps) { }: ChapterListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null); const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]); const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -74,9 +76,22 @@ function ChapterListComponent({
transparent transparent
animationType='slide' animationType='slide'
onRequestClose={onClose} onRequestClose={onClose}
// iOS defaults <Modal> to portrait-only; without this it rotates the app
// back to portrait when opened from the landscape player. Android ignores it.
supportedOrientations={["portrait", "landscape"]}
> >
<Pressable onPress={onClose} style={styles.backdrop}> <Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}> <Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text> <Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable <Pressable
@@ -157,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
}, },
sheet: { sheet: {
backgroundColor: Colors.background, backgroundColor: Colors.background,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
maxHeight: "70%", maxHeight: "70%",
paddingBottom: 24,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",

View File

@@ -37,11 +37,12 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
} }
/> />
<View <View
style={{ style={
width: `${progress}%`, Platform.isTV
backgroundColor: Platform.isTV ? "#ffffff" : undefined, ? { width: `${progress}%`, backgroundColor: "#ffffff" }
}} : { width: `${progress}%` }
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`} }
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
/> />
</> </>
); );

View File

@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
children, children,
...props ...props
}) => { }) => {
const { t } = useTranslation();
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return; return;
const options: string[] = [ const options: string[] = [
"Mark as Played", t("common.mark_as_played"),
"Mark as Not Played", t("common.mark_as_not_played"),
isFavorite ? "Unmark as Favorite" : "Mark as Favorite", isFavorite
...(isOffline ? ["Delete Download"] : []), ? t("music.track_options.remove_from_favorites")
"Cancel", : t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
]; ];
const cancelButtonIndex = options.length - 1; const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline const destructiveButtonIndex = isOffline
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isOffline, isOffline,
deleteFile, deleteFile,
item.Id, item.Id,
t,
]); ]);
if ( if (

View File

@@ -133,7 +133,6 @@ const HomeMobile = () => {
onPress={() => { onPress={() => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather <Feather

View File

@@ -37,7 +37,20 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto; return { ...item, People: people } as BaseItemDto;
}, [item, people]); }, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]); // Jellyfin can list the same person several times (e.g. an actor also
// credited as writer). Dedupe by Id so the same actor section isn't rendered
// twice and we still surface 3 distinct people.
const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (!person.Id || seen.has(person.Id)) continue;
seen.add(person.Id);
unique.push(person);
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback( const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => { (person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -34,12 +34,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
const isDisabled = disabled || disabledByAdmin; const isDisabled = disabled || disabledByAdmin;
const hasSubtitle = Boolean(effectiveSubtitle);
if (onPress) if (onPress)
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={isDisabled} disabled={isDisabled}
onPress={onPress} onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`} className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
<ListItemContent <ListItemContent
@@ -58,7 +59,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<View <View
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`} className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
{...viewProps} {...viewProps}
> >
<ListItemContent <ListItemContent

View File

@@ -1,6 +1,6 @@
import { t } from "i18next"; import { t } from "i18next";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
</View> </View>
{/* Pair with Phone */} {/* Pair with Phone */}
{onStartPairing && ( {Platform.OS !== "ios" && onStartPairing && (
<View> <View>
<Button <Button
onPress={onStartPairing} onPress={onStartPairing}

View File

@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) { if (loading) {
return null; return null;
} }
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
return ( return (
<View> <View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection <TVJellyseerrMovieSection
title={t("search.request_movies")} title={t("search.request_movies")}
items={movieResults} items={movieResults}
isFirstSection={hasMovies} isFirstSection={false}
onItemPress={onMoviePress} onItemPress={onMoviePress}
/> />
<TVJellyseerrTvSection <TVJellyseerrTvSection
title={t("search.request_series")} title={t("search.request_series")}
items={tvResults} items={tvResults}
isFirstSection={!hasMovies && hasTv} isFirstSection={false}
onItemPress={onTvPress} onItemPress={onTvPress}
/> />
<TVJellyseerrPersonSection <TVJellyseerrPersonSection
title={t("search.actors")} title={t("search.actors")}
items={personResults} items={personResults}
isFirstSection={!hasMovies && !hasTv && hasPersons} isFirstSection={false}
onItemPress={onPersonPress} onItemPress={onPersonPress}
/> />
</View> </View>

View File

@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch; forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */} our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View <View
style={{ style={{
marginBottom: 24, marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT, height: SEARCH_AREA_HEIGHT,
}} }}
> >
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */} {/* Library Search Results */}
{isLibraryMode && !loading && ( {isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}> <View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => ( {sections.map((section) => (
<TVSearchSection <TVSearchSection
key={section.key} key={section.key}
title={section.title} title={section.title}
items={section.items!} items={section.items!}
orientation={section.orientation || "vertical"} orientation={section.orientation || "vertical"}
isFirstSection={index === 0} // Never auto-focus a result. The native search field owns focus
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentInset={{ // Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
left: edgePadding, // contentOffset only applies on initial mount; since this FlatList is
right: edgePadding, // reused across searches (stable key), a second search left the inset
}} // without the offset and the grid snapped flush to the left edge.
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
}} }}
/> />

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode();
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot, so refetches after
// updateDownloadedItem() reflect the latest state instead of a stale
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
onPress={() => { onPress={() => {
router.setParams({ id: _item.Id }); router.setParams({ id: _item.Id });
}} }}
className={`flex flex-col w-44 className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"} ${item?.Id === _item.Id ? "" : "opacity-50"}
`} `}
> >

View File

@@ -1,21 +1,22 @@
import { Switch, View } from "react-native"; import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => { export const ChromecastSettings: React.FC = ({ ...props }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"Chromecast"}> <ListGroup title={t("home.settings.chromecast.title")}>
<ListItem title={"Enable H265 for Chromecast"}> <SettingsSwitchRow
<Switch title={t("home.settings.chromecast.enable_h265")}
value={settings.enableH265ForChromecast} value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) => onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast }) updateSettings({ enableH265ForChromecast })
} }
/> />
</ListItem>
</ListGroup> </ListGroup>
</View> </View>
); );

View File

@@ -2,11 +2,10 @@ import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import { Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -32,85 +31,65 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
<ListGroup <ListGroup
title={t("home.settings.gesture_controls.gesture_controls_title")} title={t("home.settings.gesture_controls.gesture_controls_title")}
> >
<ListItem <SettingsSwitchRow
title={t("home.settings.gesture_controls.horizontal_swipe_skip")} title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
subtitle={t( subtitle={t(
"home.settings.gesture_controls.horizontal_swipe_skip_description", "home.settings.gesture_controls.horizontal_swipe_skip_description",
)} )}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
> value={settings.enableHorizontalSwipeSkip}
<Switch onValueChange={(enableHorizontalSwipeSkip) =>
value={settings.enableHorizontalSwipeSkip} updateSettings({ enableHorizontalSwipeSkip })
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} }
onValueChange={(enableHorizontalSwipeSkip) => />
updateSettings({ enableHorizontalSwipeSkip })
}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.gesture_controls.left_side_brightness")} title={t("home.settings.gesture_controls.left_side_brightness")}
subtitle={t( subtitle={t(
"home.settings.gesture_controls.left_side_brightness_description", "home.settings.gesture_controls.left_side_brightness_description",
)} )}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked} disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
> value={settings.enableLeftSideBrightnessSwipe}
<Switch onValueChange={(enableLeftSideBrightnessSwipe) =>
value={settings.enableLeftSideBrightnessSwipe} updateSettings({ enableLeftSideBrightnessSwipe })
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked} }
onValueChange={(enableLeftSideBrightnessSwipe) => />
updateSettings({ enableLeftSideBrightnessSwipe })
}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.gesture_controls.right_side_volume")} title={t("home.settings.gesture_controls.right_side_volume")}
subtitle={t( subtitle={t(
"home.settings.gesture_controls.right_side_volume_description", "home.settings.gesture_controls.right_side_volume_description",
)} )}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked} disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
> value={settings.enableRightSideVolumeSwipe}
<Switch onValueChange={(enableRightSideVolumeSwipe) =>
value={settings.enableRightSideVolumeSwipe} updateSettings({ enableRightSideVolumeSwipe })
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked} }
onValueChange={(enableRightSideVolumeSwipe) => />
updateSettings({ enableRightSideVolumeSwipe })
}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.gesture_controls.hide_volume_slider")} title={t("home.settings.gesture_controls.hide_volume_slider")}
subtitle={t( subtitle={t(
"home.settings.gesture_controls.hide_volume_slider_description", "home.settings.gesture_controls.hide_volume_slider_description",
)} )}
disabled={pluginSettings?.hideVolumeSlider?.locked} disabled={pluginSettings?.hideVolumeSlider?.locked}
> value={settings.hideVolumeSlider}
<Switch onValueChange={(hideVolumeSlider) =>
value={settings.hideVolumeSlider} updateSettings({ hideVolumeSlider })
disabled={pluginSettings?.hideVolumeSlider?.locked} }
onValueChange={(hideVolumeSlider) => />
updateSettings({ hideVolumeSlider })
}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.gesture_controls.hide_brightness_slider")} title={t("home.settings.gesture_controls.hide_brightness_slider")}
subtitle={t( subtitle={t(
"home.settings.gesture_controls.hide_brightness_slider_description", "home.settings.gesture_controls.hide_brightness_slider_description",
)} )}
disabled={pluginSettings?.hideBrightnessSlider?.locked} disabled={pluginSettings?.hideBrightnessSlider?.locked}
> value={settings.hideBrightnessSlider}
<Switch onValueChange={(hideBrightnessSlider) =>
value={settings.hideBrightnessSlider} updateSettings({ hideBrightnessSlider })
disabled={pluginSettings?.hideBrightnessSlider?.locked} }
onValueChange={(hideBrightnessSlider) => />
updateSettings({ hideBrightnessSlider })
}
/>
</ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
/> />
</OptionGroup> </OptionGroup>
<OptionGroup title='Options'> <OptionGroup title={t("library.options.options_title")}>
<ToggleItem <ToggleItem
label={t("library.options.show_titles")} label={t("library.options.show_titles")}
value={settings.showTitles} value={settings.showTitles}

View File

@@ -2,11 +2,10 @@ import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -27,35 +26,27 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
return ( return (
<DisabledSetting disabled={disabled} {...props}> <DisabledSetting disabled={disabled} {...props}>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}> <ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem <SettingsStepperRow
title={t("home.settings.media_controls.forward_skip_length")} title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked} disabled={pluginSettings?.forwardSkipTime?.locked}
> value={settings.forwardSkipTime}
<Stepper step={5}
value={settings.forwardSkipTime} appendValue={t("home.settings.media_controls.seconds_unit")}
disabled={pluginSettings?.forwardSkipTime?.locked} min={0}
step={5} max={60}
appendValue={t("home.settings.media_controls.seconds_unit")} onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
min={0} />
max={60}
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
/>
</ListItem>
<ListItem <SettingsStepperRow
title={t("home.settings.media_controls.rewind_length")} title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked} disabled={pluginSettings?.rewindSkipTime?.locked}
> value={settings.rewindSkipTime}
<Stepper step={5}
value={settings.rewindSkipTime} appendValue={t("home.settings.media_controls.seconds_unit")}
disabled={pluginSettings?.rewindSkipTime?.locked} min={0}
step={5} max={60}
appendValue={t("home.settings.media_controls.seconds_unit")} onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
min={0} />
max={60}
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
/>
</ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,14 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
import { Stepper } from "@/components/inputs/Stepper"; import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings"; import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [ const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
{ key: "home.settings.buffer.cache_auto", value: "auto" }, { key: "home.settings.buffer.cache_auto", value: "auto" },
@@ -45,56 +41,43 @@ export const MpvBufferSettings: React.FC = () => {
if (!settings) return null; if (!settings) return null;
return ( return (
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'> <ListGroup title={t("home.settings.buffer.title")}>
<ListItem title={t("home.settings.buffer.cache_mode")}> <SettingsSelectRow
<PlatformDropdown title={t("home.settings.buffer.cache_mode")}
groups={cacheModeOptions} valueLabel={currentCacheModeLabel}
trigger={ groups={cacheModeOptions}
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> dropdownTitle={t("home.settings.buffer.cache_mode")}
<Text className='mr-1 text-[#8E8D91]'> />
{currentCacheModeLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.buffer.cache_mode")}
/>
</ListItem>
<ListItem title={t("home.settings.buffer.buffer_duration")}> <SettingsStepperRow
<Stepper title={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10} value={settings.mpvCacheSeconds ?? 10}
step={5} step={5}
min={5} min={5}
max={120} max={120}
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })} onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
appendValue='s' appendValue='s'
/> />
</ListItem>
<ListItem title={t("home.settings.buffer.max_cache_size")}> <SettingsStepperRow
<Stepper title={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150} value={settings.mpvDemuxerMaxBytes ?? 150}
step={25} step={25}
min={50} min={50}
max={500} max={500}
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })} onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
appendValue=' MB' appendValue=' MB'
/> />
</ListItem>
<ListItem title={t("home.settings.buffer.max_backward_cache")}> <SettingsStepperRow
<Stepper title={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50} value={settings.mpvDemuxerMaxBackBytes ?? 50}
step={25} step={25}
min={25} min={25}
max={200} max={200}
onUpdate={(value) => onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
updateSettings({ mpvDemuxerMaxBackBytes: value }) appendValue=' MB'
} />
appendValue=' MB'
/>
</ListItem>
</ListGroup> </ListGroup>
); );
}; };

View File

@@ -1,13 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings"; import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [ const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" }, { key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
@@ -46,21 +43,13 @@ export const MpvVoSettings: React.FC = () => {
if (!settings) return null; if (!settings) return null;
return ( return (
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'> <ListGroup title={t("home.settings.vo_driver.title")}>
<ListItem title={t("home.settings.vo_driver.vo_mode")}> <SettingsSelectRow
<PlatformDropdown title={t("home.settings.vo_driver.vo_mode")}
groups={voDriverOptions} valueLabel={currentVoDriverLabel}
trigger={ groups={voDriverOptions}
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> dropdownTitle={t("home.settings.vo_driver.vo_mode")}
<Text className='mr-1 text-[#8E8D91]'> />
{currentVoDriverLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.vo_driver.vo_mode")}
/>
</ListItem>
</ListGroup> </ListGroup>
); );
}; };

View File

@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
} }
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}> <ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
>
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}
trigger={ trigger={

View File

@@ -1,18 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => { export const PlaybackControlsSettings: React.FC = () => {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
@@ -116,138 +113,77 @@ export const PlaybackControlsSettings: React.FC = () => {
return ( return (
<DisabledSetting disabled={disabled}> <DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''> <ListGroup title={t("home.settings.other.other_title")} className=''>
<ListItem <SettingsSelectRow
title={t("home.settings.other.video_orientation")} title={t("home.settings.other.video_orientation")}
disabled={pluginSettings?.defaultVideoOrientation?.locked} disabled={pluginSettings?.defaultVideoOrientation?.locked}
> valueLabel={
<PlatformDropdown t(
groups={orientationOptions} orientationTranslations[
trigger={ settings.defaultVideoOrientation as keyof typeof orientationTranslations
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> ],
<Text className='mr-1 text-[#8E8D91]'> ) || "Unknown Orientation"
{t( }
orientationTranslations[ groups={orientationOptions}
settings.defaultVideoOrientation as keyof typeof orientationTranslations dropdownTitle={t("home.settings.other.orientation")}
], />
) || "Unknown Orientation"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.orientation")}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.other.safe_area_in_controls")} title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
> value={settings.safeAreaInControlsEnabled}
<Switch onValueChange={(value) =>
value={settings.safeAreaInControlsEnabled} updateSettings({ safeAreaInControlsEnabled: value })
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} }
onValueChange={(value) => />
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem <SettingsSelectRow
title={t("home.settings.other.default_quality")} title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked} disabled={pluginSettings?.defaultBitrate?.locked}
> valueLabel={settings.defaultBitrate?.key}
<PlatformDropdown groups={bitrateOptions}
groups={bitrateOptions} dropdownTitle={t("home.settings.other.default_quality")}
trigger={ />
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
<Text className='mr-1 text-[#8E8D91]'>
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.default_quality")}
/>
</ListItem>
<ListItem <SettingsSelectRow
title={t("home.settings.other.default_playback_speed")} title={t("home.settings.other.default_playback_speed")}
disabled={pluginSettings?.defaultPlaybackSpeed?.locked} disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
> valueLabel={
<PlatformDropdown PLAYBACK_SPEEDS.find(
groups={playbackSpeedOptions} (s) => s.value === settings.defaultPlaybackSpeed,
trigger={ )?.label ?? "1x"
<View className='flex flex-row items-center justify-between pl-3 py-1.5'> }
<Text className='mr-1 text-[#8E8D91]'> groups={playbackSpeedOptions}
{PLAYBACK_SPEEDS.find( dropdownTitle={t("home.settings.other.default_playback_speed")}
(s) => s.value === settings.defaultPlaybackSpeed, />
)?.label ?? "1x"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.default_playback_speed")}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.other.disable_haptic_feedback")} title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
> value={settings.disableHapticFeedback}
<Switch onValueChange={(disableHapticFeedback) =>
value={settings.disableHapticFeedback} updateSettings({ disableHapticFeedback })
disabled={pluginSettings?.disableHapticFeedback?.locked} }
onValueChange={(disableHapticFeedback) => />
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
<ListItem <SettingsSwitchRow
title={t("home.settings.other.auto_play_next_episode")} title={t("home.settings.other.auto_play_next_episode")}
disabled={pluginSettings?.autoPlayNextEpisode?.locked} disabled={pluginSettings?.autoPlayNextEpisode?.locked}
> value={settings.autoPlayNextEpisode}
<Switch onValueChange={(autoPlayNextEpisode) =>
value={settings.autoPlayNextEpisode} updateSettings({ autoPlayNextEpisode })
disabled={pluginSettings?.autoPlayNextEpisode?.locked} }
onValueChange={(autoPlayNextEpisode) => />
updateSettings({ autoPlayNextEpisode })
}
/>
</ListItem>
<ListItem <SettingsSelectRow
title={t("home.settings.other.max_auto_play_episode_count")} title={t("home.settings.other.max_auto_play_episode_count")}
disabled={!settings.autoPlayNextEpisode} disabled={
> !settings.autoPlayNextEpisode ||
<PlatformDropdown pluginSettings?.maxAutoPlayEpisodeCount?.locked
groups={autoPlayEpisodeOptions} }
trigger={ valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> groups={autoPlayEpisodeOptions}
<Text className='mr-1 text-[#8E8D91]'> dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
{t(settings?.maxAutoPlayEpisodeCount.key)} />
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,54 +1,57 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import {
import { useCallback, useMemo, useRef, useState } from "react"; forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, View } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button"; import { Button } from "../Button";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput"; import { PinInput } from "../inputs/PinInput";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {} export type QuickConnectSheetRef = { present: () => void };
export const QuickConnect: React.FC<Props> = ({ ...props }) => { export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
const isTv = Platform.isTV; (_props, ref) => {
const [api] = useAtom(apiAtom); const isTv = Platform.isTV;
const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>(); const [user] = useAtom(userAtom);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const [quickConnectCode, setQuickConnectCode] = useState<string>();
const successHapticFeedback = useHaptic("success"); const modalRef = useRef<BottomSheetMethods>(null);
const errorHapticFeedback = useHaptic("error"); const successHapticFeedback = useHaptic("success");
const snapPoints = useMemo( const errorHapticFeedback = useHaptic("error");
() => (Platform.OS === "android" ? ["100%"] : ["40%"]), const snapPoints = useMemo(
[], () => (Platform.OS === "android" ? ["100%"] : ["40%"]),
); [],
const isAndroid = Platform.OS === "android"; );
const isAndroid = Platform.OS === "android";
const { t } = useTranslation();
const { t } = useTranslation(); useImperativeHandle(
ref,
() => ({
present: () => {
setQuickConnectCode("");
modalRef.current?.present();
},
}),
[],
);
const renderBackdrop = useCallback( const authorizeQuickConnect = useCallback(async () => {
(props: BottomSheetBackdropProps) => ( if (!quickConnectCode) return;
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try { try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({ const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: quickConnectCode, code: quickConnectCode,
@@ -61,7 +64,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
t("home.settings.quick_connect.quick_connect_autorized"), t("home.settings.quick_connect.quick_connect_autorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); modalRef.current?.close();
} else { } else {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert( Alert.alert(
@@ -76,39 +79,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
t("home.settings.quick_connect.invalid_code"), t("home.settings.quick_connect.invalid_code"),
); );
} }
} }, [
}, [api, user, quickConnectCode]); api,
user,
quickConnectCode,
t,
successHapticFeedback,
errorHapticFeedback,
]);
if (isTv) return null; if (isTv) return null;
return (
<View {...props}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem
onPress={() => {
// Reset the code when opening the sheet
setQuickConnectCode("");
bottomSheetModalRef?.current?.present();
}}
title={t("home.settings.quick_connect.authorize_button")}
textColor='blue'
/>
</ListGroup>
return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={modalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
handleIndicatorStyle={{ handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundColor: "white", backgroundStyle={{ backgroundColor: "#171717" }}
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"} keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore' keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -142,6 +132,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>
</View> );
); },
}; );
QuickConnectSheet.displayName = "QuickConnectSheet";

View File

@@ -0,0 +1,81 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import type React from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
onPress,
}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const connected = Boolean(api && user);
const imageUrl =
api && user?.Id
? (getUserImageUrl({
serverAddress: api.basePath,
userId: user.Id,
primaryImageTag: user.PrimaryImageTag,
}) ?? undefined)
: undefined;
const host = api?.basePath?.replace(/^https?:\/\//, "");
return (
<TouchableOpacity
onPress={onPress}
className='mx-3 mb-4 rounded-2xl overflow-hidden'
>
<LinearGradient
colors={["#241b33", "#15151a"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View className='flex-row items-center p-4'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: 52, height: 52, borderRadius: 26 }}
/>
) : (
<LinearGradient
colors={["#a855f7", "#6d28d9"]}
style={{
width: 52,
height: 52,
borderRadius: 26,
alignItems: "center",
justifyContent: "center",
}}
>
<Text className='text-white text-[22px] font-bold'>
{(user?.Name?.[0] ?? "?").toUpperCase()}
</Text>
</LinearGradient>
)}
<View className='flex-1 ml-3'>
<Text
className='text-white text-[18px] font-bold'
numberOfLines={1}
>
{user?.Name ?? ""}
</Text>
<View className='flex-row items-center mt-0.5'>
<View
className='w-2 h-2 rounded-full mr-1.5'
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
/>
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
{host}
</Text>
</View>
</View>
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
</View>
</LinearGradient>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,62 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
export interface SettingsRowProps {
title: string;
icon: keyof typeof Ionicons.glyphMap;
value?: string;
showChevron?: boolean;
onPress: () => void;
isLast?: boolean;
}
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
export const SettingsRow: React.FC<SettingsRowProps> = ({
title,
icon,
value,
showChevron = true,
onPress,
isLast = false,
}) => {
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
return (
<TouchableOpacity
onPress={() => {
haptic();
onPress();
}}
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
isLast ? "" : "border-b border-[#ffffff14]"
}`}
>
<View
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
style={{ backgroundColor: `${ACCENT}29` }}
>
<Ionicons name={icon} size={17} color='#c79bff' />
</View>
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
{title}
</Text>
{value ? (
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
{value}
</Text>
) : null}
{showChevron ? (
<Ionicons
name='chevron-forward'
size={17}
color='#5A5960'
style={{ marginLeft: 4 }}
/>
) : null}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,28 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import type React from "react";
import { TextInput, TouchableOpacity, View } from "react-native";
export const SettingsSearchBar: React.FC<{
value: string;
onChange: (v: string) => void;
}> = ({ value, onChange }) => (
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
<Ionicons name='search' size={16} color='#76767c' />
<TextInput
value={value}
onChangeText={onChange}
placeholder={t("home.settings.search_placeholder")}
placeholderTextColor='#76767c'
className='flex-1 ml-2 text-white text-[15px]'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='search'
/>
{value.length > 0 ? (
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
<Ionicons name='close-circle' size={18} color='#76767c' />
</TouchableOpacity>
) : null}
</View>
);

View File

@@ -0,0 +1,19 @@
import type React from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export const SettingsSection: React.FC<{
title?: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<View className='mb-5'>
{title ? (
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
{title}
</Text>
) : null}
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
{children}
</View>
</View>
);

View File

@@ -0,0 +1,38 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
interface Props {
title: string;
valueLabel?: string;
groups: OptionGroup[];
dropdownTitle?: string;
disabled?: boolean;
}
export const SettingsSelectRow: React.FC<Props> = ({
title,
valueLabel,
groups,
dropdownTitle,
disabled,
}) => (
<ListItem title={title} disabled={disabled}>
<PlatformDropdown
groups={groups}
title={dropdownTitle}
trigger={
<View className='flex flex-row items-center justify-between pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
/>
</ListItem>
);

View File

@@ -0,0 +1,39 @@
import type React from "react";
import { Stepper } from "@/components/inputs/Stepper";
import { ListItem } from "@/components/list/ListItem";
interface Props {
title: string;
subtitle?: string;
value: number;
step: number;
min: number;
max: number;
onUpdate: (value: number) => void;
appendValue?: string;
disabled?: boolean;
}
export const SettingsStepperRow: React.FC<Props> = ({
title,
subtitle,
value,
step,
min,
max,
onUpdate,
appendValue,
disabled,
}) => (
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
<Stepper
value={value}
step={step}
min={min}
max={max}
onUpdate={onUpdate}
appendValue={appendValue}
disabled={disabled}
/>
</ListItem>
);

View File

@@ -0,0 +1,23 @@
import type React from "react";
import { Switch } from "react-native";
import { ListItem } from "@/components/list/ListItem";
interface Props {
title: string;
subtitle?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}
export const SettingsSwitchRow: React.FC<Props> = ({
title,
subtitle,
value,
onValueChange,
disabled,
}) => (
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
</ListItem>
);

View File

@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { matchesQuery, normalize } from "./searchFilter";
test("normalize strips accents and lowercases", () => {
expect(normalize("Légèreté")).toBe("legerete");
expect(normalize(" AUDIO ")).toBe("audio");
});
test("matchesQuery matches title case/accent-insensitively", () => {
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
true,
);
expect(
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
).toBe(true);
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
});
test("matchesQuery returns true for empty query", () => {
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
});

View File

@@ -0,0 +1,14 @@
export const normalize = (s: string): string =>
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
export interface Searchable {
title: string;
keywords?: string[];
}
export const matchesQuery = (item: Searchable, query: string): boolean => {
const q = normalize(query);
if (!q) return true;
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
return hay.includes(q);
};

View File

@@ -0,0 +1,125 @@
import type { Ionicons } from "@expo/vector-icons";
export type SettingsTarget =
| { type: "route"; route: string }
| { type: "action"; action: "quickConnect" };
export interface SettingsEntry {
id: string;
titleKey: string;
icon: keyof typeof Ionicons.glyphMap;
target: SettingsTarget;
/** extra search terms (English); the title is always searched too */
keywords?: string[];
/** when set, entry only shows on these platforms */
platforms?: ("ios" | "android")[];
}
export interface SettingsSectionDef {
id: string;
titleKey: string;
entries: SettingsEntry[];
}
/**
* Single source of truth for the Settings index: drives both rendering and search.
*
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
* entry to the right section (and, if it lives inside a sub-page, an entry in
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
*/
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
{
id: "playback",
titleKey: "home.settings.categories.playback",
entries: [
{
id: "playback-controls",
titleKey: "home.settings.playback_controls.title",
icon: "play",
target: { type: "route", route: "/settings/playback-controls/page" },
keywords: ["speed", "skip", "autoplay", "orientation"],
},
{
id: "audio-subtitles",
titleKey: "home.settings.audio_subtitles.title",
icon: "chatbox-ellipses",
target: { type: "route", route: "/settings/audio-subtitles/page" },
keywords: ["subtitle", "audio", "language"],
},
{
id: "music",
titleKey: "home.settings.music.title",
icon: "musical-notes",
target: { type: "route", route: "/settings/music/page" },
},
],
},
{
id: "personalization",
titleKey: "home.settings.categories.personalization",
entries: [
{
id: "appearance",
titleKey: "home.settings.appearance.title",
icon: "color-palette",
target: { type: "route", route: "/settings/appearance/page" },
},
{
id: "notifications",
titleKey: "home.settings.notifications.title",
icon: "notifications",
target: { type: "route", route: "/settings/notifications/page" },
},
],
},
{
id: "advanced",
titleKey: "home.settings.categories.advanced",
entries: [
{
id: "quick-connect",
titleKey: "home.settings.quick_connect.quick_connect_title",
icon: "key",
target: { type: "action", action: "quickConnect" },
},
{
id: "pair",
titleKey: "pairing.pair_with_phone",
icon: "phone-portrait",
target: {
type: "route",
route: "/(auth)/(tabs)/(home)/companion-login",
},
platforms: ["android"],
},
{
id: "plugins",
titleKey: "home.settings.plugins.plugins_title",
icon: "extension-puzzle",
target: { type: "route", route: "/settings/plugins/page" },
},
{
id: "network",
titleKey: "home.settings.network.title",
icon: "wifi",
target: { type: "route", route: "/settings/network/page" },
},
{
id: "logs",
titleKey: "home.settings.logs.logs_title",
icon: "document-text",
target: { type: "route", route: "/settings/logs/page" },
},
{
id: "intro",
titleKey: "home.settings.intro.title",
icon: "information-circle",
target: { type: "route", route: "/settings/intro/page" },
},
],
},
];

View File

@@ -0,0 +1,266 @@
export interface SearchableOption {
titleKey: string;
parentRoute: string;
parentTitleKey: string;
keywords?: string[];
platforms?: ("ios" | "android")[];
}
/**
* Internal options of sub-pages, for deep search ("index + internal settings").
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
*/
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
// --- Playback & Controls -------------------------------------------------
// MediaToggles
{
titleKey: "home.settings.media_controls.forward_skip_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["skip", "forward", "seconds"],
},
{
titleKey: "home.settings.media_controls.rewind_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["rewind", "back", "seconds"],
},
// GestureControls
{
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "skip", "seek"],
},
{
titleKey: "home.settings.gesture_controls.left_side_brightness",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "brightness", "left"],
},
{
titleKey: "home.settings.gesture_controls.right_side_volume",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "volume", "right"],
},
{
titleKey: "home.settings.gesture_controls.hide_volume_slider",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["volume", "slider", "hide", "gesture"],
},
{
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["brightness", "slider", "hide", "gesture"],
},
// PlaybackControlsSettings (home.settings.other.*)
{
titleKey: "home.settings.other.video_orientation",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["orientation", "rotate", "landscape", "portrait"],
},
{
titleKey: "home.settings.other.safe_area_in_controls",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["safe", "area", "notch", "controls", "inset"],
},
{
titleKey: "home.settings.other.default_quality",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["quality", "bitrate", "resolution"],
},
{
titleKey: "home.settings.other.default_playback_speed",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["playback", "speed", "rate"],
},
{
titleKey: "home.settings.other.disable_haptic_feedback",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["haptic", "vibration", "feedback"],
},
{
titleKey: "home.settings.other.auto_play_next_episode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["autoplay", "auto", "next", "episode"],
},
{
titleKey: "home.settings.other.max_auto_play_episode_count",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["autoplay", "auto", "episode", "count", "limit"],
},
// MpvBufferSettings
{
titleKey: "home.settings.buffer.cache_mode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["cache", "buffer", "mode"],
},
{
titleKey: "home.settings.buffer.buffer_duration",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["buffer", "duration", "cache", "seconds"],
},
{
titleKey: "home.settings.buffer.max_cache_size",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["buffer", "cache", "size", "memory"],
},
// MpvVoSettings (Android only)
{
titleKey: "home.settings.vo_driver.vo_mode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["video", "output", "driver", "gpu", "mpv"],
platforms: ["android"],
},
// --- Audio & Subtitles ---------------------------------------------------
// AudioToggles
{
titleKey: "home.settings.audio.set_audio_track",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "track", "remember", "previous"],
},
{
titleKey: "home.settings.audio.audio_language",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "language", "default"],
},
{
titleKey: "home.settings.audio.transcode_mode.title",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
},
// SubtitleToggles
{
titleKey: "home.settings.subtitles.subtitle_language",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "language", "default"],
},
{
titleKey: "home.settings.subtitles.subtitle_mode",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "mode", "forced"],
},
{
titleKey: "home.settings.subtitles.set_subtitle_track",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "track", "remember", "previous"],
},
{
titleKey: "home.settings.subtitles.subtitle_size",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "size", "caption", "scale", "font"],
},
// --- Music ---------------------------------------------------------------
{
titleKey: "home.settings.music.prefer_downloaded",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "downloaded", "offline", "local"],
},
{
titleKey: "home.settings.music.lookahead_enabled",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "lookahead", "cache", "prefetch"],
},
{
titleKey: "home.settings.music.lookahead_count",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "lookahead", "cache", "count", "tracks"],
},
{
titleKey: "home.settings.music.max_cache_size",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "cache", "size", "storage"],
},
{
titleKey: "home.settings.storage.clear_music_cache",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "cache", "clear", "storage"],
},
{
titleKey: "home.settings.storage.delete_all_downloaded_songs",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "downloaded", "delete", "songs", "storage"],
},
// --- Appearance ----------------------------------------------------------
{
titleKey: "home.settings.other.show_custom_menu_links",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["menu", "links", "custom", "navigation"],
},
{
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["continue", "watching", "next", "up", "merge", "home"],
},
{
titleKey: "home.settings.other.hide_libraries",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["hide", "libraries", "library", "home"],
},
{
titleKey: "home.settings.appearance.hide_remote_session_button",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["remote", "session", "button", "hide", "cast"],
},
// --- Network -------------------------------------------------------------
{
titleKey: "home.settings.network.remote_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["remote", "url", "server", "address"],
},
{
titleKey: "home.settings.network.active_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["active", "url", "server", "connection"],
},
{
titleKey: "home.settings.network.auto_switch_enabled",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["auto", "switch", "local", "wifi", "network"],
},
{
titleKey: "home.settings.network.local_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["local", "url", "lan", "server", "address"],
},
];

View File

@@ -0,0 +1,46 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import { useMemo } from "react";
import { Platform } from "react-native";
import { matchesQuery } from "./searchFilter";
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
export interface SearchResult {
id: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
subtitle?: string;
target: SettingsTarget;
}
export const useSettingsSearch = (query: string): SearchResult[] => {
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
return useMemo(() => {
if (!query.trim()) return [];
const results: SearchResult[] = [];
for (const section of SETTINGS_CATALOG) {
for (const e of section.entries) {
if (e.platforms && !e.platforms.includes(os)) continue;
const title = t(e.titleKey);
if (matchesQuery({ title, keywords: e.keywords }, query)) {
results.push({ id: e.id, title, icon: e.icon, target: e.target });
}
}
}
for (const o of SETTINGS_SEARCH_INDEX) {
if (o.platforms && !o.platforms.includes(os)) continue;
const title = t(o.titleKey);
if (matchesQuery({ title, keywords: o.keywords }, query)) {
results.push({
id: `${o.parentRoute}#${o.titleKey}`,
title,
icon: "search",
subtitle: t(o.parentTitleKey),
target: { type: "route", route: o.parentRoute },
});
}
}
return results;
}, [query, os]);
};

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,9 +75,6 @@ interface BottomControlsProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
// Chapter props
chapterPositions?: number[];
} }
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterPositions = [],
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false); const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers. // Only expose chapter UI when there are at least two real markers.
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, left: insets.left,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, bottom: Math.max(insets.bottom - 17, 0),
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
}, },
]} ]}
className={"flex flex-col px-2"} className={"flex flex-col px-2"}
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual} onPress={handleNextEpisodeManual}
/> />
)} )}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View> </View>
</View> </View>
<View <View

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max} maximumValue={max}
thumbWidth={0} thumbWidth={0}
onValueChange={handleValueChange} onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{ containerStyle={{
borderRadius: 50, borderRadius: 50,
}} }}
theme={{ theme={{
minimumTrackTintColor: "#FDFDFD", minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A", maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}} }}
/> />
<Ionicons <Ionicons

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter, goToNextChapter,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
return ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
top: "50%", top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, right: insets.right,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",

View File

@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
hasNextChapter, hasNextChapter,
goToPreviousChapter, goToPreviousChapter,
goToNextChapter, goToNextChapter,
chapterPositions,
} = useChapterNavigation({ } = useChapterNavigation({
chapters: item.Chapters, chapters: item.Chapters,
progress, progress,
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
{ applyLanguagePreferences: true }, { applyLanguagePreferences: true },
); );
const queryParams = new URLSearchParams({ // Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }), ...(offline && { offline: "true" }),
itemId: item.Id ?? "", itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(), bitrateValue: bitrateValue?.toString(),
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "", item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); });
router.replace(`player/direct-player?${queryParams}` as any);
}, },
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], [
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
); );
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/> />
</Animated.View> </Animated.View>
</> </>

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at")} {getFinishTime()} {t("player.ends_at", { time: getFinishTime() })}
</Text> </Text>
</View> </View>
)} )}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at")} {getFinishTime()} {t("player.ends_at", { time: getFinishTime() })}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
getDownloadedEpisodesForSeason, getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers, getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { settings } = useSettings(); const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
} }
}, []); }, []);
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot. The snapshot only refreshes on the
// provider refreshKey, so after updateDownloadedItem() invalidates
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop: paddingTop: insets.top,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, paddingLeft: insets.left,
paddingLeft: paddingRight: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View <View

View File

@@ -5,12 +5,11 @@ import type {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation(); const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, top: insets.top,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING, padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants"; import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20, left: Math.max(insets.left, 48) + 20,
} }
: { : {
top: top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
(settings?.safeAreaInControlsEnabled ?? true) left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
}; };
const textStyle = Platform.isTV const textStyle = Platform.isTV

View File

@@ -1,4 +1,5 @@
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime, currentTime,
remainingTime, remainingTime,
}) => { }) => {
const { t } = useTranslation();
const getFinishTime = () => { const getFinishTime = () => {
const now = new Date(); const now = new Date();
// remainingTime is in ms // remainingTime is in ms
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
-{formatTimeString(remainingTime, "ms")} -{formatTimeString(remainingTime, "ms")}
</Text> </Text>
<Text className='text-[10px] text-neutral-500 opacity-70'> <Text className='text-[10px] text-neutral-500 opacity-70'>
ends at {getFinishTime()} {t("player.ends_at", { time: getFinishTime() })}
</Text> </Text>
</View> </View>
</View> </View>

View File

@@ -1,6 +1,7 @@
{ {
"cli": { "cli": {
"version": ">= 9.1.0" "version": ">= 16.0.0",
"appVersionSource": "remote"
}, },
"build": { "build": {
"development": { "development": {
@@ -51,44 +52,70 @@
} }
}, },
"production": { "production": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"channel": "0.54.0", "autoIncrement": true,
"android": { "android": {
"image": "latest" "image": "latest",
"config": "android-production.yml"
},
"ios": {
"config": "ios-production.yml"
} }
}, },
"production-apk": { "production-apk": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"channel": "0.54.0", "autoIncrement": true,
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest",
"config": "android-production-apk.yml"
} }
}, },
"production-apk-tv": { "production-apk-tv": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"channel": "0.54.0", "autoIncrement": true,
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest",
"config": "android-production-tv.yml"
}, },
"env": { "env": {
"EXPO_TV": "1" "EXPO_TV": "1"
} }
}, },
"production_tv": { "production_tv": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"channel": "0.54.0", "autoIncrement": true,
"env": { "env": {
"EXPO_TV": "1" "EXPO_TV": "1"
}, },
"ios": { "ios": {
"credentialsSource": "local" "credentialsSource": "local",
"config": "ios-production.yml"
} }
} }
}, },
"submit": { "submit": {
"production": {}, "production": {
"production_tv": {} "ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
} }
} }

View File

@@ -0,0 +1,18 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -1,3 +1,4 @@
import { File, Paths } from "expo-file-system";
import { useCallback } from "react"; import { useCallback } from "react";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -12,36 +13,28 @@ const useImageStorage = () => {
} }
}, []); }, []);
/**
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
* resolves to an empty payload under RN's New Architecture.
*/
const image2Base64 = useCallback(async (url?: string | null) => { const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null; if (!url) return null;
let blob: Blob; const tmpFile = new File(
Paths.cache,
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
);
try { try {
// Fetch the data from the URL const downloaded = await File.downloadFileAsync(url, tmpFile, {
const response = await fetch(url); idempotent: true,
blob = await response.blob(); });
return await downloaded.base64();
} catch (error) { } catch (error) {
console.warn("Error fetching image:", error); console.warn("Error fetching image:", error);
return null; return null;
} finally {
if (tmpFile.exists) tmpFile.delete();
} }
// Create a FileReader instance
const reader = new FileReader();
// Convert blob to base64
return new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Extract the base64 string (remove the data URL prefix)
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to convert image to base64"));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}, []); }, []);
const saveImage = useCallback( const saveImage = useCallback(

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0, staleTime: 0,
}); });
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
const previousItem = useMemo(() => { const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) { if (!adjacentItems || currentIndex <= 0) return null;
return null; const candidate = adjacentItems[currentIndex - 1];
} return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
/** The next item in the series */ /** The next item in the series */
const nextItem = useMemo(() => { const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) { if (!adjacentItems || currentIndex < 0) return null;
return null; const candidate = adjacentItems[currentIndex + 1];
} return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
/** /**
* Reports playback progress. * Reports playback progress.

View File

@@ -1,46 +1,20 @@
plugins { apply plugin: 'expo-module-gradle-plugin'
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader' group = 'expo.modules.backgrounddownloader'
version = '1.0.0' version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") expoModule {
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' canBePublished false
}
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android { android {
namespace "expo.modules.backgrounddownloader" namespace "expo.modules.backgrounddownloader"
defaultConfig {
compileOptions { versionCode 1
sourceCompatibility JavaVersion.VERSION_17 versionName "1.0.0"
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
} }
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:4.12.0" implementation "com.squareup.okhttp3:okhttp:4.12.0"
} }
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
/** /**
* Attach surface and re-enable video output. * Attach surface and ensure video output is active.
* Based on Findroid's implementation. *
* During PiP transitions, the surface is destroyed and recreated by Android.
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
* resumes immediately when the new surface is attached — avoiding the black
* screen that occurs when the VO is fully re-initialized via setOptionString.
*/ */
fun attachSurface(surface: Surface) { fun attachSurface(surface: Surface) {
this.surface = surface this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) { if (isRunning) {
MPVLib.attachSurface(surface) MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes") MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", voDriver) // Read back vo to confirm it's still active
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)") val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
/** /**
* Detach surface and disable video output. * Detach surface without killing the VO pipeline.
* Based on Findroid's implementation. *
* The previous approach (vo=null / force-window=no) destroyed the entire video
* output pipeline on every surface transition. During PiP mode, the rapid
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
* did not properly re-initialize rendering into the new PiP surface.
*
* By keeping the VO alive, frames are simply dropped while no surface is
* attached, and rendering resumes immediately when the new surface arrives.
*/ */
fun detachSurface() { fun detachSurface() {
this.surface = null this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) { if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface() MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
} }
} }
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun updateSurfaceSize(width: Int, height: Int) { fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) { if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height") MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "Surface size updated: ${width}x$height") Log.i(TAG, "[PiP] updateSurfaceSize ${width}x${height}")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
}
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
} }
} }
@@ -692,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike. // dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next // This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection. // episode without a manual re-selection.
if (initialAudioId != null && initialAudioId > 0) { initialAudioId?.let { if (it > 0) setAudioTrack(it) }
setAudioTrack(initialAudioId)
}
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) { if (!isReadyToSeek) {

View File

@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
} }
} }
} }

View File

@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.TextureView
import android.view.SurfaceView import android.view.View
import android.widget.FrameLayout import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * MpvPlayerView - ExpoView that hosts the MPV player.
* This mirrors the iOS MpvPlayerView implementation. * Uses TextureView for reliable Picture-in-Picture support.
*/ */
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback { MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
} }
// Event dispatchers // Event dispatchers
val onLoad by EventDispatcher() val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher() val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher() val onProgress by EventDispatcher()
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null private var pipController: PiPController? = null
private var currentUrl: String? = null private var currentUrl: String? = null
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// Create SurfaceView for video rendering // Create TextureView for video rendering (composites into app window for PiP support)
surfaceView = SurfaceView(context).apply { textureView = TextureView(context).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = ViewGroup.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
holder.addCallback(this@MpvPlayerView) surfaceTextureListener = this@MpvPlayerView
} }
addView(surfaceView) addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) { override fun onSeekBy(seconds: Double) {
seekBy(seconds) seekBy(seconds)
} }
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
} }
// Renderer is created lazily in loadVideo once we have the voDriver setting // Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try { try {
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface -> pendingSurface?.let { surface ->
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}")) onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
} }
} }
// MARK: - SurfaceHolder.Callback // MARK: - TextureView.SurfaceTextureListener
override fun surfaceCreated(holder: SurfaceHolder) { override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface created") this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
renderer?.attachSurface(holder.surface) renderer?.attachSurface(surface)
} else { } else {
// Renderer not started yet - store surface to attach after start pendingSurface = surface
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
} }
// If we have a pending load, execute it now // If we have a pending load, execute it now
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pendingConfig = null pendingConfig = null
} }
} }
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}") surfaceTexture.setDefaultBufferSize(width, height)
// Update MPV with the new surface size (Findroid approach)
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
Log.i(TAG, "Surface destroyed") this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
} }
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading // MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) { fun loadVideo(config: VideoLoadConfig) {
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
loadVideoInternal(config) loadVideoInternal(config)
} }
private fun loadVideoInternal(config: VideoLoadConfig) { private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url currentUrl = config.url
renderer?.load( renderer?.load(
url = config.url, url = config.url,
headers = config.headers, headers = config.headers,
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
initialSubtitleId = config.initialSubtitleId, initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId initialAudioId = config.initialAudioId
) )
if (config.autoplay) { if (config.autoplay) {
play() play()
} }
onLoad(mapOf("url" to config.url)) onLoad(mapOf("url" to config.url))
} }
// Convenience method for simple loads // Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) { fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers)) loadVideo(VideoLoadConfig(url = url, headers = headers))
} }
// MARK: - Playback Controls // MARK: - Playback Controls
fun play() { fun play() {
intendedPlayState = true intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
} }
fun pause() { fun pause() {
intendedPlayState = false intendedPlayState = false
renderer?.pause() renderer?.pause()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
} }
fun seekTo(position: Double) { fun seekTo(position: Double) {
renderer?.seekTo(position) renderer?.seekTo(position)
} }
fun seekBy(offset: Double) { fun seekBy(offset: Double) {
renderer?.seekBy(offset) renderer?.seekBy(offset)
} }
fun setSpeed(speed: Double) { fun setSpeed(speed: Double) {
renderer?.setSpeed(speed) renderer?.setSpeed(speed)
} }
fun getSpeed(): Double { fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0 return renderer?.getSpeed() ?: 1.0
} }
fun isPaused(): Boolean { fun isPaused(): Boolean {
return renderer?.isPausedState ?: true return renderer?.isPausedState ?: true
} }
fun getCurrentPosition(): Double { fun getCurrentPosition(): Double {
return cachedPosition return cachedPosition
} }
fun getDuration(): Double { fun getDuration(): Double {
return cachedDuration return cachedDuration
} }
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called") isWaitingForPiPTransition = true
pipController?.startPictureInPicture() pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
} }
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
fun isPictureInPictureSupported(): Boolean { fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false return pipController?.isPictureInPictureSupported() ?: false
} }
fun isPictureInPictureActive(): Boolean { fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false return pipController?.isPictureInPictureActive() ?: false
} }
// MARK: - Subtitle Controls // MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> { fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList() return renderer?.getSubtitleTracks() ?: emptyList()
} }
fun setSubtitleTrack(trackId: Int) { fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId) renderer?.setSubtitleTrack(trackId)
} }
fun disableSubtitles() { fun disableSubtitles() {
renderer?.disableSubtitles() renderer?.disableSubtitles()
} }
fun getCurrentSubtitleTrack(): Int { fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0 return renderer?.getCurrentSubtitleTrack() ?: 0
} }
fun addSubtitleFile(url: String, select: Boolean = true) { fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select) renderer?.addSubtitleFile(url, select)
} }
// MARK: - Subtitle Positioning // MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) { fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position) renderer?.setSubtitlePosition(position)
} }
fun setSubtitleScale(scale: Double) { fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale) renderer?.setSubtitleScale(scale)
} }
fun setSubtitleMarginY(margin: Int) { fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin) renderer?.setSubtitleMarginY(margin)
} }
fun setSubtitleAlignX(alignment: String) { fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment) renderer?.setSubtitleAlignX(alignment)
} }
fun setSubtitleAlignY(alignment: String) { fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment) renderer?.setSubtitleAlignY(alignment)
} }
fun setSubtitleFontSize(size: Int) { fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size) renderer?.setSubtitleFontSize(size)
} }
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// MARK: - Audio Track Controls // MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> { fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList() return renderer?.getAudioTracks() ?: emptyList()
} }
fun setAudioTrack(trackId: Int) { fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId) renderer?.setAudioTrack(trackId)
} }
fun getCurrentAudioTrack(): Int { fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0 return renderer?.getCurrentAudioTrack() ?: 0
} }
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
// Update PiP progress // Update PiP progress
if (pipController?.isPictureInPictureActive() == true) { if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration) pipController?.setCurrentTime(position, duration)
} }
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"cacheSeconds" to cacheSeconds "cacheSeconds" to cacheSeconds
)) ))
} }
override fun onPauseChanged(isPaused: Boolean) { override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isPaused" to isPaused, "isPaused" to isPaused,
"isPlaying" to !isPaused "isPlaying" to !isPaused
)) ))
} }
override fun onLoadingChanged(isLoading: Boolean) { override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isLoading" to isLoading "isLoading" to isLoading
)) ))
} }
override fun onReadyToSeek() { override fun onReadyToSeek() {
onPlaybackStateChange(mapOf( onPlaybackStateChange(mapOf(
"isReadyToSeek" to true "isReadyToSeek" to true
)) ))
} }
override fun onTracksReady() { override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>()) onTracksReady(emptyMap<String, Any>())
} }
override fun onVideoDimensionsChanged(width: Int, height: Int) { override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height) pipController?.setVideoDimensions(width, height)
} }
override fun onError(message: String) { override fun onError(message: String) {
onError(mapOf("error" to message)) onError(mapOf("error" to message))
} }
// MARK: - Cleanup // MARK: - Cleanup
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
surfaceView.holder.removeCallback(this) surfaceTexture = null
surfaceReady = false
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
cleanup() cleanup()

View File

@@ -1,51 +1,62 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.app.Activity import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.View import android.view.View
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) { class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object { companion object {
private const val TAG = "PiPController" private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16 private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9 private const val DEFAULT_ASPECT_HEIGHT = 9
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
} }
interface Delegate { interface Delegate {
fun onPlay() fun onPlay()
fun onPause() fun onPause()
fun onSeekBy(seconds: Double) fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
} }
var delegate: Delegate? = null var delegate: Delegate? = null
private var currentPosition: Double = 0.0 private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null private var playerView: View? = null
/** // PiP state tracking
* Check if Picture-in-Picture is supported on this device private var isInPiPMode: Boolean = false
*/ private var pipEntryNotified: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private var pipBroadcastReceiver: BroadcastReceiver? = null
fun isPictureInPictureSupported(): Boolean { fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
false false
} }
} }
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean { fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
return false return false
} }
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() { fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val activity = getActivity()
if (activity == null) { val activity = getActivity() ?: run {
Log.e(TAG, "Cannot start PiP: no activity found") Log.e(TAG, "Cannot start PiP: no activity")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
val result = activity.enterPictureInPictureMode(params)
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return return
} }
if (!isPictureInPictureSupported()) { isInPiPMode = true
Log.e(TAG, "PiP not supported on this device") pipEntryNotified = true
return delegate?.onPictureInPictureModeChanged(true)
} registerLifecycleCallbacks()
} catch (e: Exception) {
try { Log.e(TAG, "Failed to enter PiP: ${e.message}")
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
} }
} }
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() { fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user isInPiPMode = false
// or by finishing the activity. We can request to move task to back. pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) { if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false) activity.moveTaskToBack(false)
} }
} }
} }
/** fun isCurrentlyInPiP(): Boolean = isInPiPMode
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun setCurrentTime(position: Double, duration: Double) { fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position currentPosition = position
currentDuration = duration currentDuration = duration
} }
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
// Update PiP params to reflect play/pause state if (rate > 0) {
registerLifecycleCallbacks()
}
// Update PiP params so autoEnterEnabled and action icons track play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) { if (activity != null) {
try { try {
activity.setPictureInPictureParams(buildPiPParams()) activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) { } catch (e: Exception) {
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
} }
} }
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) { fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
videoWidth = width videoWidth = width
videoHeight = height videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded() updatePiPParamsIfNeeded()
} }
} }
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) { fun setPlayerView(view: View?) {
playerView = view playerView = view
} }
private fun updatePiPParamsIfNeeded() { private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity()
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
} }
} }
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView val view = playerView
val viewWidth = view?.width ?: 0 val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0 val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid) // Video aspect ratio with 2.39:1 clamping
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational( Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else { } else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
} }
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) { val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) { if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect( Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
} else { } else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect( Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
} }
} else { } else {
null null
} }
val builder = PictureInPictureParams.Builder() val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio) .setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) } sourceRectHint?.let { builder.setSourceRectHint(it) }
// On Android 12+, enable auto-enter (like Findroid) ensurePiPReceiverRegistered()
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true) builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
} }
return builder.build() return builder.build()
} }
private fun getActivity(): Activity? { private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it } appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context var ctx = context
while (ctx is android.content.ContextWrapper) { while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { if (ctx is Activity) return ctx
return ctx
}
ctx = ctx.baseContext ctx = ctx.baseContext
} }
return null return null
} }
/** // MARK: - Lifecycle-based PiP Detection
* Handle PiP action (called from activity when user taps PiP controls)
*/ private fun registerLifecycleCallbacks() {
fun handlePiPAction(action: String) { if (lifecycleRegistered) return
when (action) {
"play" -> delegate?.onPlay() val app = context.applicationContext as? Application ?: run {
"pause" -> delegate?.onPause() Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
"skip_forward" -> delegate?.onSeekBy(10.0) startFallbackPolling()
"skip_backward" -> delegate?.onSeekBy(-10.0) return
} }
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
if (!isInPiPMode) return
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}
override fun onActivityPaused(activity: Activity) {
// Proactively hide controls when user leaves while playing,
// before the PiP window captures the UI. onActivityStopped
// will restore if PiP didn't actually enter.
if (playbackRate > 0 && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
}
}
override fun onActivityStopped(activity: Activity) {
pipHandler.postDelayed({
val inPip = activity.isInPictureInPictureMode
if (inPip && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
return@postDelayed
}
if (!isInPiPMode) return@postDelayed
if (inPip) return@postDelayed
// Not in PiP after 1s — check again to avoid false positive during transition
pipHandler.postDelayed({
if (!isInPiPMode) return@postDelayed
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}, 1500)
}, 1000)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
isInPiPMode = false
}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
if (!lifecycleRegistered) return
lifecycleCallbacks?.let {
(context.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(it)
}
lifecycleCallbacks = null
lifecycleRegistered = false
pipHandler.removeCallbacksAndMessages(null)
unregisterPiPBroadcastReceiver()
}
private fun startFallbackPolling() {
var falseReadCount = 0
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed(object : Runnable {
override fun run() {
if (!isInPiPMode) return
var ctx = context
var activity: Activity? = null
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { activity = ctx; break }
ctx = ctx.baseContext
}
val stillInPip = activity?.isInPictureInPictureMode == true
if (!stillInPip) {
falseReadCount++
if (falseReadCount >= 3) {
isInPiPMode = false
delegate?.onPictureInPictureModeChanged(false)
return
}
pipHandler.postDelayed(this, 500)
return
}
falseReadCount = 0
pipHandler.postDelayed(this, 1000)
}
}, 3000)
}
// MARK: - PiP Remote Actions
private fun ensurePiPReceiverRegistered() {
if (pipBroadcastReceiver != null) return
pipBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_PLAY_PAUSE -> {
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
}
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PIP_PLAY_PAUSE)
addAction(ACTION_PIP_SKIP_FORWARD)
addAction(ACTION_PIP_SKIP_BACKWARD)
}
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
}
private fun unregisterPiPBroadcastReceiver() {
pipBroadcastReceiver?.let {
try {
context.applicationContext.unregisterReceiver(it)
} catch (_: Exception) {}
}
pipBroadcastReceiver = null
}
private fun buildPiPActions(): List<RemoteAction> {
val isPlaying = playbackRate > 0
return listOf(
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
"Rewind", "Skip backward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
),
RemoteAction(
Icon.createWithResource(
context,
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
),
if (isPlaying) "Pause" else "Play",
if (isPlaying) "Pause playback" else "Resume playback",
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
),
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
"Fast Forward", "Skip forward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
)
)
}
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
android.app.PendingIntent.FLAG_IMMUTABLE
} else {
0
}
return android.app.PendingIntent.getBroadcast(
context.applicationContext, 0, Intent(action), flags
)
} }
} }

View File

@@ -1,32 +1,19 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'MpvPlayer' s.name = 'MpvPlayer'
s.version = '1.0.0' s.version = '1.0.0'
s.summary = 'MPVKit for Expo' s.summary = 'MPV-based video player for Streamyfin (Expo module)'
s.description = 'MPVKit for Expo' s.author = 'Streamyfin'
s.author = 'mpvkit' s.homepage = 'https://github.com/streamyfin/streamyfin'
s.homepage = 'https://github.com/mpvkit/MPVKit' s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.platforms = { s.source = { git: '' }
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit-GPL' s.dependency 'MPVKit'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'VALID_ARCHS' => 'arm64', 'SWIFT_COMPILATION_MODE' => 'wholemodule'
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
} }
s.source_files = "*.{h,m,mm,swift,hpp,cpp}" s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -81,7 +81,6 @@ class MpvPlayerView: ExpoView {
private func setupView() { private func setupView() {
clipsToBounds = true clipsToBounds = true
backgroundColor = .black backgroundColor = .black
configureAudioSession()
videoContainer = UIView() videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,21 +140,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func configureAudioSession() { private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
try audioSession.setCategory( try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
.playback, try session.setActive(true)
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch { } catch {
print("Failed to configure audio session: \(error)") print("Failed to configure audio session: \(error)")
} }
} }
// MARK: - Audio Session & Notifications
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
private func setupNotifications() { private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio) // Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -270,6 +274,7 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
configureAudioSession()
setupRemoteCommands() setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -440,6 +445,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo() clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }
@@ -519,9 +525,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused()) syncNowPlaying(isPlaying: !isPaused())
} }
} }

View File

@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>; export type OnTracksReadyEventPayload = Record<string, never>;
export type OnPictureInPictureChangePayload = {
isActive: boolean;
};
export type NowPlayingMetadata = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void; onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void; onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void; onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload;
}) => void;
}; };
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {

View File

@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> = const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("MpvPlayer"); requireNativeView("MpvPlayer");
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>( export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function MpvPlayerView(props, ref) { function MpvPlayerView(props, ref) {
const nativeRef = useRef<any>(null); const nativeRef = useRef<any>(null);
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
return await nativeRef.current?.getDuration(); return await nativeRef.current?.getDuration();
}, },
startPictureInPicture: async () => { startPictureInPicture: async () => {
console.log(PIP_LOG, "startPictureInPicture → native");
await nativeRef.current?.startPictureInPicture(); await nativeRef.current?.startPictureInPicture();
console.log(PIP_LOG, "startPictureInPicture ← native returned");
}, },
stopPictureInPicture: async () => { stopPictureInPicture: async () => {
console.log(PIP_LOG, "stopPictureInPicture → native");
await nativeRef.current?.stopPictureInPicture(); await nativeRef.current?.stopPictureInPicture();
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
}, },
isPictureInPictureSupported: async () => { isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported(); const result = await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
}, },
isPictureInPictureActive: async () => { isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive(); const result = await nativeRef.current?.isPictureInPictureActive();
console.log(PIP_LOG, "isPictureInPictureActive =", result);
return result;
}, },
getSubtitleTracks: async () => { getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks(); return await nativeRef.current?.getSubtitleTracks();

View File

@@ -1,11 +1,13 @@
import { useTranslation } from "react-i18next";
import { MpvPlayerViewProps } from "./MpvPlayer.types"; import { MpvPlayerViewProps } from "./MpvPlayer.types";
export default function MpvPlayerView(props: MpvPlayerViewProps) { export default function MpvPlayerView(props: MpvPlayerViewProps) {
const url = props.source?.url ?? ""; const url = props.source?.url ?? "";
const { t } = useTranslation();
return ( return (
<div> <div>
<iframe <iframe
title='MPV Player' title={t("player.mpv_player_title")}
style={{ flex: 1 }} style={{ flex: 1 }}
src={url} src={url}
onLoad={() => props.onLoad?.({ nativeEvent: { url } })} onLoad={() => props.onLoad?.({ nativeEvent: { url } })}

View File

@@ -32,9 +32,10 @@
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14", "@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5", "@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14", "@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -51,6 +52,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",
@@ -104,6 +106,7 @@
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "4.25.2",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM", "react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
@@ -122,8 +125,10 @@
"@biomejs/biome": "2.4.16", "@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3", "@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6", "@react-native-tvos/config-tv": "0.1.6",
"@types/bun": "^1.3.14",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/node": "^24",
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
@@ -162,10 +167,5 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"unrs-resolver" "unrs-resolver"
], ]
"patchedDependencies": {
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
}
} }

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7 diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644 index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift --- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift +++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI @@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate self.delegate = delegate
} }
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *) @available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) { public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none" var placementValue = "none"
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644 index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift --- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift +++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier { @@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
} }
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
} }
#endif #endif
+ +
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644 index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift --- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift +++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View { @@ -281,7 +281,7 @@ extension View {
@ViewBuilder @ViewBuilder
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) { if #available(iOS 26.0, macOS 26.0, *) {
if let behavior { if let behavior {
self.tabBarMinimizeBehavior(behavior.convert()) self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644 index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift --- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift +++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String { @@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp case onScrollUp
case onScrollDown case onScrollDown

View File

@@ -1,7 +1,7 @@
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644 index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift --- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift +++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView { @@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType); return rootView.recursivelyFindSubview(whereType: targetType);
}; };

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91 diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644 index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec --- a/node_modules/react-native-udp/react-native-udp.podspec
+++ b/react-native-udp.podspec +++ b/node_modules/react-native-udp/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s| @@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"] s.homepage = package_json["homepage"]
s.license = package_json["license"] s.license = package_json["license"]

View File

@@ -39,6 +39,28 @@ function buildPatch() {
" end", " end",
" end", " end",
"", "",
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
" # files, so the pods-only flag above is not enough — the app's link still fails with",
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
" if ENV['EXPO_TV'] != '1'",
" installer.aggregate_targets.each do |agg|",
" next unless agg.user_project",
" agg.user_project.native_targets.each do |target|",
" target.build_configurations.each do |cfg|",
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
" existing = existing.join(' ') if existing.is_a?(Array)",
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
" end",
" end",
" end",
" agg.user_project.save",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components", " # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"', ' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)", " if File.exist?(filepath)",

View File

@@ -4,9 +4,16 @@ const { withEntitlementsPlist } = require("expo/config-plugins");
* Expo config plugin to add User Management entitlement for tvOS profile linking * Expo config plugin to add User Management entitlement for tvOS profile linking
*/ */
const withTVUserManagement = (config) => { const withTVUserManagement = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in
// profile"). The entitlement is only needed for tvOS
// TVUserManager.currentUserIdentifier.
if (process.env.EXPO_TV !== "1") {
return config;
}
return withEntitlementsPlist(config, (config) => { return withEntitlementsPlist(config, (config) => {
// Only add for tvOS builds (check if building for TV)
// The entitlement is needed for TVUserManager.currentUserIdentifier to work
config.modResults["com.apple.developer.user-management"] = [ config.modResults["com.apple.developer.user-management"] = [
"runs-as-current-user", "runs-as-current-user",
]; ];

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/** /**
* Get the downloads database from storage * Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/ */
export function getDownloadsDatabase(): DownloadsDatabase { export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY); const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) { if (file) {
return JSON.parse(file) as DownloadsDatabase; cachedDb = JSON.parse(file) as DownloadsDatabase;
return cachedDb;
} }
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
} }
/** /**
* Save the downloads database to storage * Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/ */
export function saveDownloadsDatabase(db: DownloadsDatabase): void { export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
} }
/** /**
* Get all downloaded items as a flat array * Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/ */
export function getAllDownloadedItems(): DownloadedItem[] { export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
const items: DownloadedItem[] = []; const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
} }
} }
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items; return items;
} }
/** /**
* Get a downloaded item by its ID * Build or refresh the item index for O(1) lookups
*/ */
export function getDownloadedItemById(id: string): DownloadedItem | undefined { function ensureItemIndex(): void {
const db = getDownloadsDatabase(); if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
if (db.movies[id]) {
return db.movies[id];
} }
for (const series of Object.values(db.series)) { // Build new index from all items
for (const season of Object.values(series.seasons)) { itemIndex = new Map<string, DownloadedItem>();
for (const episode of Object.values(season.episodes)) { const items = getAllDownloadedItems();
if (episode.item.Id === id) {
return episode; for (const item of items) {
} if (item.item.Id) {
} itemIndex.set(item.item.Id, item);
} }
} }
if (db.other?.[id]) { indexCacheVersion = cacheVersion;
return db.other[id]; }
}
return undefined; /**
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
} }
/** /**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/ */
export function clearAllDownloadedItems(): void { export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
} }

View File

@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type * as NotificationsType from "expo-notifications"; import type * as NotificationsType from "expo-notifications";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { storage } from "@/utils/mmkv";
// Conditionally import expo-notifications only on non-TV platforms // Conditionally import expo-notifications only on non-TV platforms
const Notifications = Platform.isTV const Notifications = Platform.isTV
@@ -67,6 +68,14 @@ export async function sendDownloadNotification(
): Promise<void> { ): Promise<void> {
if (Platform.isTV || !Notifications) return; if (Platform.isTV || !Notifications) return;
try {
const raw = storage.getString("settings");
const s = raw ? JSON.parse(raw) : {};
if (s.notificationsEnabled === false || s.notifyDownloads === false) return;
} catch {
// ignore parse errors; fall through to sending (defaults are enabled)
}
try { try {
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {

View File

@@ -53,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({ const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" }, clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => { const initialUser = (() => {
try { try {
// Only return a stored user if we also have a token. Otherwise the
// user atom would be populated while the api atom is null (e.g. after
// a logout that left stale user JSON in storage), which causes
// useProtectedRoute to keep us inside the (auth) group instead of
// redirecting to /login.
const token = storage.getString("token");
if (!token) return null;
const userStr = storage.getString("user"); const userStr = storage.getString("user");
if (userStr) { if (userStr) {
return JSON.parse(userStr) as UserDto; return JSON.parse(userStr) as UserDto;
@@ -128,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
return new Jellyfin({ return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" }, clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -162,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.0"`, }, DeviceId="${deviceId}", Version="0.54.1"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
); );
storage.remove("token"); storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely(); clearTVDiscoverySafely();
setUser(null); setUser(null);
setApi(null); setApi(null);

View File

@@ -29,6 +29,10 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@@ -4,6 +4,9 @@
"error_title": "خطأ", "error_title": "خطأ",
"login_title": "تسجيل الدخول", "login_title": "تسجيل الدخول",
"login_to_title": "تسجيل الدخول إلى", "login_to_title": "تسجيل الدخول إلى",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "اسم المستخدم", "username_placeholder": "اسم المستخدم",
"password_placeholder": "كلمة المرور", "password_placeholder": "كلمة المرور",
"login_button": "تسجيل الدخول", "login_button": "تسجيل الدخول",
@@ -30,48 +33,54 @@
"connect_button": "اتصل", "connect_button": "اتصل",
"previous_servers": "الخوادم السابقة", "previous_servers": "الخوادم السابقة",
"clear_button": "مسح", "clear_button": "مسح",
"swipe_to_remove": "Swipe to remove", "swipe_to_remove": "مرر للإزالة",
"search_for_local_servers": "البحث عن الخوادم المحلية", "search_for_local_servers": "البحث عن الخوادم المحلية",
"searching": "جاري البحث...", "searching": "جاري البحث...",
"servers": "الخوادم", "servers": "الخوادم",
"saved": "Saved", "saved": "تم الحفظ",
"session_expired": "Session Expired", "session_expired": "انتهت الجلسة",
"please_login_again": "Your saved session has expired. Please log in again.", "please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
"remove_saved_login": "Remove Saved Login", "remove_saved_login": "إزالة تسجيل دخول محفوظ",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", "remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
"accounts_count": "{{count}} accounts", "accounts_count": "الحسابات {{count}}",
"select_account": "Select Account", "select_account": "اختر الحساب",
"add_account": "Add Account", "add_account": "إضافة حساب",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "حفظ الحساب",
"save_for_later": "Save this account", "save_for_later": "حفظ هذا الحساب",
"security_option": "Security Option", "security_option": "‮خيارات الأمان",
"no_protection": "No protection", "no_protection": "بدون حماية",
"no_protection_desc": "Quick login without authentication", "no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
"pin_code": "PIN code", "pin_code": "رمز PIN",
"pin_code_desc": "4-digit PIN required when switching", "pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
"password": "Re-enter password", "password": "أعد إدخال كلمة المرور",
"password_desc": "Password required when switching", "password_desc": "كلمة المرور مطلوبة عند التبديل",
"save_button": "Save", "save_button": "حفظ",
"cancel_button": "Cancel" "cancel_button": "إلغاء"
}, },
"pin": { "pin": {
"enter_pin": "Enter PIN", "enter_pin": "‏أدخل رمز PIN",
"enter_pin_for": "Enter PIN for {{username}}", "enter_pin_for": "أدخل رمز PIN لـ {{username}}",
"enter_4_digits": "Enter 4 digits", "enter_4_digits": "ادخل 4 أرقام",
"invalid_pin": "Invalid PIN", "invalid_pin": "PIN غير صالح",
"setup_pin": "Set Up PIN", "setup_pin": "تعيين رمز PIN",
"confirm_pin": "Confirm PIN", "confirm_pin": "تأكيد رمز PIN",
"pins_dont_match": "PINs don't match", "pins_dont_match": "رموز PIN غير متطابقة",
"forgot_pin": "Forgot PIN?", "forgot_pin": "نسيت رمز PIN؟",
"forgot_pin_desc": "Your saved credentials will be removed" "forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
}, },
"password": { "password": {
"enter_password": "Enter Password", "enter_password": "أدخل كلمة المرور",
"enter_password_for": "Enter password for {{username}}", "enter_password_for": "أدخل كلمة المرور لـ {{username}}",
"invalid_password": "Invalid password" "invalid_password": "كلمة المرور غير صحيحة"
}, },
"home": { "home": {
"checking_server_connection": "التحقق من اتصال الخادم...", "checking_server_connection": "التحقق من اتصال الخادم...",
@@ -86,8 +95,9 @@
"oops": "عفوًا!", "oops": "عفوًا!",
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.", "error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
"continue_watching": "متابعة المشاهدة", "continue_watching": "متابعة المشاهدة",
"continue": "Continue",
"next_up": "التالي", "next_up": "التالي",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "تابع و التالي",
"recently_added_in": "أضيف مؤخراً في {{libraryName}}", "recently_added_in": "أضيف مؤخراً في {{libraryName}}",
"suggested_movies": "أفلام مقترحة", "suggested_movies": "أفلام مقترحة",
"suggested_episodes": "حلقات مقترحة", "suggested_episodes": "حلقات مقترحة",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "الإعدادات", "settings_title": "الإعدادات",
"log_out_button": "تسجيل الخروج", "log_out_button": "تسجيل الخروج",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "الأقسام" "title": "الأقسام"
}, },
@@ -120,36 +136,45 @@
}, },
"appearance": { "appearance": {
"title": "المظهر", "title": "المظهر",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "الشبكة",
"local_network": "Local Network", "local_network": "الشبكة المحلية",
"auto_switch_enabled": "Auto-switch when at home", "auto_switch_enabled": "التبديل التلقائي عند المنزل",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi", "auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
"local_url": "Local URL", "local_url": "رابط محلي",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", "local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096", "local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks", "home_wifi_networks": "شبكات WiFi المنزل",
"add_current_network": "Add \"{{ssid}}\"", "add_current_network": "إضافة \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi", "not_connected_to_wifi": "غير متصل بشبكة WiFi",
"no_networks_configured": "No networks configured", "no_networks_configured": "لا توجد شبكات مكونة",
"add_network_hint": "Add your home WiFi network to enable auto-switching", "add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
"current_wifi": "Current WiFi", "current_wifi": "شبكة WiFi الحالية",
"using_url": "Using", "using_url": "استخدام",
"local": "Local URL", "local": "رابط محلي",
"remote": "Remote URL", "remote": "الـ URL الخارجي",
"not_connected": "Not connected", "not_connected": "غير متصل",
"current_server": "Current Server", "current_server": "الخادم الحالي",
"remote_url": "Remote URL", "remote_url": "الـ URL الخارجي",
"active_url": "Active URL", "active_url": "الرابط النشط",
"not_configured": "Not configured", "not_configured": "لم يتم تكوينه",
"network_added": "Network added", "network_added": "تمت إضافة الشبكة",
"network_already_added": "Network already added", "network_already_added": "الشبكة مضافة مسبقاً",
"no_wifi_connected": "Not connected to WiFi", "no_wifi_connected": "غير متصل بشبكة WiFi",
"permission_denied": "Location permission denied", "permission_denied": "تم رفض إذن الوصول إلى الموقع",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings." "permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
}, },
"user_info": { "user_info": {
"user_info_title": "معلومات المستخدم", "user_info_title": "معلومات المستخدم",
@@ -174,6 +199,22 @@
"rewind_length": "مدة الترجيع", "rewind_length": "مدة الترجيع",
"seconds_unit": "ث" "seconds_unit": "ث"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "التحكم بالإيماءات", "gesture_controls_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي", "horizontal_swipe_skip": "السحب الأفقي للتخطي",
@@ -182,10 +223,10 @@
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع", "left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن", "right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت", "right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
"hide_volume_slider": "Hide Volume Slider", "hide_volume_slider": "إخفاء شريط مستوى الصوت",
"hide_volume_slider_description": "Hide the volume slider in the video player", "hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
"hide_brightness_slider": "Hide Brightness Slider", "hide_brightness_slider": "إخفاء شريط السطوع",
"hide_brightness_slider_description": "Hide the brightness slider in the video player" "hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
}, },
"audio": { "audio": {
"audio_title": "الصوت", "audio_title": "الصوت",
@@ -195,12 +236,12 @@
"none": "لا شيء", "none": "لا شيء",
"language": "اللغة", "language": "اللغة",
"transcode_mode": { "transcode_mode": {
"title": "Audio Transcoding", "title": "تحويل ترميز الصوت",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", "description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
"auto": "Auto", "auto": "تلقائي",
"stereo": "Force Stereo", "stereo": "إجبار تشغيل ستيريو",
"5_1": "Allow 5.1", "5_1": "السماح بـ 5.1",
"passthrough": "Passthrough" "passthrough": "تمرير الصوت"
} }
}, },
"subtitles": { "subtitles": {
@@ -251,29 +292,45 @@
"Normal": "عادي", "Normal": "عادي",
"Thick": "سميك" "Thick": "سميك"
}, },
"subtitle_color": "Subtitle Color", "subtitle_color": "لون الترجمة",
"subtitle_background_color": "Background Color", "subtitle_background_color": "لون الخلفية",
"subtitle_font": "Subtitle Font", "subtitle_font": "خط الترجمة",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "إعدادات KSPlayer",
"hardware_decode": "Hardware Decoding", "hardware_decode": "فك الترميز بواسطة الجهاز",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "إعدادات ترجمة VLC",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.", "hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
"text_color": "Text Color", "text_color": "لون النص",
"background_color": "Background Color", "background_color": "لون الخلفية",
"background_opacity": "Background Opacity", "background_opacity": "شفافية الخلفية",
"outline_color": "Outline Color", "outline_color": "لون إطار الخط",
"outline_opacity": "Outline Opacity", "outline_opacity": "شفافية إطار الخط",
"outline_thickness": "Outline Thickness", "outline_thickness": "سمك إطار الخط",
"bold": "Bold Text", "bold": "خط عريض",
"margin": "Bottom Margin" "margin": "الهامش السفلي"
}, },
"video_player": { "video_player": {
"title": "Video Player", "title": "مشغل الفيديو",
"video_player": "Video Player", "video_player": "مشغل الفيديو",
"video_player_description": "Choose which video player to use on iOS.", "video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
"ksplayer": "KSPlayer", "ksplayer": "KSPlayer",
"vlc": "VLC" "vlc": "VLC"
}, },
@@ -305,8 +362,8 @@
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.", "select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية", "disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية", "default_quality": "الجودة الافتراضية",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "سرعة التشغيل الافتراضية",
"auto_play_next_episode": "Auto-play Next Episode", "auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا", "max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
"disabled": "معطل" "disabled": "معطل"
}, },
@@ -314,15 +371,15 @@
"downloads_title": "التنزيلات" "downloads_title": "التنزيلات"
}, },
"music": { "music": {
"title": "Music", "title": "الموسيقى",
"playback_title": "Playback", "playback_title": "التشغيل",
"playback_description": "Configure how music is played.", "playback_description": "ضبط كيفية تشغيل الموسيقى.",
"prefer_downloaded": "Prefer Downloaded Songs", "prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
"caching_title": "Caching", "caching_title": "التخزين المؤقت",
"caching_description": "Automatically cache upcoming tracks for smoother playback.", "caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
"lookahead_enabled": "Enable Look-Ahead Caching", "lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
"lookahead_count": "Tracks to Pre-cache", "lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
"max_cache_size": "Max Cache Size" "max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
}, },
"plugins": { "plugins": {
"plugins_title": "الإضافات", "plugins_title": "الإضافات",
@@ -357,39 +414,39 @@
"save_button": "حفظ", "save_button": "حفظ",
"toasts": { "toasts": {
"saved": "تم الحفظ", "saved": "تم الحفظ",
"refreshed": "Settings refreshed from server" "refreshed": "تم تحديث الإعدادات من الخادم"
}, },
"refresh_from_server": "Refresh Settings from Server" "refresh_from_server": "تحديث الإعدادات من الخادم"
}, },
"streamystats": { "streamystats": {
"enable_streamystats": "Enable Streamystats", "enable_streamystats": "تفعيل Streamystats",
"disable_streamystats": "Disable Streamystats", "disable_streamystats": "تعطيل Streamystats",
"enable_search": "Use for Search", "enable_search": "استخدم للبحث",
"url": "URL", "url": "الرابط",
"server_url_placeholder": "http(s)://streamystats.example.com", "server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", "streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
"read_more_about_streamystats": "Read More About Streamystats.", "read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
"save_button": "Save", "save_button": "حفظ",
"save": "Save", "save": "حفظ",
"features_title": "Features", "features_title": "المميزات",
"home_sections_title": "Home Sections", "home_sections_title": "أقسام الرئيسية",
"enable_movie_recommendations": "Movie Recommendations", "enable_movie_recommendations": "توصيات الأفلام",
"enable_series_recommendations": "Series Recommendations", "enable_series_recommendations": "توصيات المسلسلات",
"enable_promoted_watchlists": "Promoted Watchlists", "enable_promoted_watchlists": "قوائم مشاهدة مختارة",
"hide_watchlists_tab": "Hide Watchlists Tab", "hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.", "home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
"recommended_movies": "Recommended Movies", "recommended_movies": "أفلام موصى بها",
"recommended_series": "Recommended Series", "recommended_series": "مسلسلات موصى بها",
"toasts": { "toasts": {
"saved": "Saved", "saved": "تم الحفظ",
"refreshed": "Settings refreshed from server", "refreshed": "تم تحديث الإعدادات من الخادم",
"disabled": "Streamystats disabled" "disabled": "تم تعطيل Streamystats"
}, },
"refresh_from_server": "Refresh Settings from Server" "refresh_from_server": "تحديث الإعدادات من الخادم"
}, },
"kefinTweaks": { "kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration", "watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
"watchlist_button": "Toggle Watchlist integration" "watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
} }
}, },
"storage": { "storage": {
@@ -398,15 +455,21 @@
"device_usage": "الجهاز {{availableSpace}}%", "device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}", "size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها", "delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"music_cache_title": "Music Cache", "music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"enable_music_cache": "Enable Music Cache", "enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "مسح التخزين المؤقت للموسيقى",
"music_cache_size": "{{size}} cached", "music_cache_size": "تم تخزين {{size}} مؤقتاً",
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "تم تنزيل {{size}}",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "المقدمة", "title": "المقدمة",
@@ -430,6 +493,21 @@
"error_deleting_files": "خطأ في حذف الملفات", "error_deleting_files": "خطأ في حذف الملفات",
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية", "background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية" "background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.", "new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
"back": "رجوع", "back": "رجوع",
"delete": "حذف", "delete": "حذف",
"delete_download": "Delete Download",
"something_went_wrong": "حدث خطأ ما", "something_went_wrong": "حدث خطأ ما",
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin", "could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
"eta": "الوقت المتبقي {{eta}}", "eta": "الوقت المتبقي {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "اختر", "select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر", "no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو", "video": "فيديو",
"audio": "الصوت", "audio": "الصوت",
"subtitle": "الترجمة", "subtitle": "الترجمة",
"play": "تشغيل", "play": "تشغيل",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "لا شيء", "none": "لا شيء",
"track": "Track", "track": "أغنية",
"cancel": "Cancel", "cancel": "إلغاء",
"delete": "Delete", "stop": "Stop",
"ok": "OK", "delete": "حذف",
"remove": "Remove", "ok": "حسناً",
"next": "Next", "remove": "إزالة",
"back": "Back", "next": "التالي",
"continue": "Continue", "back": "رجوع",
"verifying": "Verifying..." "continue": "متابعة",
"verifying": "جارٍ التحقق...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "بحث...", "search": "بحث...",
@@ -521,10 +606,10 @@
"episodes": "حلقات", "episodes": "حلقات",
"collections": "مجموعات", "collections": "مجموعات",
"actors": "ممثلون", "actors": "ممثلون",
"artists": "Artists", "artists": "الفنانون",
"albums": "Albums", "albums": "الألبومات",
"songs": "Songs", "songs": "الأغاني",
"playlists": "Playlists", "playlists": "قوائم التشغيل",
"request_movies": "طلب أفلام", "request_movies": "طلب أفلام",
"request_series": "طلب مسلسلات", "request_series": "طلب مسلسلات",
"recently_added": "أضيف مؤخرًا", "recently_added": "أضيف مؤخرًا",
@@ -556,6 +641,7 @@
"movies": "أفلام", "movies": "أفلام",
"series": "مسلسلات", "series": "مسلسلات",
"boxsets": "مجموعات", "boxsets": "مجموعات",
"playlists": "Playlists",
"items": "عناصر" "items": "عناصر"
}, },
"options": { "options": {
@@ -566,15 +652,20 @@
"poster": "ملصق", "poster": "ملصق",
"cover": "غلاف", "cover": "غلاف",
"show_titles": "إظهار العناوين", "show_titles": "إظهار العناوين",
"show_stats": "إظهار الإحصائيات" "show_stats": "إظهار الإحصائيات",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "الأنواع", "genres": "الأنواع",
"years": "السنوات", "years": "السنوات",
"sort_by": "ترتيب حسب", "sort_by": "ترتيب حسب",
"filter_by": "Filter By", "filter_by": "تصفية حسب",
"sort_order": "اتجاه الترتيب", "sort_order": "اتجاه الترتيب",
"tags": "الوسوم" "tags": "الوسوم",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "لا توجد روابط" "no_links": "لا توجد روابط"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "خطأ", "error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث", "failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.", "an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
@@ -604,11 +697,39 @@
"index": "الفِهْرِس:", "index": "الفِهْرِس:",
"continue_watching": "متابعة المشاهدة", "continue_watching": "متابعة المشاهدة",
"go_back": "رجوع", "go_back": "رجوع",
"downloaded_file_title": "You have this file downloaded", "downloaded_file_title": "تم تنزيل هذا الملف",
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟", "downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
"downloaded_file_yes": "نعم", "downloaded_file_yes": "نعم",
"downloaded_file_no": "لا", "downloaded_file_no": "لا",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "إلغاء",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "التالي", "next_up": "التالي",
@@ -617,6 +738,11 @@
"series": "مسلسلات", "series": "مسلسلات",
"seasons": "مواسم", "seasons": "مواسم",
"season": "موسم", "season": "موسم",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم", "no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
"overview": "نظرة عامة", "overview": "نظرة عامة",
"more_with": "المزيد مع {{name}}", "more_with": "المزيد مع {{name}}",
@@ -624,13 +750,24 @@
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة", "no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
"video": "فيديو", "video": "فيديو",
"more_details": "المزيد من التفاصيل", "more_details": "المزيد من التفاصيل",
"media_options": "Media Options", "media_options": "خيارات الوسائط",
"quality": "الجودة", "quality": "الجودة",
"audio": "الصوت", "audio": "الصوت",
"subtitles": "الترجمة", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "عرض المزيد", "show_more": "عرض المزيد",
"show_less": "عرض أقل", "show_less": "عرض أقل",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "ظهر في", "appeared_in": "ظهر في",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "تعذر تحميل العنصر", "could_not_load_item": "تعذر تحميل العنصر",
"none": "لا شيء", "none": "لا شيء",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "تنزيل {{item_count}} عناصر", "download_x_item": "تنزيل {{item_count}} عناصر",
"download_unwatched_only": "غير المشاهدة فقط", "download_unwatched_only": "غير المشاهدة فقط",
"download_button": "تنزيل" "download_button": "تنزيل"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "التالي", "next": "التالي",
@@ -652,7 +795,18 @@
"movies": "أفلام", "movies": "أفلام",
"sports": "رياضة", "sports": "رياضة",
"for_kids": "للأطفال", "for_kids": "للأطفال",
"news": "أخبار" "news": "أخبار",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "تأكيد", "confirm": "تأكيد",
@@ -697,6 +851,12 @@
"decline": "رفض", "decline": "رفض",
"requested_by": "مطلوب من {{user}}", "requested_by": "مطلوب من {{user}}",
"unknown_user": "مستخدم غير معروف", "unknown_user": "مستخدم غير معروف",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل", "jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.", "jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
@@ -716,130 +876,162 @@
"search": "بحث", "search": "بحث",
"library": "المكتبة", "library": "المكتبة",
"custom_links": "روابط مخصصة", "custom_links": "روابط مخصصة",
"favorites": "المفضلة" "favorites": "المفضلة",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "الموسيقى",
"tabs": { "tabs": {
"suggestions": "Suggestions", "suggestions": "الإقتراحات",
"albums": "Albums", "albums": "الألبومات",
"artists": "Artists", "artists": "الفنانون",
"playlists": "Playlists", "playlists": "قوائم التشغيل",
"tracks": "tracks" "tracks": "الأغاني"
}, },
"filters": { "filters": {
"all": "All" "all": "الكل"
}, },
"recently_added": "Recently Added", "recently_added": "أضيف مؤخرًا",
"recently_played": "Recently Played", "recently_played": "تم تشغيله مؤخرًا",
"frequently_played": "Frequently Played", "frequently_played": "الأكثر تشغيلاً",
"explore": "Explore", "explore": "اكتشف",
"top_tracks": "Top Tracks", "top_tracks": "أفضل الأغاني",
"play": "Play", "play": "تشغيل",
"shuffle": "Shuffle", "shuffle": "ترتيب عشوائي",
"play_top_tracks": "Play Top Tracks", "play_top_tracks": "تشغيل أفضل الأغاني",
"no_suggestions": "No suggestions available", "no_suggestions": "لا توجد مقترحات متاحة",
"no_albums": "No albums found", "no_albums": "لا توجد ألبومات",
"no_artists": "No artists found", "no_artists": "لا يوجد فنانون",
"no_playlists": "No playlists found", "no_playlists": "لا توجد قوائم تشغيل",
"album_not_found": "Album not found", "album_not_found": "الألبوم غير موجود",
"artist_not_found": "Artist not found", "artist_not_found": "الفنان غير موجود",
"playlist_not_found": "Playlist not found", "playlist_not_found": "قائمة التشغيل غير موجودة",
"track_options": { "track_options": {
"play_next": "Play Next", "play_next": "تشغيل التالي",
"add_to_queue": "Add to Queue", "add_to_queue": "إضافة إلى قائمة الانتظار",
"add_to_playlist": "Add to Playlist", "add_to_playlist": "أضف إلى قائمة التشغيل",
"download": "Download", "download": "تنزيل",
"downloaded": "Downloaded", "downloaded": "تم التنزيل",
"downloading": "Downloading...", "downloading": "جارٍ التنزيل...",
"cached": "Cached", "cached": "تم التخزين مؤقتاً",
"delete_download": "Delete Download", "delete_download": "حذف ملف التنزيل",
"delete_cache": "Remove from Cache", "delete_cache": "إزالة من التخزين المؤقت",
"go_to_artist": "Go to Artist", "go_to_artist": "انتقال إلى الفنان",
"go_to_album": "Go to Album", "go_to_album": "انتقال إلى الألبوم",
"add_to_favorites": "Add to Favorites", "add_to_favorites": "إضافة إلى المفضلة",
"remove_from_favorites": "Remove from Favorites", "remove_from_favorites": "إزالة من المفضلة",
"remove_from_playlist": "Remove from Playlist" "remove_from_playlist": "إزالة من قائمة التشغيل"
}, },
"playlists": { "playlists": {
"create_playlist": "Create Playlist", "create_playlist": "إنشاء قائمة التشغيل",
"playlist_name": "Playlist Name", "playlist_name": "اسم قائمة التشغيل",
"enter_name": "Enter playlist name", "enter_name": "أدخل اسم قائمة التشغيل",
"create": "Create", "create": "إنشاء",
"search_playlists": "Search playlists...", "search_playlists": "البحث عن قوائم التشغيل...",
"added_to": "Added to {{name}}", "added_to": "تمت الإضافة إلى {{name}}",
"added": "Added to playlist", "added": "تمت الإضافة إلى قائمة التشغيل",
"removed_from": "Removed from {{name}}", "removed_from": "تمت الإزالة من {{name}}",
"removed": "Removed from playlist", "removed": "تمت الازالة من قائمة التشغيل",
"created": "Playlist created", "created": "تم إنشاء قائمة التشغيل",
"create_new": "Create New Playlist", "create_new": "إنشاء قائمة تشغيل جديدة",
"failed_to_add": "Failed to add to playlist", "failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
"failed_to_remove": "Failed to remove from playlist", "failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
"failed_to_create": "Failed to create playlist", "failed_to_create": "فشل إنشاء قائمة التشغيل",
"delete_playlist": "Delete Playlist", "delete_playlist": "حذف قائمة التشغيل",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", "delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
"deleted": "Playlist deleted", "deleted": "تم حذف قائمة التشغيل",
"failed_to_delete": "Failed to delete playlist" "failed_to_delete": "فشل إنشاء قائمة التشغيل"
}, },
"sort": { "sort": {
"title": "Sort By", "title": "ترتيب حسب",
"alphabetical": "Alphabetical", "alphabetical": "أبجدي",
"date_created": "Date Created" "date_created": "تاريخ الإنشاء"
} }
}, },
"watchlists": { "watchlists": {
"title": "Watchlists", "title": "قوائم المشاهدة",
"my_watchlists": "My Watchlists", "my_watchlists": "قوائم المشاهدة الخاصة بي",
"public_watchlists": "Public Watchlists", "public_watchlists": "قوائم مشاهدة عامة",
"create_title": "Create Watchlist", "create_title": "إنشاء قائمة مشاهدة",
"edit_title": "Edit Watchlist", "edit_title": "تعديل قائمة المشاهدة",
"create_button": "Create Watchlist", "create_button": "إنشاء قائمة مشاهدة",
"save_button": "Save Changes", "save_button": "حفظ التغييرات",
"delete_button": "Delete", "delete_button": "حذف",
"remove_button": "Remove", "remove_button": "إزالة",
"cancel_button": "Cancel", "cancel_button": "إلغاء",
"name_label": "Name", "name_label": "الاسم",
"name_placeholder": "Enter watchlist name", "name_placeholder": "أدخل اسم قائمة المشاهدة",
"description_label": "Description", "description_label": "الوصف",
"description_placeholder": "Enter description (optional)", "description_placeholder": "أدخل الوصف (اختياري)",
"is_public_label": "Public Watchlist", "is_public_label": "قائمة مشاهدة عامة",
"is_public_description": "Allow others to view this watchlist", "is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
"allowed_type_label": "Content Type", "allowed_type_label": "نوع المحتوى",
"sort_order_label": "Default Sort Order", "sort_order_label": "الترتيب الافتراضي",
"empty_title": "No Watchlists", "empty_title": "لا توجد قوائم مشاهدة",
"empty_description": "Create your first watchlist to start organizing your media", "empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
"empty_watchlist": "This watchlist is empty", "empty_watchlist": "قائمة المشاهدة هذه فارغة",
"empty_watchlist_hint": "Add items from your library to this watchlist", "empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
"not_configured_title": "Streamystats Not Configured", "not_configured_title": "لم يتم ضبط Streamystats",
"not_configured_description": "Configure Streamystats in settings to use watchlists", "not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
"go_to_settings": "Go to Settings", "go_to_settings": "الذهاب إلى الإعدادات",
"add_to_watchlist": "Add to Watchlist", "add_to_watchlist": "إضافة إلى قائمة المشاهدة",
"remove_from_watchlist": "Remove from Watchlist", "remove_from_watchlist": "إزالة من قائمة المشاهدة",
"select_watchlist": "Select Watchlist", "select_watchlist": "تحديد قائمة المشاهدة",
"create_new": "Create New Watchlist", "create_new": "إنشاء قائمة مشاهدة جديدة",
"item": "item", "item": "عنصر",
"items": "items", "items": "عناصر",
"public": "Public", "public": "عامة",
"private": "Private", "private": "خاصة",
"you": "You", "you": "أنت",
"by_owner": "By another user", "by_owner": "بواسطة مستخدم آخر",
"not_found": "Watchlist not found", "not_found": "قائمة المشاهدة غير موجودة",
"delete_confirm_title": "Delete Watchlist", "delete_confirm_title": "حذف قائمة المشاهدة",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", "delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"remove_item_title": "Remove from Watchlist", "remove_item_title": "إزالة من قائمة المشاهدة",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?", "remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
"loading": "Loading watchlists...", "loading": "تحميل قوائم المشاهدة...",
"no_compatible_watchlists": "No compatible watchlists", "no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
"create_one_first": "Create a watchlist that accepts this content type" "create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
}, },
"playback_speed": { "playback_speed": {
"title": "Playback Speed", "title": "سرعة التشغيل",
"apply_to": "Apply To", "apply_to": "تطبيق على",
"speed": "Speed", "speed": "السرعة",
"scope": { "scope": {
"media": "This media only", "media": "الوسائط هذه فقط",
"show": "This show", "show": "هذا المسلسل",
"all": "All media (default)" "all": "جميع الوسائط (الافتراضي)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Error", "error_title": "Error",
"login_title": "Inicia sessió", "login_title": "Inicia sessió",
"login_to_title": "Inicia sessió a", "login_to_title": "Inicia sessió a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'usuari", "username_placeholder": "Nom d'usuari",
"password_placeholder": "Contrasenya", "password_placeholder": "Contrasenya",
"login_button": "Inicia sessió", "login_button": "Inicia sessió",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Oops!", "oops": "Oops!",
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.", "error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
"continue_watching": "Continua veient", "continue_watching": "Continua veient",
"continue": "Continue",
"next_up": "A continuació", "next_up": "A continuació",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Afegit recentment a {{libraryName}}", "recently_added_in": "Afegit recentment a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Configuració", "settings_title": "Configuració",
"log_out_button": "Tanca sessió", "log_out_button": "Tanca sessió",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "Categories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Durada del rebobinat", "rewind_length": "Durada del rebobinat",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Gesture Controls", "gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip", "horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error en suprimir fitxers", "error_deleting_files": "Error en suprimir fitxers",
"background_downloads_enabled": "Descàrregues en segon pla activades", "background_downloads_enabled": "Descàrregues en segon pla activades",
"background_downloads_disabled": "Descàrregues en segon pla desactivades" "background_downloads_disabled": "Descàrregues en segon pla desactivades"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.", "new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
"back": "Enrere", "back": "Enrere",
"delete": "Suprimeix", "delete": "Suprimeix",
"delete_download": "Delete Download",
"something_went_wrong": "Alguna cosa ha anat malament", "something_went_wrong": "Alguna cosa ha anat malament",
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin", "could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Select", "select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Vídeo", "video": "Vídeo",
"audio": "Àudio", "audio": "Àudio",
"subtitle": "Subtítols", "subtitle": "Subtítols",
"play": "Play", "play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Cerca...", "search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "pel·lícules", "movies": "pel·lícules",
"series": "sèries", "series": "sèries",
"boxsets": "col·leccions", "boxsets": "col·leccions",
"playlists": "Playlists",
"items": "elements" "items": "elements"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Cartell", "poster": "Cartell",
"cover": "Coberta", "cover": "Coberta",
"show_titles": "Mostrar títols", "show_titles": "Mostrar títols",
"show_stats": "Mostrar estadístiques" "show_stats": "Mostrar estadístiques",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Gèneres", "genres": "Gèneres",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar per", "sort_by": "Ordenar per",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Ordre", "sort_order": "Ordre",
"tags": "Etiquetes" "tags": "Etiquetes",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "No hi ha enllaços" "no_links": "No hi ha enllaços"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux", "failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.", "an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "A continuació", "next_up": "A continuació",
@@ -617,6 +738,11 @@
"series": "Sèries", "series": "Sèries",
"seasons": "Temporades", "seasons": "Temporades",
"season": "Temporada", "season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada", "no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
"overview": "Descripció general", "overview": "Descripció general",
"more_with": "Més amb {{name}}", "more_with": "Més amb {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Qualitat", "quality": "Qualitat",
"audio": "Àudio", "audio": "Àudio",
"subtitles": "Subtítols", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra més", "show_more": "Mostra més",
"show_less": "Mostra menys", "show_less": "Mostra menys",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Va aparèixer a", "appeared_in": "Va aparèixer a",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No s'ha pogut carregar l'element", "could_not_load_item": "No s'ha pogut carregar l'element",
"none": "Cap", "none": "Cap",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descarrega {{item_count}} elements", "download_x_item": "Descarrega {{item_count}} elements",
"download_unwatched_only": "Unwatched Only", "download_unwatched_only": "Unwatched Only",
"download_button": "Descarrega" "download_button": "Descarrega"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Següent", "next": "Següent",
@@ -652,7 +795,18 @@
"movies": "Pel·lícules", "movies": "Pel·lícules",
"sports": "Esports", "sports": "Esports",
"for_kids": "Infantil", "for_kids": "Infantil",
"news": "Notícies" "news": "Notícies",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Confirma", "confirm": "Confirma",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0", "jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.", "jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
@@ -716,7 +876,8 @@
"search": "Cercar", "search": "Cercar",
"library": "Biblioteca", "library": "Biblioteca",
"custom_links": "Enllaços personalitzats", "custom_links": "Enllaços personalitzats",
"favorites": "Preferits" "favorites": "Preferits",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Chyba", "error_title": "Chyba",
"login_title": "Přihlásit se", "login_title": "Přihlásit se",
"login_to_title": "Přihlásit se do", "login_to_title": "Přihlásit se do",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Uživatelské jméno", "username_placeholder": "Uživatelské jméno",
"password_placeholder": "Heslo", "password_placeholder": "Heslo",
"login_button": "Přihlásit se", "login_button": "Přihlásit se",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Jejda!", "oops": "Jejda!",
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.", "error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
"continue_watching": "Pokračovat ve sledování", "continue_watching": "Pokračovat ve sledování",
"continue": "Continue",
"next_up": "Další nahoru", "next_up": "Další nahoru",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nedávno přidané v {{libraryName}}", "recently_added_in": "Nedávno přidané v {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Nastavení", "settings_title": "Nastavení",
"log_out_button": "Odhlásit se", "log_out_button": "Odhlásit se",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "Categories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Délka zpětného větru", "rewind_length": "Délka zpětného větru",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Ovládání gest", "gesture_controls_title": "Ovládání gest",
"horizontal_swipe_skip": "Horizontální přejetím přeskočit", "horizontal_swipe_skip": "Horizontální přejetím přeskočit",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Chyba při mazání souborů", "error_deleting_files": "Chyba při mazání souborů",
"background_downloads_enabled": "Stahování na pozadí povoleno", "background_downloads_enabled": "Stahování na pozadí povoleno",
"background_downloads_disabled": "Stahování na pozadí zakázáno" "background_downloads_disabled": "Stahování na pozadí zakázáno"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Nová aktualizace vyžaduje opětovné stažení obsahu. Odstraňte prosím veškerý stažený obsah a zkuste to znovu.", "new_app_version_requires_re_download_description": "Nová aktualizace vyžaduje opětovné stažení obsahu. Odstraňte prosím veškerý stažený obsah a zkuste to znovu.",
"back": "Zpět", "back": "Zpět",
"delete": "Vymazat", "delete": "Vymazat",
"delete_download": "Delete Download",
"something_went_wrong": "Něco se pokazilo", "something_went_wrong": "Něco se pokazilo",
"could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin", "could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Vybrat", "select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici", "no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video", "video": "Video",
"audio": "Zvuk", "audio": "Zvuk",
"subtitle": "Podtitulek", "subtitle": "Podtitulek",
"play": "Hrát", "play": "Hrát",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Hledat...", "search": "Hledat...",
@@ -556,6 +641,7 @@
"movies": "Filmy", "movies": "Filmy",
"series": "Série", "series": "Série",
"boxsets": "Sada boxů", "boxsets": "Sada boxů",
"playlists": "Playlists",
"items": "Položky" "items": "Položky"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Plakát", "poster": "Plakát",
"cover": "Kryt", "cover": "Kryt",
"show_titles": "Zobrazit názvy", "show_titles": "Zobrazit názvy",
"show_stats": "Zobrazit statistiky" "show_stats": "Zobrazit statistiky",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Seřadit podle", "sort_by": "Seřadit podle",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Řazení", "sort_order": "Řazení",
"tags": "Štítky" "tags": "Štítky",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Žádné odkazy" "no_links": "Žádné odkazy"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Chyba", "error": "Chyba",
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu", "failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.", "an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Další nahoru", "next_up": "Další nahoru",
@@ -617,6 +738,11 @@
"series": "Série", "series": "Série",
"seasons": "Série", "seasons": "Série",
"season": "Sezóna", "season": "Sezóna",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Žádné epizody pro tuto sezónu", "no_episodes_for_this_season": "Žádné epizody pro tuto sezónu",
"overview": "Přehled", "overview": "Přehled",
"more_with": "Více s {{name}}", "more_with": "Více s {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Kvalita", "quality": "Kvalita",
"audio": "Zvuk", "audio": "Zvuk",
"subtitles": "Podtitulek", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Zobrazit více", "show_more": "Zobrazit více",
"show_less": "Zobrazit méně", "show_less": "Zobrazit méně",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Zobrazeno v", "appeared_in": "Zobrazeno v",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nelze načíst položku", "could_not_load_item": "Nelze načíst položku",
"none": "Nic", "none": "Nic",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Stáhnout položky {{item_count}}", "download_x_item": "Stáhnout položky {{item_count}}",
"download_unwatched_only": "Pouze nezhlédnuté", "download_unwatched_only": "Pouze nezhlédnuté",
"download_button": "Stáhnout" "download_button": "Stáhnout"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Další", "next": "Další",
@@ -652,7 +795,18 @@
"movies": "Filmy", "movies": "Filmy",
"sports": "Sporty", "sports": "Sporty",
"for_kids": "Pro děti", "for_kids": "Pro děti",
"news": "Novinky" "news": "Novinky",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Potvrdit", "confirm": "Potvrdit",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0", "jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0",
"jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.", "jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.",
@@ -716,7 +876,8 @@
"search": "Hledat", "search": "Hledat",
"library": "Knihovna", "library": "Knihovna",
"custom_links": "Vlastní odkazy", "custom_links": "Vlastní odkazy",
"favorites": "Oblíbené" "favorites": "Oblíbené",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Fejl", "error_title": "Fejl",
"login_title": "Log ind", "login_title": "Log ind",
"login_to_title": "Log ind på", "login_to_title": "Log ind på",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Brugernavn", "username_placeholder": "Brugernavn",
"password_placeholder": "Adgangskode", "password_placeholder": "Adgangskode",
"login_button": "Log ind", "login_button": "Log ind",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!", "oops": "Ups!",
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.", "error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
"continue_watching": "Fortsæt med at se", "continue_watching": "Fortsæt med at se",
"continue": "Continue",
"next_up": "Næste", "next_up": "Næste",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Senest tilføjet i {{libraryName}}", "recently_added_in": "Senest tilføjet i {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Indstillinger", "settings_title": "Indstillinger",
"log_out_button": "Log ud", "log_out_button": "Log ud",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "Categories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Spol tilbage længde", "rewind_length": "Spol tilbage længde",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Bevægelsesstyring", "gesture_controls_title": "Bevægelsesstyring",
"horizontal_swipe_skip": "Vandret Stryg for at springe over", "horizontal_swipe_skip": "Vandret Stryg for at springe over",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fejl ved sletning af filer", "error_deleting_files": "Fejl ved sletning af filer",
"background_downloads_enabled": "Baggrundsdownloads aktiveret", "background_downloads_enabled": "Baggrundsdownloads aktiveret",
"background_downloads_disabled": "Baggrundsdownloads deaktiveret" "background_downloads_disabled": "Baggrundsdownloads deaktiveret"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Den nye opdatering kræver, at indhold downloades igen. Fjern venligst alt downloadet indhold og prøv igen.", "new_app_version_requires_re_download_description": "Den nye opdatering kræver, at indhold downloades igen. Fjern venligst alt downloadet indhold og prøv igen.",
"back": "Tilbage", "back": "Tilbage",
"delete": "Slet", "delete": "Slet",
"delete_download": "Delete Download",
"something_went_wrong": "Noget gik galt", "something_went_wrong": "Noget gik galt",
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin", "could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Vælg", "select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt", "no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
"subtitle": "Undertekster", "subtitle": "Undertekster",
"play": "Afspil", "play": "Afspil",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Søg...", "search": "Søg...",
@@ -556,6 +641,7 @@
"movies": "film", "movies": "film",
"series": "serier", "series": "serier",
"boxsets": "box sæt", "boxsets": "box sæt",
"playlists": "Playlists",
"items": "elementer" "items": "elementer"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Plakat", "poster": "Plakat",
"cover": "Omslag", "cover": "Omslag",
"show_titles": "Vis titler", "show_titles": "Vis titler",
"show_stats": "Vis statistik" "show_stats": "Vis statistik",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genrer", "genres": "Genrer",
@@ -574,7 +661,11 @@
"sort_by": "Sortér efter", "sort_by": "Sortér efter",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Sorteringsrækkefølge", "sort_order": "Sorteringsrækkefølge",
"tags": "Mærker" "tags": "Mærker",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ingen links" "no_links": "Ingen links"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fejl", "error": "Fejl",
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en", "failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.", "an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Næste", "next_up": "Næste",
@@ -617,6 +738,11 @@
"series": "Serier", "series": "Serier",
"seasons": "Sæsoner", "seasons": "Sæsoner",
"season": "Sæson", "season": "Sæson",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ingen episoder for denne sæson", "no_episodes_for_this_season": "Ingen episoder for denne sæson",
"overview": "Oversigt", "overview": "Oversigt",
"more_with": "Mere med {{name}}", "more_with": "Mere med {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Kvalitet", "quality": "Kvalitet",
"audio": "Lyd", "audio": "Lyd",
"subtitles": "Undertekster", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Vis mere", "show_more": "Vis mere",
"show_less": "Vis mindre", "show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Medvirket i", "appeared_in": "Medvirket i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kunne ikke indlæse elementet", "could_not_load_item": "Kunne ikke indlæse elementet",
"none": "Ingen", "none": "Ingen",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} elementer", "download_x_item": "Download {{item_count}} elementer",
"download_unwatched_only": "Kun Usete", "download_unwatched_only": "Kun Usete",
"download_button": "Hent" "download_button": "Hent"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Næste", "next": "Næste",
@@ -652,7 +795,18 @@
"movies": "Film", "movies": "Film",
"sports": "Sport", "sports": "Sport",
"for_kids": "For børn", "for_kids": "For børn",
"news": "Nyheder" "news": "Nyheder",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Bekræft", "confirm": "Bekræft",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0", "jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.", "jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.",
@@ -716,7 +876,8 @@
"search": "Søg", "search": "Søg",
"library": "Bibliotek", "library": "Bibliotek",
"custom_links": "Tilpassede links", "custom_links": "Tilpassede links",
"favorites": "Favoritter" "favorites": "Favoritter",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Fehler", "error_title": "Fehler",
"login_title": "Anmelden", "login_title": "Anmelden",
"login_to_title": "Anmelden bei", "login_to_title": "Anmelden bei",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Benutzername", "username_placeholder": "Benutzername",
"password_placeholder": "Passwort", "password_placeholder": "Passwort",
"login_button": "Anmelden", "login_button": "Anmelden",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} Konten", "accounts_count": "{{count}} Konten",
"select_account": "Konto auswählen", "select_account": "Konto auswählen",
"add_account": "Konto hinzufügen", "add_account": "Konto hinzufügen",
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt." "remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Konto speichern", "title": "Konto speichern",
@@ -86,6 +95,7 @@
"oops": "Ups!", "oops": "Ups!",
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.", "error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
"continue_watching": "Weiterschauen", "continue_watching": "Weiterschauen",
"continue": "Continue",
"next_up": "Als nächstes", "next_up": "Als nächstes",
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"", "continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}", "recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Einstellungen", "settings_title": "Einstellungen",
"log_out_button": "Abmelden", "log_out_button": "Abmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Kategorien" "title": "Kategorien"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Aussehen", "title": "Aussehen",
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren", "merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden" "hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Netzwerk", "title": "Netzwerk",
@@ -174,6 +199,22 @@
"rewind_length": "Rückspullänge", "rewind_length": "Rückspullänge",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Gestensteuerung", "gesture_controls_title": "Gestensteuerung",
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen", "horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
@@ -256,7 +297,23 @@
"subtitle_font": "Untertitel-Schriftart", "subtitle_font": "Untertitel-Schriftart",
"ksplayer_title": "KSPlayer Einstellungen", "ksplayer_title": "KSPlayer Einstellungen",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten." "hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Untertitel-Einstellungen", "title": "VLC Untertitel-Einstellungen",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Musik-Cache geleert", "music_cache_cleared": "Musik-Cache geleert",
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen", "delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
"downloaded_songs_size": "{{size}} heruntergeladen", "downloaded_songs_size": "{{size}} heruntergeladen",
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht" "downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Einführung", "title": "Einführung",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fehler beim Löschen von Dateien", "error_deleting_files": "Fehler beim Löschen von Dateien",
"background_downloads_enabled": "Hintergrunddownloads aktiviert", "background_downloads_enabled": "Hintergrunddownloads aktiviert",
"background_downloads_disabled": "Hintergrunddownloads deaktiviert" "background_downloads_disabled": "Hintergrunddownloads deaktiviert"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.", "new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
"back": "Zurück", "back": "Zurück",
"delete": "Löschen", "delete": "Löschen",
"delete_download": "Download löschen",
"something_went_wrong": "Etwas ist schiefgelaufen", "something_went_wrong": "Etwas ist schiefgelaufen",
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten", "could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Auswählen", "select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar", "no_trailer_available": "Kein Trailer verfügbar",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
"subtitle": "Untertitel", "subtitle": "Untertitel",
"play": "Abspielen", "play": "Abspielen",
"mark_as_played": "Als gesehen markieren",
"mark_as_not_played": "Als ungesehen markieren",
"none": "Keine", "none": "Keine",
"track": "Spur", "track": "Spur",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"stop": "Stop",
"delete": "Löschen", "delete": "Löschen",
"ok": "OK", "ok": "OK",
"remove": "Entfernen", "remove": "Entfernen",
"next": "Weiter", "next": "Weiter",
"back": "Zurück", "back": "Zurück",
"continue": "Fortsetzen", "continue": "Fortsetzen",
"verifying": "Verifiziere..." "verifying": "Verifiziere...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Suchen...", "search": "Suchen...",
@@ -556,6 +641,7 @@
"movies": "Filme", "movies": "Filme",
"series": "Serien", "series": "Serien",
"boxsets": "Boxsets", "boxsets": "Boxsets",
"playlists": "Playlists",
"items": "Elemente" "items": "Elemente"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Poster", "poster": "Poster",
"cover": "Cover", "cover": "Cover",
"show_titles": "Titel anzeigen", "show_titles": "Titel anzeigen",
"show_stats": "Statistiken anzeigen" "show_stats": "Statistiken anzeigen",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sortieren nach", "sort_by": "Sortieren nach",
"filter_by": "Filtern nach", "filter_by": "Filtern nach",
"sort_order": "Sortierreihenfolge", "sort_order": "Sortierreihenfolge",
"tags": "Tags" "tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Keine Links" "no_links": "Keine Links"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fehler", "error": "Fehler",
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL", "failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.", "an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Heruntergeladene Datei abspielen?", "downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja", "downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein", "downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen" "downloaded_file_cancel": "Abbrechen",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Endet um {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Als Nächstes", "next_up": "Als Nächstes",
@@ -617,6 +738,11 @@
"series": "Serien", "series": "Serien",
"seasons": "Staffeln", "seasons": "Staffeln",
"season": "Staffel", "season": "Staffel",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Keine Episoden für diese Staffel", "no_episodes_for_this_season": "Keine Episoden für diese Staffel",
"overview": "Überblick", "overview": "Überblick",
"more_with": "Mehr mit {{name}}", "more_with": "Mehr mit {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Medienoptionen", "media_options": "Medienoptionen",
"quality": "Qualität", "quality": "Qualität",
"audio": "Audio", "audio": "Audio",
"subtitles": "Untertitel", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mehr anzeigen", "show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen", "show_less": "Weniger anzeigen",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Erschien in", "appeared_in": "Erschien in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Konnte Element nicht laden", "could_not_load_item": "Konnte Element nicht laden",
"none": "Keine", "none": "Keine",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}} Elemente herunterladen", "download_x_item": "{{item_count}} Elemente herunterladen",
"download_unwatched_only": "Nur Ungesehene", "download_unwatched_only": "Nur Ungesehene",
"download_button": "Herunterladen" "download_button": "Herunterladen"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Nächste", "next": "Nächste",
@@ -652,7 +795,18 @@
"movies": "Filme", "movies": "Filme",
"sports": "Sport", "sports": "Sport",
"for_kids": "Für Kinder", "for_kids": "Für Kinder",
"news": "Nachrichten" "news": "Nachrichten",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -697,6 +851,12 @@
"decline": "Ablehnen", "decline": "Ablehnen",
"requested_by": "Angefragt von {{user}}", "requested_by": "Angefragt von {{user}}",
"unknown_user": "Unbekannter Nutzer", "unknown_user": "Unbekannter Nutzer",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.", "jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.", "jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
@@ -716,7 +876,8 @@
"search": "Suche", "search": "Suche",
"library": "Bibliothek", "library": "Bibliothek",
"custom_links": "Links", "custom_links": "Links",
"favorites": "Favoriten" "favorites": "Favoriten",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Musik", "title": "Musik",
@@ -841,5 +1002,36 @@
"show": "Nur diese Serie", "show": "Nur diese Serie",
"all": "Alle (Standard)" "all": "Alle (Standard)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Σφάλμα", "error_title": "Σφάλμα",
"login_title": "Σύνδεση", "login_title": "Σύνδεση",
"login_to_title": "Συνδεθείτε στο", "login_to_title": "Συνδεθείτε στο",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Όνομα Χρήστη", "username_placeholder": "Όνομα Χρήστη",
"password_placeholder": "Κωδικός", "password_placeholder": "Κωδικός",
"login_button": "Σύνδεση", "login_button": "Σύνδεση",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ωχ!", "oops": "Ωχ!",
"error_message": "Something went wrong.\nPlease log out and in again.", "error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Συνέχεια Παρακολούθησης", "continue_watching": "Συνέχεια Παρακολούθησης",
"continue": "Continue",
"next_up": "Επόμενο Επάνω", "next_up": "Επόμενο Επάνω",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}", "recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Ρυθμίσεις", "settings_title": "Ρυθμίσεις",
"log_out_button": "Αποσύνδεση", "log_out_button": "Αποσύνδεση",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "Categories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Επαναφορά Μήκους", "rewind_length": "Επαναφορά Μήκους",
"seconds_unit": "ίνα" "seconds_unit": "ίνα"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Έλεγχοι Χειρονομιών", "gesture_controls_title": "Έλεγχοι Χειρονομιών",
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη", "horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων", "error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν", "background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν" "background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.", "new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.",
"back": "Πίσω", "back": "Πίσω",
"delete": "Διαγραφή", "delete": "Διαγραφή",
"delete_download": "Delete Download",
"something_went_wrong": "Κάτι Πήγε Λάθος", "something_went_wrong": "Κάτι Πήγε Λάθος",
"could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin", "could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Επιλογή", "select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο", "no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο", "video": "Βίντεο",
"audio": "Ήχος", "audio": "Ήχος",
"subtitle": "Υπότιτλος", "subtitle": "Υπότιτλος",
"play": "Αναπαραγωγή", "play": "Αναπαραγωγή",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Αναζήτηση...", "search": "Αναζήτηση...",
@@ -556,6 +641,7 @@
"movies": "Ταινίες", "movies": "Ταινίες",
"series": "Σειρά", "series": "Σειρά",
"boxsets": "Σύνολα Πλαισίων", "boxsets": "Σύνολα Πλαισίων",
"playlists": "Playlists",
"items": "Στοιχεία" "items": "Στοιχεία"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Αφίσα", "poster": "Αφίσα",
"cover": "Εξώφυλλο", "cover": "Εξώφυλλο",
"show_titles": "Εμφάνιση Τίτλων", "show_titles": "Εμφάνιση Τίτλων",
"show_stats": "Εμφάνιση Στατιστικών" "show_stats": "Εμφάνιση Στατιστικών",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Ταξινόμηση Κατά", "sort_by": "Ταξινόμηση Κατά",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Σειρά Ταξινόμησης", "sort_order": "Σειρά Ταξινόμησης",
"tags": "Ετικέτες" "tags": "Ετικέτες",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Δεν Υπάρχουν Σύνδεσμοι" "no_links": "Δεν Υπάρχουν Σύνδεσμοι"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Σφάλμα", "error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής", "failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.", "an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Επόμενο Επάνω", "next_up": "Επόμενο Επάνω",
@@ -617,6 +738,11 @@
"series": "Σειρά", "series": "Σειρά",
"seasons": "Περίοδοι", "seasons": "Περίοδοι",
"season": "Σεζόν", "season": "Σεζόν",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν", "no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
"overview": "Επισκόπηση", "overview": "Επισκόπηση",
"more_with": "More with {{name}}", "more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Ποιότητα", "quality": "Ποιότητα",
"audio": "Ήχος", "audio": "Ήχος",
"subtitles": "Υπότιτλος", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Εμφάνιση Περισσότερων", "show_more": "Εμφάνιση Περισσότερων",
"show_less": "Εμφάνιση Λιγότερων", "show_less": "Εμφάνιση Λιγότερων",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Εμφανίστηκε Σε", "appeared_in": "Εμφανίστηκε Σε",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου", "could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
"none": "Κανένα", "none": "Κανένα",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Λήψη Αντικειμένων {{item_count}}", "download_x_item": "Λήψη Αντικειμένων {{item_count}}",
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση", "download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
"download_button": "Λήψη" "download_button": "Λήψη"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Επόμενο", "next": "Επόμενο",
@@ -652,7 +795,18 @@
"movies": "Ταινίες", "movies": "Ταινίες",
"sports": "Αθλητισμός", "sports": "Αθλητισμός",
"for_kids": "Για Παιδιά", "for_kids": "Για Παιδιά",
"news": "Νέα" "news": "Νέα",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Επιβεβαίωση", "confirm": "Επιβεβαίωση",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0", "jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.", "jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
@@ -716,7 +876,8 @@
"search": "Αναζήτηση", "search": "Αναζήτηση",
"library": "Βιβλιοθήκη", "library": "Βιβλιοθήκη",
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι", "custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
"favorites": "Αγαπημένα" "favorites": "Αγαπημένα",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -119,6 +119,9 @@
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "Settings",
"log_out_button": "Log Out", "log_out_button": "Log Out",
"search_placeholder": "Search settings",
"search_results": "Results",
"search_no_results": "No matching settings",
"switch_user": { "switch_user": {
"title": "Switch User", "title": "Switch User",
"account": "Account", "account": "Account",
@@ -126,7 +129,25 @@
"current": "current" "current": "current"
}, },
"categories": { "categories": {
"title": "Categories" "title": "Categories",
"playback": "Playback",
"personalization": "Personalization",
"advanced": "Advanced"
},
"notifications": {
"title": "Notifications",
"disabled_title": "Notifications are off",
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
"enable_button": "Enable notifications",
"events_title": "Notify me about",
"master": "Enable notifications",
"downloads": "Download completed / failed"
},
"account": {
"title": "Account",
"copy_token": "Copy token",
"copied": "Copied to clipboard",
"copy_failed": "Couldn't copy to clipboard"
}, },
"playback_controls": { "playback_controls": {
"title": "Playback & Controls" "title": "Playback & Controls"
@@ -199,6 +220,10 @@
"rewind_length": "Rewind Length", "rewind_length": "Rewind Length",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"chromecast": {
"title": "Chromecast",
"enable_h265": "Enable H265 for Chromecast"
},
"buffer": { "buffer": {
"title": "Buffer Settings", "title": "Buffer Settings",
"cache_mode": "Cache Mode", "cache_mode": "Cache Mode",
@@ -534,6 +559,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back", "back": "Back",
"delete": "Delete", "delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong", "something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -577,6 +603,8 @@
"audio": "Audio", "audio": "Audio",
"subtitle": "Subtitle", "subtitle": "Subtitle",
"play": "Play", "play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
@@ -649,7 +677,8 @@
"poster": "Poster", "poster": "Poster",
"cover": "Cover", "cover": "Cover",
"show_titles": "Show Titles", "show_titles": "Show Titles",
"show_stats": "Show Stats" "show_stats": "Show Stats",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -679,6 +708,7 @@
}, },
"player": { "player": {
"live": "LIVE", "live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -698,7 +728,7 @@
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel", "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings", "swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at", "ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles", "search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks", "subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download", "subtitle_search": "Search & Download",

View File

@@ -4,6 +4,9 @@
"error_title": "Error", "error_title": "Error",
"login_title": "Iniciar sesión", "login_title": "Iniciar sesión",
"login_to_title": "Iniciar sesión en", "login_to_title": "Iniciar sesión en",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nombre de usuario", "username_placeholder": "Nombre de usuario",
"password_placeholder": "Contraseña", "password_placeholder": "Contraseña",
"login_button": "Iniciar sesión", "login_button": "Iniciar sesión",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} cuentas", "accounts_count": "{{count}} cuentas",
"select_account": "Seleccione una cuenta", "select_account": "Seleccione una cuenta",
"add_account": "Añadir cuenta", "add_account": "Añadir cuenta",
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}." "remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Guardar Cuenta", "title": "Guardar Cuenta",
@@ -86,6 +95,7 @@
"oops": "¡Vaya!", "oops": "¡Vaya!",
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.", "error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
"continue_watching": "Seguir viendo", "continue_watching": "Seguir viendo",
"continue": "Continue",
"next_up": "A continuación", "next_up": "A continuación",
"continue_and_next_up": "Continuar y siguiente", "continue_and_next_up": "Continuar y siguiente",
"recently_added_in": "Recientemente añadido en {{libraryName}}", "recently_added_in": "Recientemente añadido en {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Configuración", "settings_title": "Configuración",
"log_out_button": "Cerrar sesión", "log_out_button": "Cerrar sesión",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categorías" "title": "Categorías"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Apariencia", "title": "Apariencia",
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente", "merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
"hide_remote_session_button": "Ocultar botón de sesión remota" "hide_remote_session_button": "Ocultar botón de sesión remota",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Cadena", "title": "Cadena",
@@ -174,6 +199,22 @@
"rewind_length": "Longitud de retroceso", "rewind_length": "Longitud de retroceso",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Controles de gestos", "gesture_controls_title": "Controles de gestos",
"horizontal_swipe_skip": "Deslizar horizontal para omitir", "horizontal_swipe_skip": "Deslizar horizontal para omitir",
@@ -256,7 +297,23 @@
"subtitle_font": "Fuente de los subtítulos", "subtitle_font": "Fuente de los subtítulos",
"ksplayer_title": "Ajustes de KSPlayer", "ksplayer_title": "Ajustes de KSPlayer",
"hardware_decode": "Decodificación de hardware", "hardware_decode": "Decodificación de hardware",
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción." "hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "Configuración de subtítulos VLC", "title": "Configuración de subtítulos VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Caché de música eliminado", "music_cache_cleared": "Caché de música eliminado",
"delete_all_downloaded_songs": "Eliminar todas las descargas", "delete_all_downloaded_songs": "Eliminar todas las descargas",
"downloaded_songs_size": "{{tamaño}} descargado", "downloaded_songs_size": "{{tamaño}} descargado",
"downloaded_songs_deleted": "Canciones descargadas eliminadas" "downloaded_songs_deleted": "Canciones descargadas eliminadas",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error al eliminar archivos", "error_deleting_files": "Error al eliminar archivos",
"background_downloads_enabled": "Descargas en segundo plano habilitadas", "background_downloads_enabled": "Descargas en segundo plano habilitadas",
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas" "background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.", "new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.",
"back": "Atrás", "back": "Atrás",
"delete": "Borrar", "delete": "Borrar",
"delete_download": "Delete Download",
"something_went_wrong": "Algo ha salido mal", "something_went_wrong": "Algo ha salido mal",
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin", "could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
"eta": "{{eta}} restante", "eta": "{{eta}} restante",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Seleccionar", "select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible", "no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo", "video": "Vídeo",
"audio": "Audio", "audio": "Audio",
"subtitle": "Subtítulos", "subtitle": "Subtítulos",
"play": "Jugar", "play": "Jugar",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nada", "none": "Nada",
"track": "Pista", "track": "Pista",
"cancel": "Cancelar", "cancel": "Cancelar",
"stop": "Stop",
"delete": "Borrar", "delete": "Borrar",
"ok": "Aceptar", "ok": "Aceptar",
"remove": "Eliminar", "remove": "Eliminar",
"next": "Siguiente", "next": "Siguiente",
"back": "Atrás", "back": "Atrás",
"continue": "Continuar", "continue": "Continuar",
"verifying": "Verificando..." "verifying": "Verificando...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Buscar...", "search": "Buscar...",
@@ -556,6 +641,7 @@
"movies": "Películas", "movies": "Películas",
"series": "Series", "series": "Series",
"boxsets": "Colecciones", "boxsets": "Colecciones",
"playlists": "Playlists",
"items": "Elementos" "items": "Elementos"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Póster", "poster": "Póster",
"cover": "Portada", "cover": "Portada",
"show_titles": "Mostrar títulos", "show_titles": "Mostrar títulos",
"show_stats": "Mostrar estadísticas" "show_stats": "Mostrar estadísticas",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Géneros", "genres": "Géneros",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar por", "sort_by": "Ordenar por",
"filter_by": "Filtrar por", "filter_by": "Filtrar por",
"sort_order": "Ordenar", "sort_order": "Ordenar",
"tags": "Etiquetas" "tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Sin enlaces" "no_links": "Sin enlaces"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Error al obtener la URL del Steam", "failed_to_get_stream_url": "Error al obtener la URL del Steam",
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.", "an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?", "downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
"downloaded_file_yes": "Sí", "downloaded_file_yes": "Sí",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancelar" "downloaded_file_cancel": "Cancelar",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "A continuación", "next_up": "A continuación",
@@ -617,6 +738,11 @@
"series": "Series", "series": "Series",
"seasons": "Temporadas", "seasons": "Temporadas",
"season": "Temporada", "season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hay episodios para esta temporada", "no_episodes_for_this_season": "No hay episodios para esta temporada",
"overview": "Resumen", "overview": "Resumen",
"more_with": "Más con {{name}}", "more_with": "Más con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opciones de medios", "media_options": "Opciones de medios",
"quality": "Calidad", "quality": "Calidad",
"audio": "Audio", "audio": "Audio",
"subtitles": "Subtítulos", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostrar más", "show_more": "Mostrar más",
"show_less": "Mostrar menos", "show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apareció en", "appeared_in": "Apareció en",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No se pudo cargar el ítem", "could_not_load_item": "No se pudo cargar el ítem",
"none": "Ninguno", "none": "Ninguno",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descargar {{item_count}} ítems", "download_x_item": "Descargar {{item_count}} ítems",
"download_unwatched_only": "No visto", "download_unwatched_only": "No visto",
"download_button": "Descargar" "download_button": "Descargar"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Siguiente", "next": "Siguiente",
@@ -652,7 +795,18 @@
"movies": "Películas", "movies": "Películas",
"sports": "Deportes", "sports": "Deportes",
"for_kids": "Para niños", "for_kids": "Para niños",
"news": "Noticias" "news": "Noticias",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -697,6 +851,12 @@
"decline": "Rechazar", "decline": "Rechazar",
"requested_by": "Solicitado por {{user}}", "requested_by": "Solicitado por {{user}}",
"unknown_user": "Usuario desconocido", "unknown_user": "Usuario desconocido",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.", "jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.", "jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
@@ -716,7 +876,8 @@
"search": "Buscar", "search": "Buscar",
"library": "Bibliotecas", "library": "Bibliotecas",
"custom_links": "Enlaces personalizados", "custom_links": "Enlaces personalizados",
"favorites": "Favoritos" "favorites": "Favoritos",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Música", "title": "Música",
@@ -841,5 +1002,36 @@
"show": "Este programa", "show": "Este programa",
"all": "Todos los medios (por defecto)" "all": "Todos los medios (por defecto)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Virhe", "error_title": "Virhe",
"login_title": "Kirjaudu sisään", "login_title": "Kirjaudu sisään",
"login_to_title": "Kirjaudu sisään palveluun", "login_to_title": "Kirjaudu sisään palveluun",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Käyttäjätunnus", "username_placeholder": "Käyttäjätunnus",
"password_placeholder": "Salasana", "password_placeholder": "Salasana",
"login_button": "Kirjaudu sisään", "login_button": "Kirjaudu sisään",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!", "oops": "Ups!",
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.", "error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
"continue_watching": "Jatka katsomista", "continue_watching": "Jatka katsomista",
"continue": "Continue",
"next_up": "Seuraavaksi", "next_up": "Seuraavaksi",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon", "recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Asetukset", "settings_title": "Asetukset",
"log_out_button": "Kirjaudu ulos", "log_out_button": "Kirjaudu ulos",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Kategoriat" "title": "Kategoriat"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Ulkoasu", "title": "Ulkoasu",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Taaksepäin hyppäämisen pituus", "rewind_length": "Taaksepäin hyppäämisen pituus",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Ele Ohjaus", "gesture_controls_title": "Ele Ohjaus",
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä", "horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Esittely", "title": "Esittely",
@@ -430,6 +493,21 @@
"error_deleting_files": "Virhe tiedostojen poistamisessa", "error_deleting_files": "Virhe tiedostojen poistamisessa",
"background_downloads_enabled": "Taustalataukset käytössä", "background_downloads_enabled": "Taustalataukset käytössä",
"background_downloads_disabled": "Taustalataukset pois käytöstä" "background_downloads_disabled": "Taustalataukset pois käytöstä"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Uusi päivitys vaatii sisällön lataamista uudelleen. Poista kaikki ladattu sisältö ja yritä uudelleen.", "new_app_version_requires_re_download_description": "Uusi päivitys vaatii sisällön lataamista uudelleen. Poista kaikki ladattu sisältö ja yritä uudelleen.",
"back": "Takaisin", "back": "Takaisin",
"delete": "Poista", "delete": "Poista",
"delete_download": "Delete Download",
"something_went_wrong": "Jotain meni pieleen", "something_went_wrong": "Jotain meni pieleen",
"could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta", "could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta",
"eta": "Arvio {{eta}}", "eta": "Arvio {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Valitse", "select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla", "no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video", "video": "Video",
"audio": "Ääni", "audio": "Ääni",
"subtitle": "Tekstitys", "subtitle": "Tekstitys",
"play": "Toista", "play": "Toista",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Ei mitään", "none": "Ei mitään",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Haku...", "search": "Haku...",
@@ -556,6 +641,7 @@
"movies": "elokuvat", "movies": "elokuvat",
"series": "sarjat", "series": "sarjat",
"boxsets": "bokset", "boxsets": "bokset",
"playlists": "Playlists",
"items": "kohteet" "items": "kohteet"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Juliste", "poster": "Juliste",
"cover": "Kansi", "cover": "Kansi",
"show_titles": "Näytä otsikot", "show_titles": "Näytä otsikot",
"show_stats": "Näytä tilastot" "show_stats": "Näytä tilastot",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genret", "genres": "Genret",
@@ -574,7 +661,11 @@
"sort_by": "Lajittele", "sort_by": "Lajittele",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Lajittelujärjestys", "sort_order": "Lajittelujärjestys",
"tags": "Tunnisteet" "tags": "Tunnisteet",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ei Linkkejä" "no_links": "Ei Linkkejä"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Virhe", "error": "Virhe",
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui", "failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.", "an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?", "downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
"downloaded_file_yes": "Kyllä", "downloaded_file_yes": "Kyllä",
"downloaded_file_no": "Ei", "downloaded_file_no": "Ei",
"downloaded_file_cancel": "Peruuta" "downloaded_file_cancel": "Peruuta",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Seuraavaksi", "next_up": "Seuraavaksi",
@@ -617,6 +738,11 @@
"series": "Sarjat", "series": "Sarjat",
"seasons": "Kaudet", "seasons": "Kaudet",
"season": "Kausi", "season": "Kausi",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ei jaksoja tälle kaudelle", "no_episodes_for_this_season": "Ei jaksoja tälle kaudelle",
"overview": "Yleiskatsaus", "overview": "Yleiskatsaus",
"more_with": "Enemmän {{name}} kanssa", "more_with": "Enemmän {{name}} kanssa",
@@ -627,10 +753,21 @@
"media_options": "Media-asetukset", "media_options": "Media-asetukset",
"quality": "Laatu", "quality": "Laatu",
"audio": "Ääni", "audio": "Ääni",
"subtitles": "Tekstitys", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Näytä Lisää", "show_more": "Näytä Lisää",
"show_less": "Näytä Vähemmän", "show_less": "Näytä Vähemmän",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Esiintyy Sisään", "appeared_in": "Esiintyy Sisään",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kohdetta Ei Voitu Ladata", "could_not_load_item": "Kohdetta Ei Voitu Ladata",
"none": "Ei mitään", "none": "Ei mitään",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Lataa {{item_count}} Kohteita", "download_x_item": "Lataa {{item_count}} Kohteita",
"download_unwatched_only": "Vain Katsomattomat", "download_unwatched_only": "Vain Katsomattomat",
"download_button": "Lataa" "download_button": "Lataa"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Seuraava", "next": "Seuraava",
@@ -652,7 +795,18 @@
"movies": "Elokuvat", "movies": "Elokuvat",
"sports": "Urheilu", "sports": "Urheilu",
"for_kids": "Lapsille", "for_kids": "Lapsille",
"news": "Uutiset" "news": "Uutiset",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Vahvista", "confirm": "Vahvista",
@@ -697,6 +851,12 @@
"decline": "Hylkää", "decline": "Hylkää",
"requested_by": "Käyttäjän {{user}} pyynnöstä", "requested_by": "Käyttäjän {{user}} pyynnöstä",
"unknown_user": "Tuntematon käyttäjä", "unknown_user": "Tuntematon käyttäjä",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0", "jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0",
"jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.", "jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.",
@@ -716,7 +876,8 @@
"search": "Haku", "search": "Haku",
"library": "Kirjasto", "library": "Kirjasto",
"custom_links": "Mukautetut linkit", "custom_links": "Mukautetut linkit",
"favorites": "Suosikit" "favorites": "Suosikit",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Erreur", "error_title": "Erreur",
"login_title": "Se connecter", "login_title": "Se connecter",
"login_to_title": "Se connecter à", "login_to_title": "Se connecter à",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'utilisateur", "username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe", "password_placeholder": "Mot de passe",
"login_button": "Se connecter", "login_button": "Se connecter",
@@ -42,7 +45,13 @@
"accounts_count": "Comptes {{count}}", "accounts_count": "Comptes {{count}}",
"select_account": "Sélectionnez un compte", "select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte", "add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}." "remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Sauvegarder le compte", "title": "Sauvegarder le compte",
@@ -86,6 +95,7 @@
"oops": "Oups!", "oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.", "error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder", "continue_watching": "Continuer à regarder",
"continue": "Continue",
"next_up": "À suivre", "next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre", "continue_and_next_up": "Continuer de regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}", "recently_added_in": "Ajoutés récemment dans {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Paramètres", "settings_title": "Paramètres",
"log_out_button": "Déconnexion", "log_out_button": "Déconnexion",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Catégories" "title": "Catégories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Apparence", "title": "Apparence",
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre", "merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
"hide_remote_session_button": "Masquer le bouton de session distante" "hide_remote_session_button": "Masquer le bouton de session distante",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Réseau", "title": "Réseau",
@@ -174,6 +199,22 @@
"rewind_length": "Durée de retour en arrière", "rewind_length": "Durée de retour en arrière",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Commandes gestuelles", "gesture_controls_title": "Commandes gestuelles",
"horizontal_swipe_skip": "Glisser horizontalement pour passer", "horizontal_swipe_skip": "Glisser horizontalement pour passer",
@@ -256,7 +297,23 @@
"subtitle_font": "Police des sous-titres", "subtitle_font": "Police des sous-titres",
"ksplayer_title": "Paramètres de KSPlayer", "ksplayer_title": "Paramètres de KSPlayer",
"hardware_decode": "Décodage matériel", "hardware_decode": "Décodage matériel",
"hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture." "hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "Paramètres des sous-titres VLC", "title": "Paramètres des sous-titres VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Cache de musique effacé", "music_cache_cleared": "Cache de musique effacé",
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées", "delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé", "downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées" "downloaded_songs_deleted": "Chansons téléchargées supprimées",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Introduction", "title": "Introduction",
@@ -430,6 +493,21 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers", "error_deleting_files": "Erreur lors de la suppression des fichiers",
"background_downloads_enabled": "Téléchargements en arrière-plan activés", "background_downloads_enabled": "Téléchargements en arrière-plan activés",
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés" "background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.", "new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
"back": "Retour", "back": "Retour",
"delete": "Supprimer", "delete": "Supprimer",
"delete_download": "Delete Download",
"something_went_wrong": "Quelque chose s'est mal passé", "something_went_wrong": "Quelque chose s'est mal passé",
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Sélectionner", "select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible", "no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo", "video": "Vidéo",
"audio": "Audio", "audio": "Audio",
"subtitle": "Sous-titres", "subtitle": "Sous-titres",
"play": "Lecture", "play": "Lecture",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Aucun", "none": "Aucun",
"track": "Suivre", "track": "Suivre",
"cancel": "Annuler", "cancel": "Annuler",
"stop": "Stop",
"delete": "Supprimer", "delete": "Supprimer",
"ok": "Ok", "ok": "Ok",
"remove": "Retirer", "remove": "Retirer",
"next": "Suivant", "next": "Suivant",
"back": "Précédent", "back": "Précédent",
"continue": "Continuer", "continue": "Continuer",
"verifying": "Vérification..." "verifying": "Vérification...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Rechercher...", "search": "Rechercher...",
@@ -556,6 +641,7 @@
"movies": "Films", "movies": "Films",
"series": "Séries", "series": "Séries",
"boxsets": "Coffrets ", "boxsets": "Coffrets ",
"playlists": "Playlists",
"items": "Médias" "items": "Médias"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Affiche", "poster": "Affiche",
"cover": "Couverture", "cover": "Couverture",
"show_titles": "Afficher les titres", "show_titles": "Afficher les titres",
"show_stats": "Afficher les statistiques" "show_stats": "Afficher les statistiques",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Trier par", "sort_by": "Trier par",
"filter_by": "Filtrer par", "filter_by": "Filtrer par",
"sort_order": "Ordre de tri", "sort_order": "Ordre de tri",
"tags": "Tags" "tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Aucuns liens" "no_links": "Aucuns liens"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Erreur", "error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux", "failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.", "an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?", "downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
"downloaded_file_yes": "Oui", "downloaded_file_yes": "Oui",
"downloaded_file_no": "Non", "downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler" "downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "À suivre", "next_up": "À suivre",
@@ -617,6 +738,11 @@
"series": "Séries", "series": "Séries",
"seasons": "Saisons", "seasons": "Saisons",
"season": "Saison", "season": "Saison",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Aucun épisode pour cette saison", "no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu", "overview": "Aperçu",
"more_with": "Plus avec {{name}}", "more_with": "Plus avec {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Options média", "media_options": "Options média",
"quality": "Qualité", "quality": "Qualité",
"audio": "Audio", "audio": "Audio",
"subtitles": "Sous-titres", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Afficher plus", "show_more": "Afficher plus",
"show_less": "Afficher moins", "show_less": "Afficher moins",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparu dans", "appeared_in": "Apparu dans",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossible de charger le média", "could_not_load_item": "Impossible de charger le média",
"none": "Aucun", "none": "Aucun",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Télécharger {{item_count}} médias", "download_x_item": "Télécharger {{item_count}} médias",
"download_unwatched_only": "Non visionné uniquement", "download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger" "download_button": "Télécharger"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Suivant", "next": "Suivant",
@@ -652,7 +795,18 @@
"movies": "Films", "movies": "Films",
"sports": "Sports", "sports": "Sports",
"for_kids": "Pour enfants", "for_kids": "Pour enfants",
"news": "Actualités" "news": "Actualités",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -697,6 +851,12 @@
"decline": "Refuser", "decline": "Refuser",
"requested_by": "Demandé par {{user}}", "requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu", "unknown_user": "Utilisateur inconnu",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.", "jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.", "jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
@@ -716,7 +876,8 @@
"search": "Recherche", "search": "Recherche",
"library": "Bibliothèque", "library": "Bibliothèque",
"custom_links": "Liens personnalisés", "custom_links": "Liens personnalisés",
"favorites": "Favoris" "favorites": "Favoris",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Musique", "title": "Musique",
@@ -788,8 +949,8 @@
} }
}, },
"watchlists": { "watchlists": {
"title": "Watchlists", "title": "Listes de lecture",
"my_watchlists": "My Watchlists", "my_watchlists": "Mes listes de lecture",
"public_watchlists": "Watchlist publique", "public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist", "create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist", "edit_title": "Modifier la Watchlist",
@@ -802,7 +963,7 @@
"name_placeholder": "Entrer le nom de la playlist", "name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description", "description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)", "description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Public Watchlist", "is_public_label": "Liste de lecture Publique",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi", "is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu", "allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut", "sort_order_label": "Ordre de tri par défaut",
@@ -841,5 +1002,36 @@
"show": "Cette série", "show": "Cette série",
"all": "Tous les médias (par défaut)" "all": "Tous les médias (par défaut)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "שגיאה", "error_title": "שגיאה",
"login_title": "התחבר", "login_title": "התחבר",
"login_to_title": "התחבר אל", "login_to_title": "התחבר אל",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "שם משתמש", "username_placeholder": "שם משתמש",
"password_placeholder": "סיסמה", "password_placeholder": "סיסמה",
"login_button": "התחבר", "login_button": "התחבר",
@@ -39,10 +42,16 @@
"please_login_again": "Your saved session has expired. Please log in again.", "please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login", "remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.", "remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} חשבונות",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "אופס!", "oops": "אופס!",
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.", "error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
"continue_watching": "המשך לצפות", "continue_watching": "המשך לצפות",
"continue": "Continue",
"next_up": "הבא בתור", "next_up": "הבא בתור",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}", "recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
@@ -109,19 +119,34 @@
"settings": { "settings": {
"settings_title": "הגדרות", "settings_title": "הגדרות",
"log_out_button": "התנתק", "log_out_button": "התנתק",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "קטגוריות"
}, },
"playback_controls": { "playback_controls": {
"title": "Playback & Controls" "title": "Playback & Controls"
}, },
"audio_subtitles": { "audio_subtitles": {
"title": "Audio & Subtitles" "title": "שמע וכתוביות"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "מראה",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "אורך הזזה אחורה", "rewind_length": "אורך הזזה אחורה",
"seconds_unit": "שנ'" "seconds_unit": "שנ'"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "פקדי מחוות", "gesture_controls_title": "פקדי מחוות",
"horizontal_swipe_skip": "החלקה אופקית לדילוג", "horizontal_swipe_skip": "החלקה אופקית לדילוג",
@@ -188,7 +229,7 @@
"hide_brightness_slider_description": "Hide the brightness slider in the video player" "hide_brightness_slider_description": "Hide the brightness slider in the video player"
}, },
"audio": { "audio": {
"audio_title": "אודיו", "audio_title": "שמע",
"set_audio_track": "בחר רצועת שמע מהפריט הקודם", "set_audio_track": "בחר רצועת שמע מהפריט הקודם",
"audio_language": "שפת שמע", "audio_language": "שפת שמע",
"audio_hint": "בחר שפת שמע אוטומטית.", "audio_hint": "בחר שפת שמע אוטומטית.",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -271,8 +328,8 @@
"margin": "Bottom Margin" "margin": "Bottom Margin"
}, },
"video_player": { "video_player": {
"title": "Video Player", "title": "נגן וידאו",
"video_player": "Video Player", "video_player": "נגן וידאו",
"video_player_description": "Choose which video player to use on iOS.", "video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer", "ksplayer": "KSPlayer",
"vlc": "VLC" "vlc": "VLC"
@@ -314,7 +371,7 @@
"downloads_title": "הורדות" "downloads_title": "הורדות"
}, },
"music": { "music": {
"title": "Music", "title": "מוזיקה",
"playback_title": "Playback", "playback_title": "Playback",
"playback_description": "Configure how music is played.", "playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs", "prefer_downloaded": "Prefer Downloaded Songs",
@@ -406,10 +463,16 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "הקדמה",
"show_intro": "הצג פתיח", "show_intro": "הצג פתיח",
"reset_intro": "אפס פתיח" "reset_intro": "אפס פתיח"
}, },
@@ -430,6 +493,21 @@
"error_deleting_files": "שגיאה במחיקת קבצים", "error_deleting_files": "שגיאה במחיקת קבצים",
"background_downloads_enabled": "הורדה ברקע מופעלת", "background_downloads_enabled": "הורדה ברקע מופעלת",
"background_downloads_disabled": "הורדה ברקע כבויה" "background_downloads_disabled": "הורדה ברקע כבויה"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.", "new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.",
"back": "חזרה", "back": "חזרה",
"delete": "מחק", "delete": "מחק",
"delete_download": "Delete Download",
"something_went_wrong": "משהו השתבש", "something_went_wrong": "משהו השתבש",
"could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin", "could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin",
"eta": "זמן משוער {{eta}}", "eta": "זמן משוער {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "בחר", "select": "בחר",
"no_trailer_available": "אין טריילר זמין", "no_trailer_available": "אין טריילר זמין",
"video": "וידאו", "video": "וידאו",
"audio": "אודיו", "audio": "שמע",
"subtitle": "כתובית", "subtitle": "כתובית",
"play": "נגן", "play": "נגן",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "ללא", "none": "ללא",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "חפש...", "search": "חפש...",
@@ -521,9 +606,9 @@
"episodes": "פרקים", "episodes": "פרקים",
"collections": "אוספים", "collections": "אוספים",
"actors": "שחקנים", "actors": "שחקנים",
"artists": "Artists", "artists": "אומנים",
"albums": "Albums", "albums": "אלבומים",
"songs": "Songs", "songs": "שירים",
"playlists": "Playlists", "playlists": "Playlists",
"request_movies": "סרטים מבוקשים", "request_movies": "סרטים מבוקשים",
"request_series": "סדרות מבוקשים", "request_series": "סדרות מבוקשים",
@@ -556,6 +641,7 @@
"movies": "סרטים", "movies": "סרטים",
"series": "סדרות", "series": "סדרות",
"boxsets": "אוסף", "boxsets": "אוסף",
"playlists": "Playlists",
"items": "פריטים" "items": "פריטים"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "פוסטר", "poster": "פוסטר",
"cover": "עטיפה", "cover": "עטיפה",
"show_titles": "הצג כותרות", "show_titles": "הצג כותרות",
"show_stats": "הצג סטטיסטיקה" "show_stats": "הצג סטטיסטיקה",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "סגנונות", "genres": "סגנונות",
@@ -574,7 +661,11 @@
"sort_by": "מיין לפי", "sort_by": "מיין לפי",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "סדר מיון", "sort_order": "סדר מיון",
"tags": "תגים" "tags": "תגים",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "אין קישורים" "no_links": "אין קישורים"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "שגיאה", "error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם", "failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.", "an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
@@ -606,9 +699,37 @@
"go_back": "חזור", "go_back": "חזור",
"downloaded_file_title": "You have this file downloaded", "downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "כן",
"downloaded_file_no": "No", "downloaded_file_no": "לא",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "הבא בתור", "next_up": "הבא בתור",
@@ -617,6 +738,11 @@
"series": "סדרות", "series": "סדרות",
"seasons": "עונות", "seasons": "עונות",
"season": "עונה", "season": "עונה",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "אין פרקים בעונה זו", "no_episodes_for_this_season": "אין פרקים בעונה זו",
"overview": "סקירה", "overview": "סקירה",
"more_with": "עוד עם {{name}}", "more_with": "עוד עם {{name}}",
@@ -626,11 +752,22 @@
"more_details": "פרטים נוספים", "more_details": "פרטים נוספים",
"media_options": "Media Options", "media_options": "Media Options",
"quality": "איכות", "quality": "איכות",
"audio": "אודיו", "audio": "שמע",
"subtitles": "כתובית", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "הצג עוד", "show_more": "הצג עוד",
"show_less": "הצג פחות", "show_less": "הצג פחות",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "הופיע ב-", "appeared_in": "הופיע ב-",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "נכשל בטעינת פריט", "could_not_load_item": "נכשל בטעינת פריט",
"none": "ללא", "none": "ללא",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "הורד {{item_count}} פריטים", "download_x_item": "הורד {{item_count}} פריטים",
"download_unwatched_only": "רק שלא נצפו", "download_unwatched_only": "רק שלא נצפו",
"download_button": "הורד" "download_button": "הורד"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "הבא", "next": "הבא",
@@ -652,7 +795,18 @@
"movies": "סרטים", "movies": "סרטים",
"sports": "ספורט", "sports": "ספורט",
"for_kids": "לילדים", "for_kids": "לילדים",
"news": "חדשות" "news": "חדשות",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "אשר", "confirm": "אשר",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0", "jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.", "jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
@@ -716,13 +876,14 @@
"search": "חיפוש", "search": "חיפוש",
"library": "ספריה", "library": "ספריה",
"custom_links": "קישורים מותאמים אישית", "custom_links": "קישורים מותאמים אישית",
"favorites": "מועדפים" "favorites": "מועדפים",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "מוזיקה",
"tabs": { "tabs": {
"suggestions": "Suggestions", "suggestions": "Suggestions",
"albums": "Albums", "albums": "אלבומים",
"artists": "Artists", "artists": "Artists",
"playlists": "Playlists", "playlists": "Playlists",
"tracks": "tracks" "tracks": "tracks"
@@ -798,9 +959,9 @@
"delete_button": "Delete", "delete_button": "Delete",
"remove_button": "Remove", "remove_button": "Remove",
"cancel_button": "Cancel", "cancel_button": "Cancel",
"name_label": "Name", "name_label": "שם",
"name_placeholder": "Enter watchlist name", "name_placeholder": "Enter watchlist name",
"description_label": "Description", "description_label": "תיאור",
"description_placeholder": "Enter description (optional)", "description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist", "is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist", "is_public_description": "Allow others to view this watchlist",
@@ -817,10 +978,10 @@
"remove_from_watchlist": "Remove from Watchlist", "remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist", "select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist", "create_new": "Create New Watchlist",
"item": "item", "item": "פריט",
"items": "items", "items": "פריטים",
"public": "Public", "public": "ציבורי",
"private": "Private", "private": "פרטי",
"you": "You", "you": "You",
"by_owner": "By another user", "by_owner": "By another user",
"not_found": "Watchlist not found", "not_found": "Watchlist not found",
@@ -835,11 +996,42 @@
"playback_speed": { "playback_speed": {
"title": "Playback Speed", "title": "Playback Speed",
"apply_to": "Apply To", "apply_to": "Apply To",
"speed": "Speed", "speed": "מהירות",
"scope": { "scope": {
"media": "This media only", "media": "This media only",
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Hiba", "error_title": "Hiba",
"login_title": "Bejelentkezés", "login_title": "Bejelentkezés",
"login_to_title": "Bejelentkezés ide", "login_to_title": "Bejelentkezés ide",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Felhasználónév", "username_placeholder": "Felhasználónév",
"password_placeholder": "Jelszó", "password_placeholder": "Jelszó",
"login_button": "Bejelentkezés", "login_button": "Bejelentkezés",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Hoppá!", "oops": "Hoppá!",
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.", "error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
"continue_watching": "Nézd Tovább", "continue_watching": "Nézd Tovább",
"continue": "Continue",
"next_up": "Következő", "next_up": "Következő",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban", "recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Beállítások", "settings_title": "Beállítások",
"log_out_button": "Kijelentkezés", "log_out_button": "Kijelentkezés",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "Categories"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Visszatekerés Hossza", "rewind_length": "Visszatekerés Hossza",
"seconds_unit": "mp" "seconds_unit": "mp"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Gesztusvezérlés", "gesture_controls_title": "Gesztusvezérlés",
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz", "horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Hiba a Fájlok Törlésekor", "error_deleting_files": "Hiba a Fájlok Törlésekor",
"background_downloads_enabled": "Background downloads enabled", "background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled" "background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.", "new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.",
"back": "Vissza", "back": "Vissza",
"delete": "Törlés", "delete": "Törlés",
"delete_download": "Delete Download",
"something_went_wrong": "Hiba Történt", "something_went_wrong": "Hiba Történt",
"could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből", "could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből",
"eta": "Várható Idő: {{eta}}", "eta": "Várható Idő: {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Select", "select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Videó", "video": "Videó",
"audio": "Hang", "audio": "Hang",
"subtitle": "Felirat", "subtitle": "Felirat",
"play": "Play", "play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Keresés...", "search": "Keresés...",
@@ -556,6 +641,7 @@
"movies": "Filmek", "movies": "Filmek",
"series": "Sorozatok", "series": "Sorozatok",
"boxsets": "Gyűjtemények", "boxsets": "Gyűjtemények",
"playlists": "Playlists",
"items": "Elemek" "items": "Elemek"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Poszter", "poster": "Poszter",
"cover": "Borító", "cover": "Borító",
"show_titles": "Címek Megjelenítése", "show_titles": "Címek Megjelenítése",
"show_stats": "Statisztikák Megjelenítése" "show_stats": "Statisztikák Megjelenítése",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Műfajok", "genres": "Műfajok",
@@ -574,7 +661,11 @@
"sort_by": "Rendezés", "sort_by": "Rendezés",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Rendezés Iránya", "sort_order": "Rendezés Iránya",
"tags": "Címkék" "tags": "Címkék",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nincsenek Linkek" "no_links": "Nincsenek Linkek"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Hiba", "error": "Hiba",
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t", "failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.", "an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Következő", "next_up": "Következő",
@@ -617,6 +738,11 @@
"series": "Sorozat", "series": "Sorozat",
"seasons": "Évadok", "seasons": "Évadok",
"season": "Évad", "season": "Évad",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód", "no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
"overview": "Áttekintés", "overview": "Áttekintés",
"more_with": "További {{name}} Alkotások", "more_with": "További {{name}} Alkotások",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Minőség", "quality": "Minőség",
"audio": "Hang", "audio": "Hang",
"subtitles": "Felirat", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Több Megjelenítése", "show_more": "Több Megjelenítése",
"show_less": "Kevesebb Megjelenítése", "show_less": "Kevesebb Megjelenítése",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Megjelent:", "appeared_in": "Megjelent:",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nem Sikerült Betölteni az Elemet", "could_not_load_item": "Nem Sikerült Betölteni az Elemet",
"none": "Nincs", "none": "Nincs",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}} Elem Letöltése", "download_x_item": "{{item_count}} Elem Letöltése",
"download_unwatched_only": "Csak Nem Megtekintett", "download_unwatched_only": "Csak Nem Megtekintett",
"download_button": "Letöltés" "download_button": "Letöltés"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Következő", "next": "Következő",
@@ -652,7 +795,18 @@
"movies": "Filmek", "movies": "Filmek",
"sports": "Sport", "sports": "Sport",
"for_kids": "Gyerekeknek", "for_kids": "Gyerekeknek",
"news": "Hírek" "news": "Hírek",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Megerősítés", "confirm": "Megerősítés",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.", "jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.", "jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
@@ -716,7 +876,8 @@
"search": "Keresés", "search": "Keresés",
"library": "Könyvtár", "library": "Könyvtár",
"custom_links": "Egyéni Linkek", "custom_links": "Egyéni Linkek",
"favorites": "Kedvencek" "favorites": "Kedvencek",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "Errore", "error_title": "Errore",
"login_title": "Accesso", "login_title": "Accesso",
"login_to_title": "Accedi a", "login_to_title": "Accedi a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nome utente", "username_placeholder": "Nome utente",
"password_placeholder": "Password", "password_placeholder": "Password",
"login_button": "Accedi", "login_button": "Accedi",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ops!", "oops": "Ops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.", "error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare", "continue_watching": "Continua a guardare",
"continue": "Continue",
"next_up": "Prossimo", "next_up": "Prossimo",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Aggiunti di recente a {{libraryName}}", "recently_added_in": "Aggiunti di recente a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "Impostazioni", "settings_title": "Impostazioni",
"log_out_button": "Esci", "log_out_button": "Esci",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categorie" "title": "Categorie"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Aspetto", "title": "Aspetto",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -136,7 +161,7 @@
"not_connected_to_wifi": "Not connected to WiFi", "not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured", "no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching", "add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi", "current_wifi": "WiFi Attuale",
"using_url": "Sta utilizzando", "using_url": "Sta utilizzando",
"local": "Local URL", "local": "Local URL",
"remote": "Remote URL", "remote": "Remote URL",
@@ -174,6 +199,22 @@
"rewind_length": "Lunghezza del riavvolgimento", "rewind_length": "Lunghezza del riavvolgimento",
"seconds_unit": "secondi" "seconds_unit": "secondi"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "Controlli Gesture", "gesture_controls_title": "Controlli Gesture",
"horizontal_swipe_skip": "Scorrimento orizzontale per saltare", "horizontal_swipe_skip": "Scorrimento orizzontale per saltare",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Errore nella cancellazione dei file", "error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato", "background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato" "background_downloads_disabled": "Scaricamento in background disabilitato"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.", "new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
"back": "Indietro", "back": "Indietro",
"delete": "Cancella", "delete": "Cancella",
"delete_download": "Delete Download",
"something_went_wrong": "Qualcosa è andato storto", "something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin", "could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "Tempo stimato di completamento {{eta}}", "eta": "Tempo stimato di completamento {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "Seleziona", "select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile", "no_trailer_available": "Nessun trailer disponibile",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
"subtitle": "Sottotitoli", "subtitle": "Sottotitoli",
"play": "Gioca", "play": "Gioca",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nulla", "none": "Nulla",
"track": "Traccia", "track": "Traccia",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Cerca...", "search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "film", "movies": "film",
"series": "serie TV", "series": "serie TV",
"boxsets": "cofanetti", "boxsets": "cofanetti",
"playlists": "Playlists",
"items": "elementi" "items": "elementi"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Poster", "poster": "Poster",
"cover": "Copertina", "cover": "Copertina",
"show_titles": "Mostra titoli", "show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche" "show_stats": "Mostra statistiche",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Generi", "genres": "Generi",
@@ -574,7 +661,11 @@
"sort_by": "Ordina per", "sort_by": "Ordina per",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Criterio di ordinamento", "sort_order": "Criterio di ordinamento",
"tags": "Tag" "tags": "Tag",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nessun link" "no_links": "Nessun link"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Errore", "error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream", "failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.", "an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Il prossimo", "next_up": "Il prossimo",
@@ -617,6 +738,11 @@
"series": "Serie", "series": "Serie",
"seasons": "Stagioni", "seasons": "Stagioni",
"season": "Stagione", "season": "Stagione",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Nessun episodio per questa stagione", "no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica", "overview": "Panoramica",
"more_with": "Altri con {{name}}", "more_with": "Altri con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opzioni Media", "media_options": "Opzioni Media",
"quality": "Qualità", "quality": "Qualità",
"audio": "Audio", "audio": "Audio",
"subtitles": "Sottotitoli", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra di più", "show_more": "Mostra di più",
"show_less": "Mostra di meno", "show_less": "Mostra di meno",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in", "appeared_in": "Apparso in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento", "could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno", "none": "Nessuno",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Scarica {{item_count}} elementi", "download_x_item": "Scarica {{item_count}} elementi",
"download_unwatched_only": "Solo Non Visti", "download_unwatched_only": "Solo Non Visti",
"download_button": "Scarica" "download_button": "Scarica"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "Prossimo", "next": "Prossimo",
@@ -652,7 +795,18 @@
"movies": "Film", "movies": "Film",
"sports": "Sport", "sports": "Sport",
"for_kids": "Per Bambini", "for_kids": "Per Bambini",
"news": "Notiziari" "news": "Notiziari",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "Conferma", "confirm": "Conferma",
@@ -697,6 +851,12 @@
"decline": "Rifiuta", "decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}", "requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto", "unknown_user": "Utente Sconosciuto",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.", "jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.", "jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -716,7 +876,8 @@
"search": "Cerca", "search": "Cerca",
"library": "Libreria", "library": "Libreria",
"custom_links": "Collegamenti personalizzati", "custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti" "favorites": "Preferiti",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -4,6 +4,9 @@
"error_title": "エラー", "error_title": "エラー",
"login_title": "ログイン", "login_title": "ログイン",
"login_to_title": "ログイン先", "login_to_title": "ログイン先",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "ユーザー名", "username_placeholder": "ユーザー名",
"password_placeholder": "パスワード", "password_placeholder": "パスワード",
"login_button": "ログイン", "login_button": "ログイン",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts", "accounts_count": "{{count}} accounts",
"select_account": "Select Account", "select_account": "Select Account",
"add_account": "Add Account", "add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}." "remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Save Account", "title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "おっと!", "oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。", "error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る", "continue_watching": "続きを見る",
"continue": "Continue",
"next_up": "次の動画", "next_up": "次の動画",
"continue_and_next_up": "Continue & Next Up", "continue_and_next_up": "Continue & Next Up",
"recently_added_in": "{{libraryName}}に最近追加された", "recently_added_in": "{{libraryName}}に最近追加された",
@@ -109,6 +119,12 @@
"settings": { "settings": {
"settings_title": "設定", "settings_title": "設定",
"log_out_button": "ログアウト", "log_out_button": "ログアウト",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "カテゴリ" "title": "カテゴリ"
}, },
@@ -121,7 +137,16 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button" "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "巻き戻しの長さ", "rewind_length": "巻き戻しの長さ",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": { "gesture_controls": {
"gesture_controls_title": "ジェスチャーコントロール", "gesture_controls_title": "ジェスチャーコントロール",
"horizontal_swipe_skip": "水平方向にスワイプしてスキップ", "horizontal_swipe_skip": "水平方向にスワイプしてスキップ",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font", "subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings", "ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding", "hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
}, },
"vlc_subtitles": { "vlc_subtitles": {
"title": "VLC Subtitle Settings", "title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted" "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "イントロ", "title": "イントロ",
@@ -430,6 +493,21 @@
"error_deleting_files": "ファイルの削除エラー", "error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です", "background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です" "background_downloads_disabled": "バックグラウンドでのダウンロードは無効です"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。", "new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。",
"back": "戻る", "back": "戻る",
"delete": "削除", "delete": "削除",
"delete_download": "Delete Download",
"something_went_wrong": "問題が発生しました", "something_went_wrong": "問題が発生しました",
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした", "could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
} }
}, },
"common": { "common": {
"no_results": "No Results",
"select": "選択", "select": "選択",
"no_trailer_available": "トレーラーがありません", "no_trailer_available": "トレーラーがありません",
"video": "映像", "video": "映像",
"audio": "音声", "audio": "音声",
"subtitle": "字幕", "subtitle": "字幕",
"play": "再生", "play": "再生",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "検索...", "search": "検索...",
@@ -556,6 +641,7 @@
"movies": "映画", "movies": "映画",
"series": "シリーズ", "series": "シリーズ",
"boxsets": "ボックスセット", "boxsets": "ボックスセット",
"playlists": "Playlists",
"items": "アイテム" "items": "アイテム"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "ポスター", "poster": "ポスター",
"cover": "カバー", "cover": "カバー",
"show_titles": "タイトルの表示", "show_titles": "タイトルの表示",
"show_stats": "統計を表示" "show_stats": "統計を表示",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "ジャンル", "genres": "ジャンル",
@@ -574,7 +661,11 @@
"sort_by": "ソート", "sort_by": "ソート",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "ソート順", "sort_order": "ソート順",
"tags": "タグ" "tags": "タグ",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "リンクがありません" "no_links": "リンクがありません"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "エラー", "error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした", "failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。", "an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "次", "next_up": "次",
@@ -617,6 +738,11 @@
"series": "シリーズ", "series": "シリーズ",
"seasons": "シーズン", "seasons": "シーズン",
"season": "シーズン", "season": "シーズン",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "このシーズンのエピソードはありません", "no_episodes_for_this_season": "このシーズンのエピソードはありません",
"overview": "ストーリー", "overview": "ストーリー",
"more_with": "{{name}}の詳細", "more_with": "{{name}}の詳細",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "画質", "quality": "画質",
"audio": "音声", "audio": "音声",
"subtitles": "字幕", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "もっと見る", "show_more": "もっと見る",
"show_less": "少なく表示", "show_less": "少なく表示",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "出演作品", "appeared_in": "出演作品",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "アイテムを読み込めませんでした", "could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし", "none": "なし",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}}のアイテムをダウンロード", "download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_unwatched_only": "未視聴のみ", "download_unwatched_only": "未視聴のみ",
"download_button": "ダウンロード" "download_button": "ダウンロード"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}, },
"live_tv": { "live_tv": {
"next": "次", "next": "次",
@@ -652,7 +795,18 @@
"movies": "映画", "movies": "映画",
"sports": "スポーツ", "sports": "スポーツ",
"for_kids": "子供向け", "for_kids": "子供向け",
"news": "ニュース" "news": "ニュース",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
}, },
"jellyseerr": { "jellyseerr": {
"confirm": "確認", "confirm": "確認",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。", "jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。", "jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
@@ -716,7 +876,8 @@
"search": "検索", "search": "検索",
"library": "ライブラリ", "library": "ライブラリ",
"custom_links": "カスタムリンク", "custom_links": "カスタムリンク",
"favorites": "お気に入り" "favorites": "お気に入り",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

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