Compare commits

..

32 Commits

Author SHA1 Message Date
renovate[bot]
a5afa735fd chore(deps): Update github/codeql-action action to v4.36.1 2026-06-05 16:17:59 +00:00
Chris
b0c9dc114c Revise bug report template for clarity and specificity
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
Updated issue report template to specify improvements for Streamyfin and enhanced placeholder text for clarity.
2026-06-05 17:54:07 +02:00
Chris
89190b5054 Revise issue report template for clarity and accuracy
Updated placeholders and descriptions for device and server information in issue report template.
2026-06-05 17:51:10 +02:00
Chris
394262af5a Update Streamyfin version options in issue template
Add new version options for Streamyfin in issue report template.
2026-06-05 17:37:35 +02:00
Gauvain
3dbe5bb64c ci(issues): detect likely-duplicate issues on open (#1645)
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 / 🔍 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 / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-05 14:21:12 +02:00
Gauvain
801ab275ab fix(icon): downsize the icon and make them appropriate (#1660) 2026-06-05 11:29:38 +02:00
lance chant
f7033e7abb fix: player reporting when exiting and app splash load (#1662) 2026-06-05 08:14:45 +02:00
Alex
0d796d01b8 feat(mpv-ios): Fix controls not pressable after resuming from PIP (#1667)
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-04 21:44:04 +10: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
126 changed files with 9970 additions and 13413 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

View File

@@ -1,5 +1,5 @@
name: "🐛 Bug Report" name: "🐛 Bug Report"
description: Create a report to help us improve description: Create a report to help Streamyfin improve
title: "[Bug]: " title: "[Bug]: "
labels: labels:
- "🐛 bug" - "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes: attributes:
label: What happened? label: What happened?
description: A clear and concise description of what the bug is. description: A clear and concise description of what the bug is.
placeholder: Describe what happened in detail. placeholder: Describe what happened in detail, the more precise the better.
validations: validations:
required: true required: true
@@ -67,7 +67,7 @@ body:
attributes: attributes:
label: Which device and operating system are you using? label: Which device and operating system are you using?
description: Please provide your device model and OS version description: Please provide your device model and OS version
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14 placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
validations: validations:
required: true required: true
@@ -75,11 +75,11 @@ body:
id: version id: version
attributes: attributes:
label: Streamyfin Version label: Streamyfin Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you using?
options: options:
- 0.47.1 - 0.54.1
- 0.30.2 - 0.51.0
- older - Older
- TestFlight/Development build - TestFlight/Development build
validations: validations:
required: true required: true
@@ -90,9 +90,9 @@ body:
label: Jellyfin Server Information label: Jellyfin Server Information
description: Please provide details about your Jellyfin server description: Please provide details about your Jellyfin server
placeholder: | placeholder: |
- Jellyfin Server Version: e.g. 10.10.7 - Jellyfin Server Version: e.g. 10.11.10
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
- Connection: e.g. Local network, Remote via domain, VPN - Connection: e.g. Local network, remote via domain, VPN
- type: textarea - type: textarea
id: screenshots id: screenshots
@@ -104,7 +104,7 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs (if available) label: Relevant logs (if available)
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.** description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
render: shell render: shell
- type: textarea - type: textarea

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1

38
.github/workflows/detect-duplicate.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: 🔁 Detect Duplicate Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: detect-duplicate-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}

View File

@@ -1,9 +1,14 @@
name: 🚀 Release (EAS Build + Submit) name: 🚀 Release (EAS build + submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main. # On merge to main (gated by the `production` GitHub Environment approval),
# A manual approval gate (the `production` GitHub Environment) pauses the run # build all targets on EAS in parallel via custom bun build configs:
# before any build/submit starts. Configure required reviewers on that # 1. iOS phone → App Store (auto-submit)
# environment in repo Settings → Environments → production. # 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: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
@@ -23,7 +28,7 @@ jobs:
- name: ✅ Release approved - name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}" run: echo "Release approved for ${{ github.sha }}"
release: build:
name: 🚀 ${{ matrix.name }} name: 🚀 ${{ matrix.name }}
needs: approve needs: approve
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -36,12 +41,25 @@ jobs:
- name: 🍎 iOS - name: 🍎 iOS
platform: ios platform: ios
profile: production profile: production
submit: true
- name: 📺 tvOS - name: 📺 tvOS
platform: ios platform: ios
profile: production_tv profile: production_tv
- name: 🤖 Android submit: true
- name: 🤖 Android AAB
platform: android platform: android
profile: production 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: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
@@ -76,10 +94,8 @@ jobs:
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning # tvOS uses credentialsSource: local — restore the gitignored
# remotely, including the TopShelf extension target). Restore the # credentials.json + cert + provisioning profiles from secrets.
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
- name: 🔐 Restore tvOS signing credentials - name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv' if: matrix.profile == 'production_tv'
env: env:
@@ -94,10 +110,14 @@ jobs:
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision 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 echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key. # Android Play submit needs the Google Play service account JSON.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID / - name: 🔐 Restore Google Play service account
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8, if: matrix.platform == 'android' && matrix.submit
# tolerating either raw-PEM or base64-encoded secret content. 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 - name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios' if: matrix.platform == 'ios'
env: env:
@@ -109,18 +129,11 @@ jobs:
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8" printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi fi
# Android submit needs a Google Play service account JSON. eas.json's # ── Submit builds: cloud build + auto-submit to the store ──
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
- name: 🚀 Build & submit (${{ matrix.name }}) - name: 🚀 Build & submit (${{ matrix.name }})
if: matrix.submit
env: env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8 EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }} EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }} EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
@@ -129,4 +142,75 @@ jobs:
--platform ${{ matrix.platform }} \ --platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \ --profile ${{ matrix.profile }} \
--auto-submit \ --auto-submit \
--non-interactive --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

3
.gitignore vendored
View File

@@ -76,6 +76,9 @@ 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

View File

@@ -1,3 +1,9 @@
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";
@@ -18,11 +24,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types"; import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
export default function DownloadsPage() { export default function DownloadsPage() {
@@ -31,7 +32,7 @@ export default function DownloadsPage() {
const [_queue, _setQueue] = useAtom(queueAtom); const [_queue, _setQueue] = useAtom(queueAtom);
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload(); const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
const router = useRouter(); const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false); const [showMigration, setShowMigration] = useState(false);
@@ -267,13 +268,19 @@ export default function DownloadsPage() {
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
enablePanDownToClose
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
> >
<BottomSheetView> <BottomSheetView>
<View className='p-4 space-y-4 mb-4'> <View className='p-4 space-y-4 mb-4'>

View File

@@ -59,17 +59,19 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
<View className='mb-4'> {Platform.OS !== "ios" && (
<ListGroup title={t("pairing.pair_with_phone_title")}> <View className='mb-4'>
<ListItem <ListGroup title={t("pairing.pair_with_phone_title")}>
onPress={() => <ListItem
router.push("/(auth)/(tabs)/(home)/companion-login") onPress={() =>
} router.push("/(auth)/(tabs)/(home)/companion-login")
title={t("pairing.pair_with_phone")} }
textColor='blue' title={t("pairing.pair_with_phone")}
/> textColor='blue'
</ListGroup> />
</View> </ListGroup>
</View>
)}
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />

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

@@ -1,4 +1,12 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} 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";
@@ -24,12 +32,6 @@ import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
@@ -75,8 +77,8 @@ 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<BottomSheetMethods>(null); const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {
data: details, data: details,
@@ -142,6 +144,17 @@ const MobilePage: React.FC = () => {
} }
}, [jellyseerrApi, pendingRequest, refetch, t]); }, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) { if (result.id && issueType && issueMessage && details) {
jellyseerrApi jellyseerrApi
@@ -466,7 +479,6 @@ const MobilePage: React.FC = () => {
// This is till it's fixed because the menu isn't selectable on TV // This is till it's fixed because the menu isn't selectable on TV
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing enableDynamicSizing
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
@@ -474,6 +486,8 @@ const MobilePage: React.FC = () => {
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss} onDismiss={handleIssueModalDismiss}
> >
<BottomSheetView> <BottomSheetView>

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

@@ -102,8 +102,8 @@ export default function TabLayout() {
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab, !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/list.star.png")
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }), : (_e) => ({ sfSymbol: "list.star" }),
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -112,7 +112,7 @@ export default function TabLayout() {
title: t("tabs.library"), title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png") ? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }), : (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
}} }}
/> />
@@ -123,8 +123,8 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/link.png")
: (_e) => ({ sfSymbol: "list.dash.fill" }), : (_e) => ({ sfSymbol: "link" }),
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -134,7 +134,7 @@ export default function TabLayout() {
tabBarItemHidden: !Platform.isTV, tabBarItemHidden: !Platform.isTV,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform ? (_e) => require("@/assets/icons/gearshape.fill.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }), : (_e) => ({ sfSymbol: "gearshape.fill" }),
}} }}
/> />

View File

@@ -1,5 +1,6 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view"; import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -221,129 +222,133 @@ export default function NowPlayingScreen() {
if (!currentTrack) { if (!currentTrack) {
return ( return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
</BottomSheetModalProvider>
);
}
return (
<BottomSheetModalProvider>
<View <View
className='flex-1 bg-[#121212] items-center justify-center' className='flex-1 bg-[#121212]'
style={{ style={{
paddingTop: Platform.OS === "android" ? insets.top : 0, paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> {/* Header */}
</View> <View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
);
}
return (
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
<TouchableOpacity <TouchableOpacity
onPress={() => setViewMode("player")} onPress={handleClose}
className='px-3 py-1' hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
> >
<Text <Ionicons name='chevron-down' size={28} color='white' />
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")} <View className='flex-row'>
className='px-3 py-1' <TouchableOpacity
> onPress={() => setViewMode("player")}
<Text className='px-3 py-1'
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
> >
Queue ({queue.length}) <Text
</Text> className={
</TouchableOpacity> viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View> </View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} /> {viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View> </View>
</BottomSheetModalProvider>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
); );
} }

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(() => {
@@ -427,21 +439,15 @@ export default function DirectPlayerPage() {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({ await getPlaystateApi(api).reportPlaybackStopped({
itemId: item.Id, playbackStopInfo: {
mediaSourceId: mediaSourceId, ItemId: item.Id,
positionTicks: currentTimeInTicks, MediaSourceId: mediaSourceId,
playSessionId: stream.sessionId, PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
},
}); });
}, [ }, [api, item, mediaSourceId, stream, progress, offline]);
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping // Update URL with final playback position before stopping
@@ -459,9 +465,10 @@ export default function DirectPlayerPage() {
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => { return () => {
reportPlaybackStopped();
beforeRemoveListener(); beforeRemoveListener();
}; };
}, [navigation, stop]); }, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback((): const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo | PlaybackProgressInfo

View File

@@ -1,5 +1,6 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query"; import { onlineManager, QueryClient } from "@tanstack/react-query";
@@ -411,125 +412,127 @@ function Layout() {
<DownloadProvider> <DownloadProvider>
<MusicPlayerProvider> <MusicPlayerProvider>
<GlobalModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</GlobalModalProvider> </GlobalModalProvider>
</MusicPlayerProvider> </MusicPlayerProvider>
</DownloadProvider> </DownloadProvider>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icons/link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/icons/list.star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,65 +0,0 @@
<svg
type="certified"
viewBox="0 0 80 80"
preserveAspectRatio="xMidYMid"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g transform="translate(2.29, 0)">
<path
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
id="Fill-2"
fill="#00912D"
></path>
<mask id="mask-2" fill="white">
<polygon
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
></polygon>
</mask>
<path
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
fill="#FFD700"
mask="url(#mask-2)"
></path>
<path
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
fill="#FA6E0F"
mask="url(#mask-2)"
></path>
<path
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
fill="#0AC855"
mask="url(#mask-2)"
></path>
<path
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
fill="#0B4902"
></path>
<g transform="translate(0, 20.57)">
<mask id="mask-4" fill="white">
<polygon
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
></polygon>
</mask>
<path
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
fill="#FA3200"
mask="url(#mask-4)"
></path>
<path
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
fill="#0AC855"
mask="url(#mask-4)"
></path>
</g>
<path
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
fill="#00912D"
></path>
<path
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
fill="#0B4902"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

13767
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -6,11 +12,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Swipeable } from "react-native-gesture-handler"; import { Swipeable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { import {
deleteAccountCredential, deleteAccountCredential,
type SavedServer, type SavedServer,
@@ -38,7 +39,7 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const isAndroid = Platform.OS === "android"; const isAndroid = Platform.OS === "android";
const snapPoints = useMemo( const snapPoints = useMemo(
@@ -63,6 +64,17 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeleteAccount = async (account: SavedServerAccount) => { const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!server) return; if (!server) return;
@@ -106,16 +118,15 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
); );
if (!server) return null; if (!server) return null;
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }} handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }} backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
> >
<BottomSheetView <BottomSheetView
style={{ style={{

View File

@@ -1,4 +1,10 @@
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -17,11 +23,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl"; import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -89,7 +90,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user], [user],
); );
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => { const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
@@ -316,6 +317,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
} }
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]); }, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const renderButtonContent = () => { const renderButtonContent = () => {
// For single item downloads, show progress if item is being processed // For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued // For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
@@ -363,8 +375,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
} }
}; };
if (Platform.isTV) return null;
return ( return (
<View {...props}> <View {...props}>
<RoundButton size={size} onPress={onButtonPress}> <RoundButton size={size} onPress={onButtonPress}>
@@ -380,7 +390,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose
android_keyboardInputMode='adjustResize'
keyboardBehavior='interactive' keyboardBehavior='interactive'
keyboardBlurBehavior='restore' keyboardBlurBehavior='restore'
> >

View File

@@ -1,7 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
/** /**
* GlobalModal Component * GlobalModal Component
@@ -9,7 +12,8 @@ import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
* This component renders a global bottom sheet modal that can be controlled * This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook. * from anywhere in the app using the useGlobalModal hook.
* *
* Place this component at the root level of your app (in _layout.tsx). * Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/ */
export const GlobalModal = () => { export const GlobalModal = () => {
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal(); const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
@@ -29,6 +33,17 @@ export const GlobalModal = () => {
[hideModal], [hideModal],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = { const defaultOptions = {
enableDynamicSizing: true, enableDynamicSizing: true,
enablePanDownToClose: true, enablePanDownToClose: true,
@@ -43,8 +58,6 @@ export const GlobalModal = () => {
// Merge default options with provided options // Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options }; const modalOptions = { ...defaultOptions, ...modalState.options };
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={modalRef} ref={modalRef}
@@ -52,9 +65,12 @@ export const GlobalModal = () => {
? { snapPoints: modalOptions.snapPoints } ? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })} : { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle} handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle} backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose} enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
stackBehavior='push'
style={{ zIndex: 1000 }} style={{ zIndex: 1000 }}
> >
{modalState.content} {modalState.content}

View File

@@ -1,4 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -7,11 +13,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
export interface IntroSheetRef { export interface IntroSheetRef {
@@ -20,7 +21,7 @@ export interface IntroSheetRef {
} }
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => { export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetMethods>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
@@ -35,6 +36,17 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
}, },
})); }));
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.dismiss();
}, []); }, []);
@@ -44,13 +56,11 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
router.push("/settings"); router.push("/settings");
}, []); }, []);
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
enablePanDownToClose
enableDynamicSizing enableDynamicSizing
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: "#171717" }} backgroundStyle={{ backgroundColor: "#171717" }}
handleIndicatorStyle={{ backgroundColor: "#737373" }} handleIndicatorStyle={{ backgroundColor: "#737373" }}
> >
@@ -79,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text> </Text>
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<Image <Image
source={require("@/assets/icons/jellyseerr-logo.svg")} source={require("@/assets/icons/seerr-logo.svg")}
style={{ style={{
width: 50, width: 50,
height: 50, height: 50,
}} }}
/> />
<View className='shrink ml-2'> <View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text> <Text className='font-bold mb-1'>Seerr</Text>
<Text className='shrink text-xs'> <Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")} {t("home.intro.jellyseerr_feature_description")}
</Text> </Text>

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type { import type {
MediaSourceInfo, MediaSourceInfo,
MediaStream, MediaStream,
@@ -6,13 +12,8 @@ import type {
import type React from "react"; import type React from "react";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate"; import { formatBitrate } from "@/utils/bitrate";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -21,20 +22,9 @@ interface Props {
} }
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
if (Platform.isTV) {
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
);
}
return ( return (
<View className='px-4 mt-2 mb-4'> <View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text> <Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
@@ -46,27 +36,27 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
</TouchableOpacity> </TouchableOpacity>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing snapPoints={["80%"]}
enablePanDownToClose
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
> >
<BottomSheetScrollView> <BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4'> <View className='flex flex-col space-y-2 p-4 mb-4'>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-bold'>{t("item_card.video")}</Text>
<TouchableOpacity
onPress={() => bottomSheetModalRef.current?.dismiss()}
hitSlop={12}
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<View> <View>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'> <View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,11 +17,6 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { verifyAccountPIN } from "@/utils/secureCredentials"; import { verifyAccountPIN } from "@/utils/secureCredentials";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -42,7 +43,7 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [pinCode, setPinCode] = useState(""); const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
@@ -77,6 +78,17 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
[onClose], [onClose],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const shake = () => { const shake = () => {
Animated.sequence([ Animated.sequence([
Animated.timing(shakeAnimation, { Animated.timing(shakeAnimation, {
@@ -147,18 +159,18 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
]); ]);
}; };
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }} handleIndicatorStyle={{ 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
style={{ style={{

View File

@@ -1,15 +1,16 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, Platform, View } from "react-native"; import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -28,7 +29,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -61,6 +62,17 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
[onClose], [onClose],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!password) { if (!password) {
setError(t("password.enter_password")); setError(t("password.enter_password"));
@@ -81,18 +93,18 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
} }
}; };
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }} handleIndicatorStyle={{ 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
style={{ style={{

View File

@@ -1,16 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useEffect, useState } from "react"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { import React, { useEffect } from "react";
type LayoutChangeEvent, import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
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";
import { BottomSheetScrollView } from "@/utils/expoUiBottomSheet";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. // @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module // A static top-level import evaluates requireNativeModule('ExpoUI') at module
@@ -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

@@ -1,5 +1,6 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
@@ -31,7 +32,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { BottomSheetView } from "@/utils/expoUiBottomSheet";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";

View File

@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
<Image <Image
source={ source={
item.CriticRating < 60 item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png") ? require("@/assets/images/rt_rotten.svg")
: require("@/assets/images/not-rotten-tomatoes.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.criticsRating === "Rotten" data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg") ? require("@/assets/images/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.audienceRating === "Spilled" data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg") ? require("@/assets/images/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg") : require("@/assets/images/rt_aud_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")} source={require("@/assets/images/tmdb_logo.svg")}
style={{ style={{
width: 14, width: 14,
height: 14, height: 14,

View File

@@ -39,21 +39,19 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable onPress={handlePress} {...(viewProps as any)}> <Pressable
<BlurView onPress={handlePress}
intensity={50} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
tint='systemChromeMaterial' {...(viewProps as any)}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`} >
> {icon ? (
{icon ? ( <Ionicons
<Ionicons name={icon}
name={icon} size={size === "large" ? 22 : 18}
size={size === "large" ? 22 : 18} color={color === "white" ? "white" : "#9334E9"}
color={color === "white" ? "white" : "#9334E9"} />
/> ) : null}
) : null} {children ? children : null}
{children ? children : null}
</BlurView>
</Pressable> </Pressable>
); );
} }

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { AccountSecurityType } from "@/utils/secureCredentials"; import type { AccountSecurityType } from "@/utils/secureCredentials";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -57,7 +58,7 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none"); const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
const [pinCode, setPinCode] = useState(""); const [pinCode, setPinCode] = useState("");
const [pinError, setPinError] = useState<string | null>(null); const [pinError, setPinError] = useState<string | null>(null);
@@ -92,6 +93,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
setPinError(null); setPinError(null);
}; };
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleOptionSelect = (type: AccountSecurityType) => { const handleOptionSelect = (type: AccountSecurityType) => {
setSelectedType(type); setSelectedType(type);
setPinCode(""); setPinCode("");
@@ -123,18 +135,18 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
return true; return true;
}; };
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }} handleIndicatorStyle={{ 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
style={{ style={{

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

@@ -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

@@ -1,10 +1,15 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Platform,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -12,11 +17,6 @@ import {
} from "react-native"; } 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 {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
@@ -75,7 +75,7 @@ export const FilterSheet = <T,>({
disableSearch = false, disableSearch = false,
multiple = false, multiple = false,
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []); const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -143,15 +143,24 @@ export const FilterSheet = <T,>({
return data; return data;
}, [search, filteredData, data]); }, [search, filteredData, data]);
if (Platform.isTV) return null; const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
index={0} index={0}
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

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

@@ -1,3 +1,4 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react"; import React, { useCallback, useImperativeHandle, useRef } from "react";
import { import {
type StyleProp, type StyleProp,
@@ -7,7 +8,6 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@/utils/expoUiBottomSheet";
interface PinInputProps interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> { extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {

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

@@ -1,22 +1,23 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} 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 { forwardRef, useCallback, useMemo, useState } from "react"; import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native"; import { View, type ViewProps } 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 { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { import type {
QualityProfile, QualityProfile,
RootFolder, RootFolder,
Tag, Tag,
} from "@/utils/jellyseerr/server/api/servarr/base"; } from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { writeDebugLog } from "@/utils/log"; import { writeDebugLog } from "@/utils/log";
@@ -33,7 +34,7 @@ interface Props {
} }
const RequestModal = forwardRef< const RequestModal = forwardRef<
BottomSheetMethods, BottomSheetModalMethods,
Props & Omit<ViewProps, "id"> Props & Omit<ViewProps, "id">
>( >(
( (
@@ -282,13 +283,11 @@ const RequestModal = forwardRef<
defaultTags, defaultTags,
]); ]);
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={ref} ref={ref}
enablePanDownToClose
enableDynamicSizing enableDynamicSizing
enableDismissOnClose
onDismiss={handleDismiss} onDismiss={handleDismiss}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
@@ -296,6 +295,14 @@ const RequestModal = forwardRef<
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={(sheetProps: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
stackBehavior='push'
> >
<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'>

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

@@ -1,3 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
@@ -6,17 +13,11 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, Keyboard, Platform } from "react-native"; import { ActivityIndicator, Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations"; import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -31,7 +32,7 @@ export const CreatePlaylistModal: React.FC<Props> = ({
onPlaylistCreated, onPlaylistCreated,
initialTrackId, initialTrackId,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation(); const { t } = useTranslation();
const createPlaylist = useCreatePlaylist(); const createPlaylist = useCreatePlaylist();
@@ -58,6 +59,17 @@ export const CreatePlaylistModal: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleCreate = useCallback(async () => { const handleCreate = useCallback(async () => {
if (!name.trim()) return; if (!name.trim()) return;
@@ -74,15 +86,13 @@ export const CreatePlaylistModal: React.FC<Props> = ({
const isValid = name.trim().length > 0; const isValid = name.trim().length > 0;
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
index={0} index={0}
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

View File

@@ -1,23 +1,18 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
Alert,
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 useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations"; import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -30,7 +25,7 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
setOpen, setOpen,
playlist, playlist,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,6 +47,17 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeletePlaylist = useCallback(() => { const handleDeletePlaylist = useCallback(() => {
if (!playlist?.Id) return; if (!playlist?.Id) return;
@@ -83,15 +89,14 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
}, [playlist, deletePlaylist, setOpen, router, t]); }, [playlist, deletePlaylist, setOpen, router, t]);
if (!playlist) return null; if (!playlist) return null;
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
index={0} index={0}
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -14,7 +20,6 @@ import React, {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -24,11 +29,6 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations"; import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -43,7 +43,7 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
trackToAdd, trackToAdd,
onCreateNew, onCreateNew,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -101,6 +101,17 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSelectPlaylist = useCallback( const handleSelectPlaylist = useCallback(
async (playlist: BaseItemDto) => { async (playlist: BaseItemDto) => {
if (!trackToAdd?.Id || !playlist.Id) return; if (!trackToAdd?.Id || !playlist.Id) return;
@@ -131,15 +142,13 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[api], [api],
); );
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
index={0} index={0}
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { import {
type BottomSheetMethods, BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@/utils/expoUiBottomSheet"; } from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
export type PlaylistSortOption = "SortName" | "DateCreated"; export type PlaylistSortOption = "SortName" | "DateCreated";
@@ -42,7 +43,7 @@ export const PlaylistSortSheet: React.FC<Props> = ({
sortOrder, sortOrder,
onSortChange, onSortChange,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -62,6 +63,17 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSortSelect = useCallback( const handleSortSelect = useCallback(
(option: PlaylistSortOption) => { (option: PlaylistSortOption) => {
// If selecting same option, toggle order; otherwise use sensible default // If selecting same option, toggle order; otherwise use sensible default
@@ -81,15 +93,13 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[sortBy, sortOrder, onSortChange, setOpen], [sortBy, sortOrder, onSortChange, setOpen],
); );
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
index={0} index={0}
snapPoints={snapPoints} snapPoints={snapPoints}
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -12,7 +18,6 @@ import React, {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -31,11 +36,6 @@ import {
} from "@/providers/AudioStorage"; } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -56,7 +56,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
playlistId, playlistId,
onRemoveFromPlaylist, onRemoveFromPlaylist,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
@@ -128,6 +128,17 @@ export const TrackOptionsSheet: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handlePlayNext = useCallback(() => { const handlePlayNext = useCallback(() => {
if (track) { if (track) {
playNext(track); playNext(track);
@@ -216,14 +227,13 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const hasAlbum = !!(track?.AlbumId || track?.ParentId); const hasAlbum = !!(track?.AlbumId || track?.ParentId);
if (!track) return null; if (!track) return null;
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing enableDynamicSizing
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

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);

View File

@@ -1,9 +1,14 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Platform,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -11,11 +16,6 @@ import {
} from "react-native"; } 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 {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface LibraryOptions { interface LibraryOptions {
display: "row" | "list"; display: "row" | "list";
@@ -132,7 +132,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
updateSettings, updateSettings,
disabled = false, disabled = false,
}) => { }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -161,14 +161,25 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
[setOpen], [setOpen],
); );
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null; if (disabled) return null;
if (Platform.isTV) return null;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
onChange={handleSheetChanges} onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
@@ -176,6 +187,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose
> >
<BottomSheetView> <BottomSheetView>
<View <View
@@ -217,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

@@ -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

@@ -229,7 +229,10 @@ export const PlaybackControlsSettings: React.FC = () => {
<ListItem <ListItem
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 ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
> >
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}

View File

@@ -1,3 +1,9 @@
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 type React from "react";
@@ -6,11 +12,6 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, View, type ViewProps } 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";
@@ -24,7 +25,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>(); const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
const snapPoints = useMemo( const snapPoints = useMemo(
@@ -35,6 +36,17 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => { const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) { if (quickConnectCode) {
try { try {
@@ -85,7 +97,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
@@ -93,8 +104,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", 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'>

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

@@ -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,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { import React, {
forwardRef, forwardRef,
@@ -10,7 +16,6 @@ import React, {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -26,11 +31,6 @@ import {
useItemInWatchlists, useItemInWatchlists,
useMyWatchlistsQuery, useMyWatchlistsQuery,
} from "@/hooks/useWatchlists"; } from "@/hooks/useWatchlists";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
export interface WatchlistSheetRef { export interface WatchlistSheetRef {
@@ -263,7 +263,7 @@ const WatchlistSheetContent: React.FC<WatchlistSheetContentProps> = ({
export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>( export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
(_props, ref) => { (_props, ref) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [currentItem, setCurrentItem] = React.useState<BaseItemDto | null>( const [currentItem, setCurrentItem] = React.useState<BaseItemDto | null>(
null, null,
); );
@@ -283,13 +283,23 @@ export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
}, []); }, []);
if (Platform.isTV) return null; const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing enableDynamicSizing
maxDynamicContentSize={600}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}

View File

@@ -56,7 +56,11 @@
"environment": "production", "environment": "production",
"autoIncrement": true, "autoIncrement": true,
"android": { "android": {
"image": "latest" "image": "latest",
"config": "android-production.yml"
},
"ios": {
"config": "ios-production.yml"
} }
}, },
"production-apk": { "production-apk": {
@@ -65,7 +69,8 @@
"autoIncrement": true, "autoIncrement": true,
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest",
"config": "android-production-apk.yml"
} }
}, },
"production-apk-tv": { "production-apk-tv": {
@@ -74,7 +79,8 @@
"autoIncrement": true, "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"
@@ -88,7 +94,8 @@
"EXPO_TV": "1" "EXPO_TV": "1"
}, },
"ios": { "ios": {
"credentialsSource": "local" "credentialsSource": "local",
"config": "ios-production.yml"
} }
} }
}, },

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

@@ -213,7 +213,7 @@ public 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

@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher()
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -81,7 +82,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 +141,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 +275,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 +446,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 +526,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())
} }
} }
@@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)") print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts // Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
// is `false` when AVKit reports a failure to start, so reflect that.
onPictureInPictureChange(["isActive": didStartPictureInPicture])
} }
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill { if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill displayLayer.videoGravity = .resizeAspectFill
} }
// Notify JS that PiP has fully stopped so the controls overlay can
// be re-mounted when the user returns to full screen.
onPictureInPictureChange(["isActive": false])
} }
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

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,6 +32,7 @@
"@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.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/material-top-tabs": "7.4.28",

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

@@ -1,3 +1,4 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type React from "react"; import type React from "react";
import { import {
createContext, createContext,
@@ -10,7 +11,6 @@ import {
} from "react"; } from "react";
import { BackHandler, Platform } from "react-native"; import { BackHandler, Platform } from "react-native";
import type { BottomSheetMethods } from "@/utils/expoUiBottomSheet";
interface ModalOptions { interface ModalOptions {
enableDynamicSizing?: boolean; enableDynamicSizing?: boolean;
@@ -30,7 +30,7 @@ interface GlobalModalContextType {
hideModal: () => void; hideModal: () => void;
isVisible: boolean; isVisible: boolean;
modalState: GlobalModalState; modalState: GlobalModalState;
modalRef: React.RefObject<BottomSheetMethods | null>; modalRef: React.RefObject<BottomSheetModal | null>;
} }
const GlobalModalContext = createContext<GlobalModalContextType | undefined>( const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
@@ -57,7 +57,7 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
options: undefined, options: undefined,
}); });
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const modalRef = useRef<BottomSheetMethods>(null); const modalRef = useRef<BottomSheetModal>(null);
const showModal = useCallback( const showModal = useCallback(
(content: ReactNode, options?: ModalOptions) => { (content: ReactNode, options?: ModalOptions) => {

View File

@@ -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;
@@ -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);
@@ -611,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
const response = await getUserApi(apiInstance).getCurrentUser(); // Dismiss splash screen with cached data immediately,
setUser(response.data); // fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved try {
if (storedUser?.Id && storedUser?.Name) { const response = await getUserApi(apiInstance).getCurrentUser();
const existingCredential = await getAccountCredential( setUser(response.data);
serverUrl,
storedUser.Id, // Migrate current session to secure storage if not already saved
); if (storedUser?.Id && storedUser?.Name) {
if (!existingCredential) { const existingCredential = await getAccountCredential(
await saveAccountCredential({
serverUrl, serverUrl,
serverName: "", storedUser.Id,
token, );
userId: storedUser.Id, if (!existingCredential) {
username: storedUser.Name, await saveAccountCredential({
savedAt: Date.now(), serverUrl,
securityType: "none", serverName: "",
primaryImageTag: response.data.PrimaryImageTag ?? undefined, token,
}); userId: storedUser.Id,
} else if ( username: storedUser.Name,
response.data.PrimaryImageTag !== savedAt: Date.now(),
existingCredential.primaryImageTag securityType: "none",
) { primaryImageTag: response.data.PrimaryImageTag ?? undefined,
// Update image tag if it has changed });
addAccountToServer(serverUrl, existingCredential.serverName, { } else if (
userId: existingCredential.userId, response.data.PrimaryImageTag !==
username: existingCredential.username, existingCredential.primaryImageTag
securityType: existingCredential.securityType, ) {
savedAt: existingCredential.savedAt, // Update image tag if it has changed
primaryImageTag: response.data.PrimaryImageTag ?? undefined, addAccountToServer(serverUrl, existingCredential.serverName, {
}); userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
} }
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
} }
} else {
setInitialLoaded(true);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bun
/**
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
*
* On a match it posts ONE comment listing the closest open issues and adds the
* "possible duplicate" label. If nothing is similar enough, it does nothing.
*
* Env:
* GITHUB_REPOSITORY owner/repo
* ISSUE_NUMBER the new issue number
* ISSUE_TITLE the new issue title
* ISSUE_BODY the new issue body
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
* DUP_MAX max matches to report (default 5)
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
* DRY_RUN if set, print results instead of commenting/labelling
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
return Number.isNaN(n) ? def : n;
};
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN);
const TITLE = process.env.ISSUE_TITLE || "";
const BODY = process.env.ISSUE_BODY || "";
const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3);
const MAX = numEnv("DUP_MAX", 5);
const DRY = !!process.env.DRY_RUN;
const LABEL = "possible duplicate";
const MARKER = "<!-- duplicate-detector -->";
// Generic stop words only — keep domain/feature/platform words (android, downloads,
// subtitles…) since those are exactly what makes two reports the same or different.
const STOP = new Set(
(
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
"it its this that these those i you we they me my your our their he she him her " +
"when while where what which who how why so just then than too very can could would should will " +
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
"app application streamyfin issue bug"
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
.replace(/https?:\/\/\S+/g, " ") // drop urls
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP.has(w))
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
let inter = 0;
for (const x of A) if (B.has(x)) inter++;
return inter / (A.size + B.size - inter);
};
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
} else {
const raw = execFileSync(
"gh",
[
"api",
`repos/${REPO}/issues`,
"--paginate",
"-X",
"GET",
"-f",
"state=open",
"-f",
"per_page=100",
"--jq",
".[] | select(.pull_request | not) | {number, title, body}",
],
{ encoding: "utf8", maxBuffer: 1e8 },
);
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
const matches = issues
.filter((o) => o.number !== NUMBER)
.map((o) => ({ ...o, s: score(o) }))
.filter((o) => o.s >= THRESHOLD)
.sort((a, b) => b.s - a.s)
.slice(0, MAX);
if (!matches.length) {
console.log("No likely duplicates found.");
process.exit(0);
}
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 140);
const list = matches
.map(
(m) =>
`- #${m.number}${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
)
.join("\n");
const comment = [
MARKER,
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
"",
list,
"",
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
].join("\n");
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
if (DRY) {
console.log("\nDRY_RUN: not commenting/labelling.");
process.exit(0);
}
// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/...
if (!Number.isInteger(NUMBER) || NUMBER <= 0) {
console.error(
`Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} — refusing to comment.`,
);
process.exit(1);
}
// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers).
const priorComments = execFileSync(
"gh",
[
"api",
`repos/${REPO}/issues/${NUMBER}/comments`,
"--paginate",
"--jq",
".[].body",
],
{ encoding: "utf8", maxBuffer: 1e8 },
);
if (priorComments.includes(MARKER)) {
console.log("Already flagged (marker present); skipping.");
process.exit(0);
}
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/comments`,
"-f",
`body=${comment}`,
],
{ stdio: "ignore" },
);
try {
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
} catch {
// label may not exist yet — create then add
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/labels`,
"-f",
`name=${LABEL}`,
"-f",
"color=fbca04",
"-f",
"description=Automatically flagged as a possible duplicate",
],
{ stdio: "ignore" },
);
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
}
console.log("Commented and labelled.");

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.",
@@ -609,7 +702,34 @@
"downloaded_file_yes": "Ja", "downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein", "downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen", "downloaded_file_cancel": "Abbrechen",
"ends_at": "Endet um {{time}}" "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",
@@ -618,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}}",
@@ -628,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": {
@@ -642,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",
@@ -653,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",
@@ -698,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.",
@@ -717,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",
@@ -842,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"
} }
} }

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