Compare commits

..

35 Commits

Author SHA1 Message Date
Gauvain
f95faacece Merge branch 'develop' into cleanup/dead-code 2026-06-02 23:36:59 +02:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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
🚦 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
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Gauvain
77d3bc2563 refactor: remove unused clearAllCredentials and log diagnostics helpers
clearAllCredentials() (secureCredentials.ts), clearLogs() and
dumpDownloadDiagnostics() (log.tsx) have no callers anywhere in the tree.
Carried over from the original #1347 cleanup.
2026-06-02 23:20:17 +02:00
Gauvain
89fb0d9624 chore(seerr): remove early-stage warning notice and stale optimize-server reference
Removes the red 'early stages' notice on the Seer plugin page (and its
jellyseerr_warning key), and drops the deprecated optimize-server mention from
the welcome screen downloads description. (Pre-release cleanup, Chris's list.)
2026-06-02 23:20:10 +02:00
Gauvain
7564463065 Merge branch 'develop' into cleanup/dead-code 2026-06-02 23:01:31 +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
Gauvain
f7cd413882 Merge branch 'develop' into cleanup/dead-code 2026-06-02 18:43:22 +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
d7fbe992ae Merge branch 'develop' into cleanup/dead-code 2026-06-02 22:51:20 +10: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) Waiting to run
🚀 Release (EAS build + submit) / 🚀 🤖 Android APK (push) Blocked by required conditions
🚀 Release (EAS build + submit) / 🚀 📺 Android TV APK (push) Blocked by required conditions
🚀 Release (EAS build + submit) / 🚀 🍎 iOS (push) Blocked by required conditions
🚀 Release (EAS build + submit) / 🚀 📺 tvOS (push) Blocked by required conditions
🚀 Release (EAS build + submit) / 🚀 🤖 Android AAB (push) Blocked by required conditions
🚀 Release (EAS build + submit) / 📦 Draft GitHub Release (push) Blocked by required conditions
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
Alex
13973dc53a Merge branch 'develop' into cleanup/dead-code 2026-06-01 19:24:11 +10: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
Gauvain
8eeb571f33 refactor: remove dead code
Delete unused components, hooks and utilities that have no remaining references, and drop their now-unused exports (augmentations/string, Number.prototype.hoursToMilliseconds). Also drop the dangling ExampleGlobalModalUsage reference from GLOBAL_MODAL_GUIDE.md.
2026-06-01 00:54:18 +02:00
Felix Schneider
6b7ee0514f feat(i18n): add new translations for action sheet options (#1475)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 23:45:45 +02:00
Fredrik Burmester
c663bd0413 fix(jellyseerr): correct RequestModal ref type to fix typecheck
advancedReqModalRef was typed as BottomSheetModal but RequestModal's
forwardRef expects BottomSheetModalMethods, causing a TS2322 error
that broke the Security & Quality Gate typecheck on develop.
2026-05-31 22:10:15 +02:00
Alex
52e6f56220 fix(auth): clear stored user on logout to prevent empty home on relaunch (#1622) 2026-05-31 21:52:41 +02:00
Fredrik Burmester
c981f59a50 fix(downloads): repair bottom sheets on iOS and restore downloads delete sheet
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Bump @gorhom/bottom-sheet 5.2.8 -> 5.2.14, which fixes BottomSheetModal
present() silently no-opping under Reanimated 4 / New Architecture (SDK 56).
Affected every sheet app-wide: present() was called with a valid ref but
nothing rendered (not even the backdrop). Verified on the iOS simulator that
the download options sheet now opens.

Also restore the downloads-page delete sheet (delete movies/series/other/all)
that was accidentally dropped in the Expo 54 rewrite (#1174), which left an
orphaned trigger button and underscore-silenced handlers.
2026-05-31 14:58:51 +02:00
Alex
62fc6f9a70 fix(progress-bar): Fix progress bar not reporting watch times (#1611)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 22:12:13 +10:00
Fredrik Burmester
eb8dd51b4e feat(ci): EAS build + auto-submit release workflow for main (#1616) 2026-05-31 13:23:02 +02:00
124 changed files with 6943 additions and 7069 deletions

View File

@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ - **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ - **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

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

View File

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

View File

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

View File

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

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

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

9
.gitignore vendored
View File

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

View File

@@ -143,14 +143,6 @@ interface ModalOptions {
} }
``` ```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling ## Default Styling
The modal uses these default styles (can be overridden via options): The modal uses these default styles (can be overridden via options):

View File

@@ -7,9 +7,6 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -36,7 +33,6 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -49,8 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -129,7 +128,6 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -139,24 +137,9 @@ export default function DirectPlayerPage() {
offline: string; offline: string;
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
/** Whether playback was initiated by SyncPlay */
syncPlay?: string;
}>(); }>();
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
const openedViaSyncPlay = syncPlayStr === "true";
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyReady,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true"; const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -291,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]);
@@ -333,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;
@@ -405,6 +399,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {
@@ -420,102 +415,8 @@ export default function DirectPlayerPage() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
// SyncPlay: Connect player controls when video is ready
useEffect(() => {
if (!isVideoLoaded || !videoRef.current || offline) {
setPlayerControls(null);
return;
}
const controls: PlayerControls = {
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (positionMs: number) => {
const positionSec = positionMs / 1000;
console.log(
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
);
videoRef.current?.seekTo(positionSec);
},
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
getSpeed: () => currentPlaybackSpeed,
getCurrentPosition: () => progress.get(),
isPlaying: () => isPlaying,
isBuffering: () => isBuffering,
};
setPlayerControls(controls);
return () => {
setPlayerControls(null);
};
}, [
isVideoLoaded,
offline,
isPlaying,
isBuffering,
currentPlaybackSpeed,
progress,
setPlayerControls,
]);
// SyncPlay: Report buffering/ready state to server.
//
// CRITICAL: We must report `buffering` to the server *during* initial
// load (before `isVideoLoaded`), otherwise the server treats us as ready
// and proceeds without waiting for us. jellyfin-web reports this for
// free via the HTML5 video element's `waiting` event; for us, the
// initial load itself is the buffering window.
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const isLocallyReady = isVideoLoaded && !isBuffering;
if (isLocallyReady) {
notifyReady();
} else {
notifyBuffering();
}
}, [
isSyncPlayEnabled,
isVideoLoaded,
isBuffering,
notifyReady,
notifyBuffering,
]);
// SyncPlay: Pause playback when group is waiting
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const groupState = syncPlay.groupInfo?.State;
const isLocalReady = isVideoLoaded && !isBuffering;
const isWaitingForGroup = groupState === "Waiting";
// Pause playback when waiting for group
if (isLocalReady && isWaitingForGroup && isPlaying) {
videoRef.current?.pause();
}
}, [
isSyncPlayEnabled,
syncPlay.groupInfo?.State,
isVideoLoaded,
isBuffering,
isPlaying,
]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
@@ -749,12 +650,10 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks); const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming // Build source config - headers only needed for online streaming
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
const shouldAutoplay = !openedViaSyncPlay;
const source: MpvVideoSource = { const source: MpvVideoSource = {
url: stream.url, url: stream.url,
startPosition: startPos, startPosition: startPos,
autoplay: shouldAutoplay, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences // Pass cache/buffer settings from user preferences
@@ -973,20 +872,10 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback( const seek = useCallback((position: number) => {
(position: number) => { // MPV expects seconds, convert from ms
// Route through SyncPlay when active videoRef.current?.seekTo?.(position / 1000);
if (isSyncPlayEnabled && syncPlayController) { }, []);
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seekMs(position);
return;
}
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
// TV audio track change handler // TV audio track change handler
const handleAudioIndexChange = useCallback( const handleAudioIndexChange = useCallback(

View File

@@ -25,7 +25,6 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -410,136 +409,133 @@ function Layout() {
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<SyncPlayProvider> <DownloadProvider>
<DownloadProvider> <MusicPlayerProvider>
<MusicPlayerProvider> <GlobalModalProvider>
<GlobalModalProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <IntroSheetProvider>
<IntroSheetProvider> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
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
/> />
{!Platform.isTV && <GlobalModal />} <Stack.Screen
</ThemeProvider> name='(auth)/player'
</IntroSheetProvider> options={{
</BottomSheetModalProvider> headerShown: false,
</GlobalModalProvider> title: "",
</MusicPlayerProvider> header: () => null,
</DownloadProvider> }}
</SyncPlayProvider> />
<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>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>

View File

@@ -1,4 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",
@@ -10,7 +11,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.8", "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28", "@react-navigation/material-top-tabs": "7.4.28",
@@ -364,7 +365,7 @@
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="], "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],

View File

@@ -1,203 +0,0 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

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

View File

@@ -30,7 +30,6 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
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 { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -68,14 +67,6 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
// then performs the navigation uniformly for everyone, matching
// jellyfin-web's playbackManager intercept (Controller.play).
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom; const effectiveColors = colors || globalColorAtom;
@@ -103,37 +94,6 @@ export const PlayButton: React.FC<Props> = ({
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
// SyncPlay intercept: in a group, route playback through sthe server so
// every member gets the same PlayQueue: NewPlaylist update and navigates
// together. Skips local navigation and the Chromecast prompt entirely —
// SyncPlay + Chromecast isn't a supported combination yet, same as
// jellyfin-web.
if (isSyncPlayEnabled && syncPlayController && item.Id) {
try {
// Pass the full `item` (not just the ID) so the SyncPlay controller
// can run `translateItemsForPlayback` with full context — this is
// what jellyfin-web does, and it lets us expand Series / Season /
// BoxSet into real episode/track IDs before broadcasting the queue.
// Without expansion, receivers (jellyfin-web in particular) get
// container IDs they can't play and silently fail to open the
// player.
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -330,8 +290,6 @@ export const PlayButton: React.FC<Props> = ({
goToPlayer, goToPlayer,
isOffline, isOffline,
t, t,
isSyncPlayEnabled,
syncPlayController,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {

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

@@ -1,20 +0,0 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

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

View File

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

View File

@@ -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,12 +0,0 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -1,63 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -1,48 +0,0 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>

View File

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

View File

@@ -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,235 +0,0 @@
/**
* GroupSelectionMenu
*
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
* owned by SyncPlayButton). Calls `onClose` after successful actions to
* dismiss the parent sheet.
*/
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
interface GroupSelectionMenuProps {
onClose: () => void;
}
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const {
isEnabled,
groupInfo,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
} = useSyncPlay();
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
const fetchedGroups = await getGroups();
if (!cancelled) {
setGroups(fetchedGroups);
}
} catch (error) {
console.error("Failed to fetch groups", error);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [getGroups]);
const handleJoinGroup = useCallback(
async (groupId: string) => {
try {
await joinGroup(groupId);
onClose();
} catch (error) {
console.error("Failed to join group", error);
}
},
[joinGroup, onClose],
);
const handleCreateGroup = useCallback(async () => {
setIsCreating(true);
try {
await createGroup();
onClose();
} catch (error) {
console.error("Failed to create group", error);
} finally {
setIsCreating(false);
}
}, [createGroup, onClose]);
const handleLeaveGroup = useCallback(async () => {
try {
await leaveGroup();
onClose();
} catch (error) {
console.error("Failed to leave group", error);
}
}, [leaveGroup, onClose]);
const containerStyle = {
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
paddingTop: 8,
};
if (isEnabled && groupInfo) {
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people' size={24} color='#00a4dc' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
</View>
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
<View className='flex-row items-center justify-between mb-3'>
<Text className='text-neutral-100 font-semibold text-lg'>
{groupInfo.GroupName}
</Text>
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
<Text className='text-white text-xs font-medium'>
{groupInfo.State}
</Text>
</View>
</View>
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
<View className='flex-row items-center'>
<Ionicons name='person' size={16} color='#9ca3af' />
<Text className='text-neutral-400 ml-2'>
{groupInfo.Participants.length} {t("syncplay.members")}
</Text>
</View>
)}
</View>
<Button onPress={handleLeaveGroup} color='red'>
<View className='flex-row items-center justify-center'>
<Ionicons name='exit-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.leave_group")}
</Text>
</View>
</Button>
</View>
);
}
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people-outline' size={24} color='white' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
</View>
{isLoading && (
<View className='py-8 items-center'>
<ActivityIndicator color='#00a4dc' />
</View>
)}
{!isLoading && groups.length > 0 && (
<View className='mb-4'>
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
{t("syncplay.available_groups")}
</Text>
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
{groups.map((group, index) => (
<TouchableOpacity
key={group.GroupId ?? index}
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
className={`flex-row items-center p-4 ${
index < groups.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
<Ionicons name='people' size={20} color='#00a4dc' />
</View>
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{group.GroupName}
</Text>
<Text className='text-neutral-500 text-sm'>
{group.Participants?.length ?? 0} {t("syncplay.members")} {" "}
{group.State}
</Text>
</View>
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
</TouchableOpacity>
))}
</View>
</View>
)}
{!isLoading && groups.length === 0 && (
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
<Ionicons name='people-outline' size={40} color='#6b7280' />
<Text className='text-neutral-400 text-center mt-3'>
{t("syncplay.available_groups")}: 0{"\n"}
{t("syncplay.create_new_group")}
</Text>
</View>
)}
{canCreateGroups && (
<Button
onPress={handleCreateGroup}
color='purple'
disabled={isCreating}
>
<View className='flex-row items-center justify-center'>
{isCreating ? (
<ActivityIndicator size='small' color='white' />
) : (
<>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.create_new_group")}
</Text>
</>
)}
</View>
</Button>
)}
</View>
);
}

View File

@@ -1,97 +0,0 @@
/**
* SyncPlayButton
*
* Header button for accessing SyncPlay functionality.
* Shows group status and opens the group selection sheet.
*
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
* Compose ModalBottomSheet on Android). Because it presents natively, it
* works correctly even when triggered from `headerRight` — no portal or
* provider context is required (unlike @gorhom/bottom-sheet, which fails
* silently from detached UINavigationItem subtrees).
*
* Safe to import statically: this whole module is lazy-required only on
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
*/
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@expo/ui/community/bottom-sheet";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useRef } from "react";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useCastDevice } from "react-native-google-cast";
import { toast } from "sonner-native";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { GroupSelectionMenu } from "./GroupSelectionMenu";
interface SyncPlayButtonProps {
size?: number;
}
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
const { isEnabled, canJoinGroups } = useSyncPlay();
const { isConnected } = useNetworkStatus();
const castDevice = useCastDevice();
const sheetRef = useRef<BottomSheetMethods>(null);
const isCasting = !!castDevice;
const handlePress = useCallback(() => {
if (isCasting) {
toast("SyncPlay not available while casting");
return;
}
sheetRef.current?.present();
}, [isCasting]);
const handleDismiss = useCallback(() => {
sheetRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
if (!canJoinGroups) return null;
if (!isConnected) return null;
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
return (
<>
<Pressable
className='mr-4'
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<View className='relative'>
<Ionicons
name={isEnabled ? "people" : "people-outline"}
size={size}
color={iconColor}
/>
{isEnabled && !isCasting && (
<View
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
style={{
borderWidth: 1,
borderColor: "#171717",
}}
/>
)}
</View>
</Pressable>
<BottomSheetModal
ref={sheetRef}
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
enablePanDownToClose
>
<BottomSheetView>
<GroupSelectionMenu onClose={handleDismiss} />
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -1,165 +0,0 @@
/**
* SyncPlayIndicator
*
* Visual indicator shown during SyncPlay operations.
* Only appears when user's stream is ready but waiting for other group members.
*
* Key principle: SyncPlay indicator = "You're ready, waiting on others"
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlayIndicatorProps {
/**
* Whether the indicator should be visible.
* Should only be true when:
* 1. User's stream has loaded
* 2. Waiting for other group members
*/
visible: boolean;
/**
* Optional message to display
*/
message?: string;
}
export function SyncPlayIndicator({
visible,
message,
}: SyncPlayIndicatorProps) {
const { t } = useTranslation();
const displayMessage = message ?? t("syncplay.waiting_for_group");
const opacity = useSharedValue(0);
const scale = useSharedValue(1);
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
scale.value = withRepeat(
withTiming(1.15, {
duration: 800,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
);
} else {
opacity.value = withTiming(0, { duration: 200 });
scale.value = 1;
}
}, [visible, opacity, scale]);
const containerStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!visible) {
return null;
}
return (
<Animated.View style={[styles.container, containerStyle]}>
<View style={styles.content}>
{/* Pulsing icon container */}
<Animated.View style={[styles.iconContainer, pulseStyle]}>
<View style={styles.iconCircle}>
<Ionicons name='people' size={28} color='white' />
</View>
</Animated.View>
{/* Message */}
<Text style={styles.message}>{displayMessage}</Text>
{/* SyncPlay badge */}
<View style={styles.badge}>
<Ionicons name='sync' size={12} color='white' />
<Text style={styles.badgeText}>SyncPlay</Text>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
zIndex: 100,
},
content: {
alignItems: "center",
},
iconContainer: {
marginBottom: 16,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: SYNC_PLAY_COLOR,
justifyContent: "center",
alignItems: "center",
// Glow effect
shadowColor: SYNC_PLAY_COLOR,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 16,
elevation: 8,
},
message: {
color: "white",
fontSize: 16,
fontWeight: "500",
marginBottom: 8,
},
badge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 164, 220, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: SYNC_PLAY_COLOR,
},
badgeText: {
color: SYNC_PLAY_COLOR,
fontSize: 12,
fontWeight: "600",
marginLeft: 4,
},
});
/**
* Hook-compatible version that reads SyncPlay state directly
*/
export function useSyncPlayIndicatorState(
isLocalReady: boolean,
isGroupWaiting: boolean,
): boolean {
// Show indicator only when:
// 1. User's local stream has loaded (isLocalReady)
// 2. Group is still waiting for others (isGroupWaiting)
return isLocalReady && isGroupWaiting;
}

View File

@@ -1,53 +0,0 @@
/**
* SyncPlaySpinner
*
* Compact rotating SyncPlay icon shown in place of the play/pause button
* while a play/pause command is in flight to the server (the "schedule-play"
* indicator from jellyfin-web).
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
// SyncPlay cyan color (matches jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlaySpinnerProps {
size: number;
color?: string;
}
export function SyncPlaySpinner({
size,
color = SYNC_PLAY_COLOR,
}: SyncPlaySpinnerProps) {
const rotation = useSharedValue(0);
useEffect(() => {
rotation.value = withRepeat(
withTiming(360, {
duration: 1200,
easing: Easing.linear,
}),
-1,
false,
);
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<Ionicons name='sync' size={size} color={color} />
</Animated.View>
);
}

View File

@@ -1,10 +0,0 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayButton } from "./SyncPlayButton";
export {
SyncPlayIndicator,
useSyncPlayIndicatorState,
} from "./SyncPlayIndicator";

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,19 +1,14 @@
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 { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
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";
import { ICON_SIZES } from "./constants"; import { ICON_SIZES } from "./constants";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface CenterControlsProps { interface CenterControlsProps {
showControls: boolean; showControls: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -47,27 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter, goToNextChapter,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
// SyncPlay state from global provider
const {
isEnabled: isSyncPlayEnabled,
groupInfo,
pendingPlaybackCommand,
} = useSyncPlay();
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
// play/pause request is in flight to the server.
const isSyncPlayScheduling =
isSyncPlayEnabled && pendingPlaybackCommand !== null;
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",
@@ -138,17 +121,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
{isSyncPlayScheduling ? ( {!isBuffering ? (
// SyncPlay command in flight - rotating spinner ("schedule-play")
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
) : isSyncPlayWaiting ? (
// SyncPlay waiting indicator - clock icon, still pressable to toggle
<Ionicons
name='time'
size={ICON_SIZES.CENTER}
color={SYNC_PLAY_COLOR}
/>
) : !isBuffering ? (
<Ionicons <Ionicons
name={isPlaying ? "pause" : "play"} name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER} size={ICON_SIZES.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,39 +0,0 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -1,6 +1,6 @@
{ {
"cli": { "cli": {
"version": ">= 9.1.0", "version": ">= 16.0.0",
"appVersionSource": "remote" "appVersionSource": "remote"
}, },
"build": { "build": {
@@ -52,39 +52,50 @@
} }
}, },
"production": { "production": {
"bun": "1.3.5",
"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": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"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": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"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"
} }
}, },
"production_tv": { "production_tv": {
"bun": "1.3.5",
"environment": "production", "environment": "production",
"autoIncrement": true, "autoIncrement": true,
"env": { "env": {
"EXPO_TV": "1" "EXPO_TV": "1"
}, },
"ios": { "ios": {
"credentialsSource": "local" "credentialsSource": "local",
"config": "ios-production.yml"
} }
} }
}, },
@@ -93,6 +104,11 @@
"ios": { "ios": {
"appleTeamId": "MWD5K362T8", "appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679" "ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
} }
}, },
"production_tv": { "production_tv": {

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,37 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -1,35 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

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

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {
@@ -81,9 +80,9 @@ export const useWebSocket = ({
playTrailers, playTrailers,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { lastMessage, clearLastMessage } = useWebSocketContext(); const { lastMessage } = useWebSocketContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { isEnabled: isSyncPlayEnabled } = useSyncPlay(); const { clearLastMessage } = useWebSocketContext();
useEffect(() => { useEffect(() => {
if (!lastMessage) return; if (!lastMessage) return;
@@ -97,25 +96,6 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
const isSyncPlayCommand =
lastMessage.MessageType === "SyncPlayCommand" ||
lastMessage.MessageType === "SyncPlayGroupUpdate";
const isPlaybackCommand = [
"PlayPause",
"Pause",
"Unpause",
"Stop",
"Seek",
"NextTrack",
"PreviousTrack",
].includes(command ?? "");
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
return;
}
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
togglePlay(); togglePlay();

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

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

View File

@@ -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,7 +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.8", "@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28", "@react-navigation/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

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

View File

@@ -1,402 +0,0 @@
/**
* SyncPlay Controller
*
* Exposes SyncPlay API calls to external modules.
* Provides methods for controlling synchronized playback.
*
* Based on jellyfin-web's Controller.js
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import {
getItemsForPlayback,
msToTicks,
translateItemsForPlayback,
} from "./Helper";
import type { SyncPlayManager } from "./Manager";
import type { QueueCore } from "./QueueCore";
import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types";
/**
* SyncPlay Controller - External API for controlling SyncPlay
*/
export class SyncPlayController {
private api: Api;
private manager: SyncPlayManager;
private queueCore: QueueCore;
constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) {
this.api = api;
this.manager = manager;
this.queueCore = queueCore;
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Toggle play/pause
*/
playPause(): void {
// Use server group state (with pending in-flight command preferred) as
// the source of truth. The local player can lag the group by hundreds of
// ms while a scheduled command is pending, so reading `playerControls`
// here would cause rapid taps to send duplicate / wrong commands and
// desync other clients.
const state = this.manager.getEffectivePlayState();
console.log(`SyncPlay Controller: playPause - effectiveState=${state}`);
if (state === "Playing") {
console.log("SyncPlay Controller: requesting PAUSE");
this.pause();
} else {
console.log("SyncPlay Controller: requesting UNPAUSE");
this.unpause();
}
}
/**
* Request unpause (play)
*/
async unpause(): Promise<void> {
// Drop duplicate rapid taps while a previous request is still in flight
// (cleared when the server broadcasts back via SyncPlayCommand, or after
// a safety timeout).
if (this.manager.getPendingPlaybackCommand() === "Unpause") {
console.debug("SyncPlay Controller: unpause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Unpause");
try {
console.log("SyncPlay Controller: sending syncPlayUnpause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayUnpause();
console.log("SyncPlay Controller: syncPlayUnpause sent successfully");
} catch (error) {
console.error("SyncPlay Controller: failed to unpause", error);
}
}
/**
* Request pause
*/
async pause(): Promise<void> {
if (this.manager.getPendingPlaybackCommand() === "Pause") {
console.debug("SyncPlay Controller: pause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Pause");
try {
console.log("SyncPlay Controller: sending syncPlayPause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPause();
console.log("SyncPlay Controller: syncPlayPause sent successfully");
// Also pause locally for immediate feedback
this.manager.getPlayerControls()?.pause();
} catch (error) {
console.error("SyncPlay Controller: failed to pause", error);
}
}
/**
* Request seek to position
*/
async seek(positionTicks: number): Promise<void> {
try {
console.log(
`SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`,
);
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySeek({
seekRequestDto: {
PositionTicks: positionTicks,
},
});
console.log("SyncPlay Controller: syncPlaySeek sent successfully");
// Also seek locally for immediate feedback
const positionMs = positionTicks / 10000;
this.manager.getPlayerControls()?.seekTo(positionMs);
} catch (error) {
console.error("SyncPlay Controller: failed to seek", error);
}
}
/**
* Request seek to position in milliseconds
*/
async seekMs(positionMs: number): Promise<void> {
console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
await this.seek(msToTicks(positionMs));
}
/**
* Request stop
*/
async stop(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayStop();
} catch (error) {
console.error("SyncPlay Controller: failed to stop", error);
}
}
// ============================================================================
// Queue Control
// ============================================================================
/**
* Start playback with a new SyncPlay group queue.
*
* Mirrors jellyfin-web's `Controller.play`:
*
* - If the caller passed full `items` objects, translate them directly
* (Series → episodes, BoxSet → children, etc.).
* - Otherwise fetch the items by ID first (`getItemsForPlayback`), then
* translate.
* - Send the translated, real playable IDs to
* `syncPlaySetNewQueue` so every group member receives a queue of
* playable items — not container IDs (Series / Season / BoxSet) that
* receivers like jellyfin-web silently drop.
*
* `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web.
*/
async play(options: PlayOptions): Promise<void> {
const { items, ids, startIndex = 0, startPositionTicks = 0 } = options;
if ((!ids || ids.length === 0) && (!items || items.length === 0)) {
console.error("SyncPlay Controller: no items or ids to play");
return;
}
try {
// Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied
// items (no extra round trip), fall back to a fetch by IDs.
const sourceItems: BaseItemDto[] =
items && items.length > 0
? items
: await getItemsForPlayback(this.api, ids ?? []);
if (!sourceItems.length) {
console.error(
"SyncPlay Controller: getItemsForPlayback returned no items",
);
return;
}
// Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode
// into the real playable queue.
const translated = await translateItemsForPlayback(
this.api,
sourceItems,
{ ids, queryOptions: {} },
);
const queueIds = translated
.map((item) => item.Id)
.filter((id): id is string => !!id);
if (!queueIds.length) {
console.error(
"SyncPlay Controller: translateItemsForPlayback produced empty queue",
);
return;
}
console.log(
`SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`,
);
// Step 3 — broadcast the resolved queue to the group.
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: queueIds,
PlayingItemPosition: startIndex,
StartPositionTicks: startPositionTicks,
},
});
} catch (error) {
// Surface the server response body when available — a SetNewQueue
// that 4xx's silently is the most common "why didn't the other
// client start?" cause. Without the body we'd just see a generic
// axios error and have no way to tell whether it was a permission
// problem, an unknown item ID, or the server rejecting the queue.
const err = error as {
response?: { status?: number; data?: unknown };
message?: string;
};
console.error("SyncPlay Controller: failed to set new queue", {
status: err?.response?.status,
data: err?.response?.data,
message: err?.message,
});
}
}
/**
* Set current item in playlist
*/
async setCurrentPlaylistItem(playlistItemId: string): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetPlaylistItem({
setPlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set playlist item", error);
}
}
/**
* Play next item
*/
async nextItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayNextItem({
nextItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play next", error);
}
}
/**
* Play previous item
*/
async previousItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPreviousItem({
previousItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play previous", error);
}
}
/**
* Add items to queue
*/
async queue(
itemIds: string[],
mode: "Queue" | "QueueNext" = "Queue",
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayQueue({
queueRequestDto: {
ItemIds: itemIds,
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to queue items", error);
}
}
/**
* Add items to play next
*/
async queueNext(itemIds: string[]): Promise<void> {
await this.queue(itemIds, "QueueNext");
}
/**
* Remove items from playlist
*/
async removeFromPlaylist(playlistItemIds: string[]): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayRemoveFromPlaylist({
removeFromPlaylistRequestDto: {
PlaylistItemIds: playlistItemIds,
},
});
} catch (error) {
console.error(
"SyncPlay Controller: failed to remove from playlist",
error,
);
}
}
/**
* Move item in playlist
*/
async movePlaylistItem(
playlistItemId: string,
newIndex: number,
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayMovePlaylistItem({
movePlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
NewIndex: newIndex,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to move playlist item", error);
}
}
// ============================================================================
// Playback Settings
// ============================================================================
/**
* Set repeat mode
*/
async setRepeatMode(mode: GroupRepeatMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetRepeatMode({
setRepeatModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set repeat mode", error);
}
}
/**
* Set shuffle mode
*/
async setShuffleMode(mode: GroupShuffleMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetShuffleMode({
setShuffleModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set shuffle mode", error);
}
}
/**
* Toggle shuffle mode
*/
async toggleShuffleMode(): Promise<void> {
const currentMode = this.queueCore.getShuffleMode();
const newMode: GroupShuffleMode =
currentMode === "Sorted" ? "Shuffle" : "Sorted";
await this.setShuffleMode(newMode);
}
}

View File

@@ -1,448 +0,0 @@
/**
* SyncPlay Helper
*
* Utility functions for SyncPlay functionality.
* Based on jellyfin-web's Helper.js
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getItemsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { TicksPerMillisecond } from "./types";
/**
* Wait for an event to be triggered, with optional timeout.
*/
export function waitForEvent<T>(
eventEmitter: {
addEventListener: (event: string, handler: (data: T) => void) => void;
removeEventListener: (event: string, handler: (data: T) => void) => void;
},
eventType: string,
timeout?: number,
rejectEvents?: string[],
): Promise<T> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
eventEmitter.removeEventListener(eventType, handler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.removeEventListener(event, rejectHandler);
}
}
};
const handler = (data: T) => {
cleanup();
resolve(data);
};
const rejectHandler = () => {
cleanup();
reject(new Error("Rejected by event"));
};
eventEmitter.addEventListener(eventType, handler);
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.addEventListener(event, rejectHandler);
}
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for event"));
}, timeout);
}
});
}
/**
* Wait for a promise-based callback, with timeout.
*/
export function waitWithTimeout<T>(
promise: Promise<T>,
timeout: number,
): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Operation timed out"));
}, timeout);
promise
.then((result) => {
clearTimeout(timeoutId);
resolve(result);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
/**
* Convert ticks to milliseconds.
*/
export function ticksToMs(ticks: number): number {
return ticks / TicksPerMillisecond;
}
/**
* Convert milliseconds to ticks.
*/
export function msToTicks(ms: number): number {
return Math.round(ms * TicksPerMillisecond);
}
/**
* Convert a GUID string to standard format.
*/
export function stringToGuid(input: string): string {
return input.replace(
/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/,
"$1-$2-$3-$4-$5",
);
}
/**
* Parse a date string to Date object.
*/
export function parseDate(dateString: string): Date {
return new Date(dateString);
}
/**
* Get current time as ISO string for API requests.
*/
export function nowAsIsoString(): string {
return new Date().toISOString();
}
/**
* Clamp a value between min and max.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Simple event emitter for internal use.
*/
export class EventEmitter {
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
off(event: string, callback: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(callback);
}
emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((callback) => {
try {
callback(...args);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
removeAllListeners(event?: string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
/**
* Wait for the next emission of an event on our internal {@link EventEmitter},
* or reject after `timeoutMs`. Auto-cleans the listener.
*/
export function waitForOwnEvent(
emitter: EventEmitter,
event: string,
timeoutMs = 5000,
): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const handler = (...args: unknown[]) => {
clearTimeout(timer);
emitter.off(event, handler);
resolve(args);
};
const timer = setTimeout(() => {
emitter.off(event, handler);
reject(new Error(`Timed out waiting for "${event}"`));
}, timeoutMs);
emitter.on(event, handler);
});
}
// ============================================================================
// Item fetching / queue translation
//
// Faithful port of jellyfin-web's `getItemsForPlayback` and
// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`.
//
// Why this matters for SyncPlay:
// - The server takes the queue we send via `syncPlaySetNewQueue` and
// broadcasts it verbatim to every group member. If we send a Series /
// Season / BoxSet ID, every receiver tries to load that container as a
// playable item, which silently fails on jellyfin-web (it never opens
// the player). Sending an Episode ID without sibling expansion breaks
// next-episode auto-advance for everyone in the group.
// - jellyfin-web's `playbackManager.play` runs the same translation
// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue
// request so the broadcast carries real playable item IDs.
// - We replicate the same translation here so a mobile sender produces
// the same broadcast a jellyfin-web sender would.
// ============================================================================
/** Options bag accepted by `translateItemsForPlayback`. */
export interface TranslateOptions {
ids?: string[];
shuffle?: boolean;
queryOptions?: Record<string, unknown>;
}
/** Fields jellyfin-web requests for any playback queue. */
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
/** Resolve the current user. Cached only for the duration of one call. */
async function getCurrentUser(api: Api) {
const user = (await getUserApi(api).getCurrentUser()).data;
if (!user?.Id) {
throw new Error("SyncPlay Helper: no authenticated user");
}
return user;
}
/**
* Generic `getItems` wrapper with the playback defaults jellyfin-web uses
* (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`,
* `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`).
*
* Callers pass camelCase params straight to the SDK — no PascalCase shim.
*/
async function queryItems(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
const res = await getItemsApi(api).getItems({
limit: 300,
fields: PLAYBACK_FIELDS as unknown as never,
excludeLocationTypes: ["Virtual"] as unknown as never,
enableTotalRecordCount: false,
collapseBoxSetItems: false,
...params,
userId,
});
return res.data.Items ?? [];
}
/**
* Recursive "fetch children/tracks under X" — the shape MusicArtist /
* MusicGenre / Photo / PhotoAlbum / IsFolder all share.
*/
function fetchSiblings(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
return queryItems(api, userId, {
filters: ["IsNotFolder"],
recursive: true,
...params,
});
}
/**
* Resolve item IDs into full `BaseItemDto`s.
* Mirrors jellyfin-web's `Helper.getItemsForPlayback`:
* - single ID → `getUserLibraryApi.getItem` (cheap)
* - multi ID → `getItemsApi.getItems` with playback defaults
*/
export async function getItemsForPlayback(
api: Api,
ids: string[],
): Promise<BaseItemDto[]> {
if (!ids.length) return [];
const userId = (await getCurrentUser(api)).Id as string;
if (ids.length === 1) {
const res = await getUserLibraryApi(api).getItem({
userId,
itemId: ids[0],
});
return res.data ? [res.data] : [];
}
return queryItems(api, userId, { ids });
}
/**
* Expand a "first item" into a real playable queue.
*
* Mirrors jellyfin-web's `Helper.translateItemsForPlayback`:
* - Program → channel items
* - Playlist → playlist children
* - MusicArtist → artist tracks
* - MusicGenre → genre tracks
* - Photo / PhotoAlbum → sibling photos
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
* - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes
* - anything else → passthrough (Movies, Audio, single Episodes when autoplay off)
*
* Preserves the original `options.ids` order so the receiver sees the same
* queue order the sender intended.
*/
export async function translateItemsForPlayback(
api: Api,
items: BaseItemDto[],
options: TranslateOptions = {},
): Promise<BaseItemDto[]> {
if (!items.length) return [];
const workingItems =
items.length > 1 && options.ids
? [...items].sort(
(a, b) =>
(options.ids ?? []).indexOf(a.Id ?? "") -
(options.ids ?? []).indexOf(b.Id ?? ""),
)
: items;
const firstItem = workingItems[0];
const defaultSortBy = options.shuffle ? "Random" : "SortName";
// Program → channel's playable items. Doesn't need a user lookup.
if (firstItem.Type === "Program" && firstItem.ChannelId) {
return getItemsForPlayback(api, [firstItem.ChannelId]);
}
// Resolve the user once for every branch that follows. Saves 1-2 round
// trips vs. each helper resolving independently.
const user = await getCurrentUser(api);
const userId = user.Id as string;
if (firstItem.Type === "Playlist") {
return queryItems(api, userId, {
parentId: firstItem.Id,
sortBy: options.shuffle ? ["Random"] : undefined,
});
}
if (firstItem.Type === "MusicArtist") {
return fetchSiblings(api, userId, {
artistIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: options.shuffle
? ["Random"]
: ["Album", "ParentIndexNumber", "IndexNumber", "SortName"],
});
}
if (firstItem.MediaType === "Photo") {
const siblings = await fetchSiblings(api, userId, {
parentId: firstItem.ParentId,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
});
// Re-anchor startIndex to the chosen photo, same as jellyfin-web.
// SyncPlay doesn't currently consume startIndex from queryOptions,
// but we keep parity for any future caller.
if (siblings.length && options.queryOptions) {
const idx = siblings.findIndex((i) => i.Id === firstItem.Id);
if (idx >= 0) options.queryOptions.startIndex = idx;
}
return siblings;
}
if (firstItem.Type === "PhotoAlbum") {
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
limit: 1000,
});
}
if (firstItem.Type === "MusicGenre") {
return fetchSiblings(api, userId, {
genreIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: [defaultSortBy],
});
}
if (firstItem.IsFolder) {
// Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets
// SortBy for shuffle or BoxSet — everything else inherits server-side
// sort order (typically index/premiere date).
const sortBy = options.shuffle
? ["Random"]
: firstItem.Type === "BoxSet"
? ["SortName"]
: undefined;
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
mediaTypes: ["Audio", "Video"],
sortBy,
});
}
if (firstItem.Type === "Episode" && workingItems.length === 1) {
// Single-episode auto-next: drop everything before this episode so
// playback starts here and auto-advances through the rest of the
// series. Gated on the user's `EnableNextEpisodeAutoPlay` like
// jellyfin-web does.
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
return workingItems;
}
try {
const res = await getTvShowsApi(api).getEpisodes({
seriesId: firstItem.SeriesId,
userId,
isMissing: false,
fields: PLAYBACK_FIELDS as unknown as never,
});
const all = res.data.Items ?? [];
const foundIdx = Math.max(
0,
all.findIndex((e) => e.Id === firstItem.Id),
);
return all.slice(foundIdx);
} catch (error) {
// Don't block playback on a translation failure — fall back to the
// single-item queue the caller already supplied.
console.warn(
"SyncPlay Helper: Episode translation failed, falling back to single item",
error,
);
return workingItems;
}
}
// Everything else (Movie, Audio, ...) plays as-is.
return workingItems;
}

View File

@@ -1,797 +0,0 @@
/**
* SyncPlay Manager
*
* Main orchestrator for SyncPlay functionality.
* Manages group state, coordinates time sync, playback, and queue.
*
* Based on jellyfin-web's Manager.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { toast } from "sonner-native";
import i18n from "@/i18n";
import { EventEmitter, msToTicks } from "./Helper";
import { TimeSyncCore } from "./TimeSyncCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayStats,
} from "./types";
/**
* SyncPlay Manager - Main orchestrator
*/
export class SyncPlayManager extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
// Group state
private groupInfo: GroupInfoDto | null = null;
private syncPlayEnabledAt: Date | null = null;
private syncPlayReady = false;
private queuedCommand: SendCommand | null = null;
private followingGroupPlayback = true;
private lastPlaybackCommand: SendCommand | null = null;
// Pending play/pause request awaiting server broadcast.
// Used to (1) ignore duplicate rapid taps and (2) treat the server's
// upcoming state as the source of truth while a request is in flight.
private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
private pendingPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
// Safety: drop the pending guard after this long if no broadcast arrives.
private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
// Player state
private playerControls: PlayerControls | null = null;
private syncMethod = "None";
// Callbacks
private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
private onGetPlaylistItemId: (() => string | null) | null = null;
// Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
// own scheduled timers / cached command so we don't carry stale state into
// the next group.
private onDisable: (() => void) | null = null;
// Fired when SyncPlay is disabled — QueueCore wires up here to clear its
// last PlayQueue snapshot. Without this, re-joining the same group later
// causes the first PlayQueue echo (which can have a `LastUpdate` equal to
// or older than the snapshot we cached before the disable) to be dropped
// by the stale-update guard in `QueueCore.updatePlayQueue`.
private onQueueClear: (() => void) | null = null;
constructor(api: Api) {
super();
this.api = api;
this.timeSyncCore = new TimeSyncCore(api);
// Listen for time sync updates
this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
if (error) {
console.debug("SyncPlay Manager: time sync error", error);
return;
}
this.emit("time-sync-update", timeOffset, ping);
// Report ping to server when enabled
if (this.isSyncPlayEnabled() && ping !== null) {
this.sendPing(ping);
}
});
}
/**
* Initialize the manager
*/
init(): void {
this.timeSyncCore.startPing();
}
/**
* Update the API client
*/
updateApiClient(api: Api): void {
this.api = api;
}
/**
* Get the API client
*/
getApiClient(): Api {
return this.api;
}
/**
* Get the time sync core
*/
getTimeSyncCore(): TimeSyncCore {
return this.timeSyncCore;
}
/**
* Set player controls for playback management
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// When player controls are connected and SyncPlay is active, sync to group state
if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
const state = this.groupInfo?.State;
console.log(
`SyncPlay: player controls connected, group state is ${state}`,
);
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
// Reconcile position: if we know the last command and group is playing,
// estimate where the group is *now* and seek there before resuming. This
// fixes the case where the player attaches mid-stream and would
// otherwise resume from 0 or the last-known local position.
const last = this.lastPlaybackCommand;
if (
last &&
(last.Command === "Unpause" || last.Command === "Pause") &&
last.When &&
last.PositionTicks != null
) {
try {
const commandWhen = new Date(last.When);
let targetTicks = last.PositionTicks;
if (last.Command === "Unpause") {
const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
targetTicks +=
(remoteNow.getTime() - commandWhen.getTime()) * 10000;
}
const targetMs = Math.max(0, targetTicks / 10000);
const currentMs = controls.getCurrentPosition();
if (Math.abs(currentMs - targetMs) > 500) {
console.log(
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
);
controls.seekTo(targetMs);
}
} catch (error) {
console.warn(
"SyncPlay: failed to estimate group position on attach",
error,
);
}
}
if (state === "Playing" && !controls.isPlaying()) {
console.log("SyncPlay: starting playback to match group");
controls.play();
} else if (state === "Paused" && controls.isPlaying()) {
console.log("SyncPlay: pausing to match group");
controls.pause();
}
}
}
/**
* Get current player controls
*/
getPlayerControls(): PlayerControls | null {
return this.playerControls;
}
/**
* Set callback for playback commands
*/
setPlaybackCommandHandler(
handler: ((command: SendCommand) => void) | null,
): void {
this.onPlaybackCoreCommand = handler;
}
/**
* Set callback for queue updates
*/
setQueueUpdateHandler(
handler: ((update: PlayQueueUpdate) => void) | null,
): void {
this.onQueueUpdate = handler;
}
/**
* Set callback for getting current playlist item ID
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.onGetPlaylistItemId = getter;
}
/**
* Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
* here so it can flush scheduled commands and stale state.
*/
setDisableHandler(handler: (() => void) | null): void {
this.onDisable = handler;
}
/**
* Set a callback invoked when SyncPlay is disabled. QueueCore registers
* here so it can drop the cached PlayQueue snapshot and treat the next
* server update as fresh.
*/
setQueueClearHandler(handler: (() => void) | null): void {
this.onQueueClear = handler;
}
// ============================================================================
// Group Management
// ============================================================================
/**
* Check if SyncPlay is enabled (user is in a group)
*/
isSyncPlayEnabled(): boolean {
return this.syncPlayEnabledAt !== null;
}
/**
* Check if SyncPlay is ready (time sync complete)
*/
isSyncPlayReady(): boolean {
return this.syncPlayReady;
}
/**
* Get current group info
*/
getGroupInfo(): GroupInfoDto | null {
return this.groupInfo;
}
/**
* Get the last playback command
*/
getLastPlaybackCommand(): SendCommand | null {
return this.lastPlaybackCommand;
}
/**
* Check if currently playing
*/
isPlaying(): boolean {
// First check actual player state
if (this.playerControls) {
return this.playerControls.isPlaying();
}
// Fall back to group state
if (this.groupInfo?.State) {
return this.groupInfo.State === "Playing";
}
// Last resort: check last command
return this.lastPlaybackCommand?.Command === "Unpause";
}
/**
* Effective play state for SyncPlay routing decisions.
*
* Prefers (1) a pending in-flight command we just sent, (2) the server's
* group state, and only falls back to the local player. This avoids the
* race where a rapid second tap reads the local player (which hasn't
* applied the scheduled command yet) and sends a duplicate request that
* either re-broadcasts with a new `When` or flips the group the wrong way.
*/
getEffectivePlayState(): "Playing" | "Paused" {
if (this.pendingPlaybackCommand === "Unpause") return "Playing";
if (this.pendingPlaybackCommand === "Pause") return "Paused";
if (this.groupInfo?.State === "Playing") return "Playing";
if (this.groupInfo?.State === "Paused") return "Paused";
return this.playerControls?.isPlaying() ? "Playing" : "Paused";
}
/**
* Returns the in-flight play/pause request, if any.
*/
getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
return this.pendingPlaybackCommand;
}
/**
* Mark a play/pause request as in flight. Auto-clears on a safety timeout
* in case the server broadcast is missed.
*/
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
this.pendingPlaybackCommand = command;
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
}
this.pendingPlaybackTimeout = setTimeout(() => {
console.debug(
"SyncPlay Manager: pending playback command timed out",
command,
);
this.pendingPlaybackCommand = null;
this.pendingPlaybackTimeout = null;
this.emit("pending-playback-change", null);
}, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
this.emit("pending-playback-change", command);
}
private clearPendingPlaybackCommand(): void {
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
this.pendingPlaybackTimeout = null;
}
if (this.pendingPlaybackCommand !== null) {
this.pendingPlaybackCommand = null;
this.emit("pending-playback-change", null);
}
}
/**
* Check if following group playback
*/
isFollowingGroupPlayback(): boolean {
return this.followingGroupPlayback;
}
/**
* Enable SyncPlay (join a group)
*/
enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
if (this.isSyncPlayEnabled()) {
if (groupInfo.GroupId === this.groupInfo?.GroupId) {
console.debug(
`SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
);
return;
}
console.warn(
`SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
);
this.disableSyncPlay(false);
}
this.groupInfo = groupInfo;
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
? new Date(groupInfo.LastUpdatedAt)
: new Date();
this.followingGroupPlayback = true;
this.syncPlayReady = false;
console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
this.emit("enabled", true);
// Wait for time sync to be ready
const checkReady = () => {
if (this.timeSyncCore.isReady()) {
this.syncPlayReady = true;
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
if (this.queuedCommand) {
this.processCommand(this.queuedCommand);
this.queuedCommand = null;
}
// Act on initial group state if player is connected
if (this.playerControls && groupInfo.State) {
console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
if (groupInfo.State === "Playing") {
this.playerControls.play();
} else if (groupInfo.State === "Paused") {
this.playerControls.pause();
}
}
} else {
setTimeout(checkReady, 100);
}
};
this.timeSyncCore.forceUpdate();
checkReady();
if (showMessage) {
toast(i18n.t("syncplay.enabled"));
}
}
/**
* Disable SyncPlay (leave group)
*/
disableSyncPlay(showMessage = false): void {
this.syncPlayEnabledAt = null;
this.syncPlayReady = false;
this.followingGroupPlayback = true;
this.lastPlaybackCommand = null;
this.queuedCommand = null;
this.groupInfo = null;
this.clearPendingPlaybackCommand();
// Tell PlaybackCore (or whoever subscribed) to flush any scheduled
// commands / cached state so a future re-enable starts clean.
try {
this.onDisable?.();
} catch (error) {
console.warn("SyncPlay: onDisable handler threw", error);
}
// Drop the cached PlayQueue snapshot so a future re-join doesn't get
// its first PlayQueue update silently dropped as "older than what we
// already have".
try {
this.onQueueClear?.();
} catch (error) {
console.warn("SyncPlay: onQueueClear handler threw", error);
}
this.emit("enabled", false);
if (showMessage) {
toast(i18n.t("syncplay.disabled"));
}
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send ping to server
*/
private async sendPing(ping: number): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPing({
pingRequestDto: { Ping: Math.round(ping) },
});
} catch (error) {
console.debug("SyncPlay: failed to send ping", error);
}
}
/**
* Report that we're ready (not buffering)
*/
async reportReady(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
const now = new Date();
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
console.log(
"SyncPlay Manager: reporting READY at position",
currentPositionTicks,
);
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: this.playerControls?.isPlaying() ?? false,
PlaylistItemId:
this.onGetPlaylistItemId?.() ??
"00000000-0000-0000-0000-000000000000",
},
});
console.log("SyncPlay Manager: READY sent successfully");
} catch (error) {
console.error("SyncPlay Manager: failed to report ready", error);
}
}
/**
* Follow group playback
*/
async followGroupPlayback(): Promise<void> {
this.followingGroupPlayback = true;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: false },
});
} catch (error) {
console.error("SyncPlay: failed to follow group playback", error);
}
}
/**
* Halt group playback (stop following)
*/
async haltGroupPlayback(): Promise<void> {
this.followingGroupPlayback = false;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: true },
});
// Stop local playback
this.playerControls?.pause();
} catch (error) {
console.error("SyncPlay: failed to halt group playback", error);
}
}
// ============================================================================
// Message Processing
// ============================================================================
/**
* Process a group update from the server
* Uses generic type to handle all possible update types from server
*/
processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
const { Type, Data } = update;
switch (Type) {
case "PlayQueue": {
const playQueueData = Data as PlayQueueUpdate;
console.log(
"SyncPlay: received PlayQueue update - position:",
playQueueData.StartPositionTicks,
"reason:",
playQueueData.Reason,
);
this.onQueueUpdate?.(playQueueData);
break;
}
case "UserJoined":
toast(i18n.t("syncplay.user_joined", { username: Data }));
if (this.groupInfo) {
if (!this.groupInfo.Participants) {
this.groupInfo.Participants = [Data as string];
} else {
this.groupInfo.Participants.push(Data as string);
}
}
break;
case "UserLeft":
toast(i18n.t("syncplay.user_left", { username: Data }));
if (this.groupInfo?.Participants) {
this.groupInfo.Participants = this.groupInfo.Participants.filter(
(user: string) => user !== Data,
);
}
break;
case "GroupJoined": {
const groupData = Data as GroupInfoDto;
this.enableSyncPlay(groupData, true);
break;
}
case "SyncPlayIsDisabled":
toast(i18n.t("syncplay.permission_required"));
break;
case "NotInGroup":
case "GroupLeft":
this.disableSyncPlay(true);
break;
case "GroupUpdate": {
const updatedData = Data as GroupInfoDto;
this.groupInfo = updatedData;
this.emit("group-info-change", updatedData);
break;
}
case "StateUpdate": {
// Log full state data to see if position is included
console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
const stateData = Data as {
State: string;
Reason: string;
PositionTicks?: number;
};
// CRITICAL: Update the stored group state so subsequent checks use the correct value
if (this.groupInfo) {
this.groupInfo.State = stateData.State as any;
// Emit a fresh object so React state subscribers re-render —
// mutating in place would not trigger re-renders.
this.emit("group-info-change", { ...this.groupInfo });
}
this.emit("group-state-update", stateData.State, stateData.Reason);
console.log(
`SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
);
// Handle seek from StateUpdate if position is included
if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
console.log(
"SyncPlay: StateUpdate contains seek position:",
stateData.PositionTicks,
);
this.emit("seek-from-state-update", stateData.PositionTicks);
}
// Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
// This ensures we stay in sync even if the server doesn't send commands
if (this.playerControls) {
const currentlyPlaying = this.playerControls.isPlaying();
console.log(
`SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
);
if (stateData.State === "Paused" && currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PAUSING player");
this.playerControls.pause();
} else if (stateData.State === "Playing" && !currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PLAYING");
this.playerControls.play();
} else if (stateData.State === "Waiting") {
console.log("SyncPlay: StateUpdate -> Waiting for other members");
// Pause player when waiting
if (currentlyPlaying) {
this.playerControls.pause();
}
// Emit event so PlaybackCore can report ready
this.emit("waiting-for-ready");
}
} else {
console.warn("SyncPlay: StateUpdate but no playerControls!");
}
break;
}
case "GroupDoesNotExist":
toast(i18n.t("syncplay.group_does_not_exist"));
break;
case "CreateGroupDenied":
toast(i18n.t("syncplay.create_denied"));
break;
case "JoinGroupDenied":
toast(i18n.t("syncplay.join_denied"));
break;
case "LibraryAccessDenied":
toast(i18n.t("syncplay.library_access_denied"));
break;
default:
console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
}
}
/**
* Process a playback command from the server
*/
processCommand(command: SendCommand): void {
console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
if (!this.isSyncPlayEnabled()) {
console.warn(
"SyncPlay Manager: not enabled, ignoring command",
command.Command,
);
return;
}
const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
if (this.syncPlayEnabledAt && emittedAt) {
if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
console.debug("SyncPlay Manager: ignoring old command", command);
return;
}
}
// Reject commands targeted at a different playlist item than the one we
// currently have loaded. Stop is always honored (it may be a teardown
// before a queue swap). This prevents (e.g.) seeking the wrong episode
// when a queue change is racing a command.
if (command.Command !== "Stop" && command.PlaylistItemId) {
const currentItemId = this.onGetPlaylistItemId?.();
if (currentItemId && currentItemId !== command.PlaylistItemId) {
console.debug(
`SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
);
return;
}
}
if (!this.syncPlayReady) {
console.log(
"SyncPlay Manager: not ready, queuing command",
command.Command,
);
this.queuedCommand = command;
return;
}
// Remember the command even if we can't act on it yet. When the player
// attaches (setPlayerControls), the reconcile-on-attach path uses
// `lastPlaybackCommand` to seek to the estimated group position and
// resume/pause to match the group. Without this assignment, a command
// that arrives during the join→navigate→load window is lost.
this.lastPlaybackCommand = command;
// Clear pending guard once the matching broadcast arrives. We treat any
// Unpause/Pause arrival as satisfying the pending request (the server
// may coalesce or override our intent — either way we trust its decision).
if (command.Command === "Unpause" || command.Command === "Pause") {
this.clearPendingPlaybackCommand();
}
if (!this.playerControls) {
// Expected when a command arrives between joining the group and the
// player finishing its initial load. The reconciliation in
// setPlayerControls will replay this command from `lastPlaybackCommand`
// once controls attach.
console.debug(
`SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
);
return;
}
console.log(
`SyncPlay Manager: delegating ${command.Command} to playback core`,
);
// Delegate to playback handler
if (this.onPlaybackCoreCommand) {
this.onPlaybackCoreCommand(command);
} else {
console.error("SyncPlay Manager: no playback command handler set!");
}
}
// ============================================================================
// Stats
// ============================================================================
/**
* Get SyncPlay stats for display
*/
getStats(): SyncPlayStats {
return {
timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
playbackDiff: "0.00",
syncMethod: this.syncMethod,
};
}
/**
* Show sync icon
*/
showSyncIcon(method: string): void {
this.syncMethod = method;
this.emit("syncing", true, method);
}
/**
* Clear sync icon
*/
clearSyncIcon(): void {
this.syncMethod = "None";
this.emit("syncing", false, "None");
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Destroy the manager
*/
destroy(): void {
this.timeSyncCore.destroy();
this.disableSyncPlay(false);
this.removeAllListeners();
this.playerControls = null;
this.onPlaybackCoreCommand = null;
this.onQueueUpdate = null;
}
}

View File

@@ -1,881 +0,0 @@
/**
* PlaybackCore
*
* Manages synchronized playback for SyncPlay.
* Handles scheduling commands at precise times and sync correction.
*
* Based on jellyfin-web's PlaybackCore.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper";
import type { TimeSyncCore } from "./TimeSyncCore";
import type {
PlayerControls,
SendCommand,
SyncPlayOsdAction,
SyncPlaySettings,
} from "./types";
import { TicksPerMillisecond } from "./types";
// Random offset added when re-issuing a duplicate Seek to force the player
// off-position so the next sync correction has something to chew on. Matches
// jellyfin-web's behavior (server tolerates a range, so we deliberately land
// just outside it).
const SEEK_FORCE_RANGE_MS = 100;
// Timeout for waiting on the local player's "ready" event after seek.
// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout.
const WAIT_FOR_READY_TIMEOUT_MS = 30000;
// How close player position must be to command position to consider it
// already in the correct place (fuzz to account for player imprecision).
const POSITION_MATCH_TOLERANCE_MS = 500;
/**
* PlaybackCore - Handles synchronized playback
*/
export class PlaybackCore extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
private playerControls: PlayerControls | null = null;
// Sync state
private syncEnabled = false;
private playbackDiffMillis = 0;
private syncAttempts = 0;
private lastSyncTime = new Date();
private playerIsBuffering = false;
// Command tracking
private lastCommand: SendCommand | null = null;
private scheduledCommandTimeout: ReturnType<typeof setTimeout> | null = null;
private syncTimeout: ReturnType<typeof setTimeout> | null = null;
// Last buffering state we reported to the server. Used to dedupe
// sendBufferingRequest so we only send on actual transitions —
// jellyfin-web gets this for free from the HTML5 `waiting`/`canplay`
// events, but our player exposes state, not events, and the React
// effect that drives notifyReady/notifyBuffering can re-run many times
// per second during normal playback. Without this guard we flood the
// server with redundant Ready/Buffering requests.
private lastBufferingSent: boolean | null = null;
private inflightBufferingRequest: Promise<void> | null = null;
// Debounce buffering notifications, matching jellyfin-web's
// `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short
// re-buffer blip (<3s) shouldn't notify the server at all — there's no
// reason to pause the whole group for a hiccup that resolves itself.
// Going Ready cancels any pending buffering notification.
private notifyBufferingTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly minBufferingThresholdMillis = 3000;
// Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new
// SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem
// / PreviousItem). On the next `onReady` we pause the player BEFORE
// sending SyncPlayReady so the server sees us as `IsPlaying:false`,
// parked at the start position, awaiting an Unpause command. Mirrors
// jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which
// registers a one-shot `playbackstart` listener that does the same.
// Without this the receiver's player auto-plays the moment it loads and
// the group's Unpause command arrives to an already-playing player —
// which leaves the receiver subtly out of sync with the sender (or, on
// slower devices, stuck on a blank loading screen because the early
// play attempt races the media load and never recovers).
private pendingInitialPause = false;
// Settings
private minDelaySpeedToSync = 60.0;
private maxDelaySpeedToSync = 3000.0;
private speedToSyncDuration = 1000.0;
private minDelaySkipToSync = 400.0;
private useSpeedToSync = true;
private useSkipToSync = true;
private enableSyncCorrection = false;
// Callbacks
private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null;
private getCurrentPlaylistItemId: (() => string | null) | null = null;
constructor(api: Api, timeSyncCore: TimeSyncCore) {
super();
this.api = api;
this.timeSyncCore = timeSyncCore;
}
/**
* Set player controls
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// A new (or detached) player means the server's view of our ready
// state is stale — drop the dedupe latch so the next notifyReady /
// notifyBuffering actually reaches the server.
this.lastBufferingSent = null;
}
/**
* Set OSD notification handler
*/
setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void {
this.onNotifyOsd = handler;
}
/**
* Set playlist item ID getter
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.getCurrentPlaylistItemId = getter;
}
/**
* Load settings
*/
loadSettings(settings: Partial<SyncPlaySettings>): void {
if (settings.minDelaySpeedToSync !== undefined) {
this.minDelaySpeedToSync = settings.minDelaySpeedToSync;
}
if (settings.maxDelaySpeedToSync !== undefined) {
this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync;
}
if (settings.speedToSyncDuration !== undefined) {
this.speedToSyncDuration = settings.speedToSyncDuration;
}
if (settings.minDelaySkipToSync !== undefined) {
this.minDelaySkipToSync = settings.minDelaySkipToSync;
}
if (settings.useSpeedToSync !== undefined) {
this.useSpeedToSync = settings.useSpeedToSync;
}
if (settings.useSkipToSync !== undefined) {
this.useSkipToSync = settings.useSkipToSync;
}
if (settings.enableSyncCorrection !== undefined) {
this.enableSyncCorrection = settings.enableSyncCorrection;
}
}
// ============================================================================
// Player Events
// ============================================================================
/**
* Called when playback starts
*/
onPlaybackStart(): void {
this.emit("playbackstart");
}
/**
* Called when playback stops
*/
onPlaybackStop(): void {
this.lastCommand = null;
this.emit("playbackstop");
}
/**
* Called when player is ready to play
*/
onReady(): void {
this.playerIsBuffering = false;
// Cancel any pending buffering notification — we're ready before the
// 3s threshold fired, so the server never needs to know we hiccupped.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// If we're handling the first ready event after a queue change,
// pause the player BEFORE reporting ready. The subsequent
// `sendBufferingRequest(false)` will then read `isPlaying() === false`
// and the server will hold the group until we receive an Unpause.
if (this.pendingInitialPause) {
this.pendingInitialPause = false;
if (this.playerControls?.isPlaying()) {
console.log(
"SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)",
);
this.playerControls.pause();
}
}
this.sendBufferingRequest(false);
this.emit("ready");
}
/**
* Mark the next `onReady` call as the initial ready for a new queue
* item. The player will be paused before SyncPlayReady is sent so the
* server keeps the group in `Waiting` until our Unpause arrives.
*
* Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`.
* Called by the provider when a PlayQueue update is `NewPlaylist`,
* `SetCurrentItem`, `NextItem`, or `PreviousItem`.
*/
scheduleReadyRequestOnPlaybackStart(): void {
this.pendingInitialPause = true;
}
/**
* Called when player is buffering
*/
onBuffering(): void {
// Debounce: only flip into "buffering" state (and notify the server)
// if the stall lasts longer than minBufferingThresholdMillis. Mirrors
// jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only
// calls `onBuffering()` after the 3s timeout elapses. Keeping
// playerIsBuffering=false during brief blips lets sync correction
// continue to run normally.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
}
this.notifyBufferingTimeout = setTimeout(() => {
this.notifyBufferingTimeout = null;
this.playerIsBuffering = true;
this.sendBufferingRequest(true);
this.emit("buffering");
}, this.minBufferingThresholdMillis);
}
/**
* Check if player is buffering
*/
isBuffering(): boolean {
return this.playerIsBuffering;
}
/**
* Get playback difference in milliseconds
*/
getPlaybackDiff(): number {
return this.playbackDiffMillis;
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send buffering/ready request to server.
*
* NOTE: This must work even before player controls are bound, so that we
* can signal "I'm not ready yet, hold the group" while the video is still
* loading. jellyfin-web's HTML5 player gets this for free via the
* `waiting` event firing during initial buffering; we don't bind controls
* until the video is loaded, so we synthesize a position=0 buffering
* signal in the pre-bind window.
*/
async sendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) {
console.warn("SyncPlay PlaybackCore: no api for buffering request");
return;
}
// Skip if the desired state matches what we last sent. Without this,
// the React effect that drives notifyReady/notifyBuffering will flood
// the server every time the video player's isBuffering momentarily
// toggles during normal playback.
if (this.lastBufferingSent === isBuffering) {
return;
}
// Coalesce: if a request is already in flight, wait for it. This
// prevents racing two requests when state flips rapidly.
if (this.inflightBufferingRequest) {
await this.inflightBufferingRequest;
// Re-check after the in-flight request settled — the new state may
// already match.
if (this.lastBufferingSent === isBuffering) {
return;
}
}
const request = this.doSendBufferingRequest(isBuffering);
this.inflightBufferingRequest = request;
try {
await request;
this.lastBufferingSent = isBuffering;
} finally {
if (this.inflightBufferingRequest === request) {
this.inflightBufferingRequest = null;
}
}
}
private async doSendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) return;
try {
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
const isPlaying = this.playerControls?.isPlaying() ?? false;
const now = this.timeSyncCore.localDateToRemote(new Date());
const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null;
const syncPlayApi = getSyncPlayApi(this.api);
console.log(
`SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`,
{
position: currentPositionTicks,
playlistItemId,
hasPlayerControls: !!this.playerControls,
},
);
if (isBuffering) {
await syncPlayApi.syncPlayBuffering({
bufferRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
} else {
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
}
console.log(
`SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`,
);
} catch (error) {
console.error("SyncPlay: failed to send buffering request", error);
// On failure, clear the dedupe latch so the next attempt actually
// re-sends rather than getting stuck thinking the server knows.
throw error;
}
}
// ============================================================================
// Command Handling
// ============================================================================
/**
* Apply a playback command
*/
async applyCommand(command: SendCommand): Promise<void> {
console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`);
// Parse the When time from string
const commandWhen = command.When ? new Date(command.When) : new Date();
const positionTicks = command.PositionTicks ?? 0;
// Duplicate command handling — don't blindly skip. Match jellyfin-web:
// if the duplicate's scheduled time has already passed and local player
// state doesn't match, re-apply (with a force-offset for seek). This
// self-heals after a missed broadcast, reconnect, or local drift.
if (this.lastCommand?.When) {
const lastWhen = new Date(this.lastCommand.When);
if (
lastWhen.getTime() === commandWhen.getTime() &&
this.lastCommand.PositionTicks === command.PositionTicks &&
this.lastCommand.Command === command.Command &&
this.lastCommand.PlaylistItemId === command.PlaylistItemId
) {
const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen);
if (whenLocal > new Date()) {
// Still in the future — already scheduled, nothing to do.
console.debug(
"SyncPlay PlaybackCore: duplicate (still scheduled), skipping",
);
return;
}
if (!this.playerControls) {
console.debug(
"SyncPlay PlaybackCore: duplicate past command but no player",
);
return;
}
const currentPositionMs = this.playerControls.getCurrentPosition();
const isPlaying = this.playerControls.isPlaying();
const targetMs = ticksToMs(positionTicks);
const positionMatches =
Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS;
switch (command.Command) {
case "Unpause":
if (!isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling");
await this.scheduleUnpause(commandWhen, positionTicks);
}
return;
case "Pause":
if (isPlaying || !positionMatches) {
console.debug("SyncPlay PlaybackCore: dup Pause — reconciling");
this.schedulePause(commandWhen, positionTicks);
}
return;
case "Stop":
if (isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Stop — reconciling");
this.scheduleStop(commandWhen);
}
return;
case "Seek": {
if (!isPlaying && positionMatches) {
// Already paused at target — just confirm ready.
this.sendBufferingRequest(false);
return;
}
// Force a re-seek with a small random offset so the player
// actually moves (server tolerates a range).
const randomOffsetTicks =
Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) *
TicksPerMillisecond;
console.debug(
`SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`,
);
this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks);
return;
}
default:
console.error(
"SyncPlay PlaybackCore: unrecognized duplicate command",
command,
);
return;
}
}
}
this.lastCommand = command;
if (!this.playerControls) {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!");
return;
}
console.log(
`SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`,
);
switch (command.Command) {
case "Unpause":
await this.scheduleUnpause(commandWhen, positionTicks);
break;
case "Pause":
this.schedulePause(commandWhen, positionTicks);
break;
case "Stop":
this.scheduleStop(commandWhen);
break;
case "Seek":
this.scheduleSeek(commandWhen, positionTicks);
break;
default:
console.error("SyncPlay PlaybackCore: unrecognized command", command);
}
}
/**
* Schedule an unpause at a specific time
*/
private async scheduleUnpause(
playAtTime: Date,
positionTicks: number,
): Promise<void> {
this.clearScheduledCommand();
const currentTime = new Date();
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
const positionMs = ticksToMs(positionTicks);
if (playAtTimeLocal > currentTime) {
// Future command - schedule it
const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime();
// Pre-seek only when we're AHEAD of the target by more than the skip
// threshold. If we're behind, the unpause itself plays forward and
// SkipToSync/SpeedToSync will catch us up — forward-seeking now would
// just cause needless buffering. (Matches jellyfin-web.)
const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0;
const aheadByMs = currentPositionMs - positionMs;
console.log(
`SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`,
);
if (aheadByMs > this.minDelaySkipToSync) {
console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`);
this.localSeek(positionMs);
}
this.scheduledCommandTimeout = setTimeout(() => {
this.localUnpause();
this.onNotifyOsd?.("unpause");
// Enable sync after a delay
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
}, playTimeout);
console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`);
} else {
// Past command - play immediately and seek to estimated position
const elapsed = currentTime.getTime() - playAtTimeLocal.getTime();
const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond;
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localUnpause();
this.localSeek(serverPositionMs);
setTimeout(() => {
this.onNotifyOsd?.("unpause");
}, 100);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`);
}
}
/**
* Schedule a pause at a specific time
*/
private schedulePause(pauseAtTime: Date, positionTicks: number): void {
console.log("SyncPlay PlaybackCore: schedulePause called");
this.clearScheduledCommand();
const currentTime = new Date();
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW");
// If we're already paused at the target position, do nothing.
// jellyfin-web gets this for free because HTML5 video's seekTo is a
// no-op when the target equals currentTime, and pause() is a no-op
// when already paused. Our PlayerControls.seekTo always actually
// seeks, which triggers waiting→canplay and a notifyBuffering →
// notifyReady cycle. The server reacts by re-sending Pause, which
// re-enters this callback → infinite feedback loop. Guarding here
// breaks the loop while preserving normal pause behaviour.
if (this.playerControls) {
const isPlaying = this.playerControls.isPlaying();
const currentPositionMs = this.playerControls.getCurrentPosition();
const positionMatches =
positionMs <= 100 ||
Math.abs(currentPositionMs - positionMs) <=
POSITION_MATCH_TOLERANCE_MS;
if (!isPlaying && positionMatches) {
console.debug(
"SyncPlay PlaybackCore: already paused at target position, skipping",
);
this.onNotifyOsd?.("pause");
return;
}
}
this.localPause();
// Only seek if we have a valid position (not 0 or very small)
if (positionMs > 100) {
this.localSeek(positionMs);
} else {
console.log("SyncPlay PlaybackCore: skipping seek (no valid position)");
}
this.onNotifyOsd?.("pause");
};
if (pauseAtTimeLocal > currentTime) {
const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
console.log(
`SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`,
);
} else {
console.log("SyncPlay PlaybackCore: immediate pause (past time)");
callback();
}
}
/**
* Schedule a stop at a specific time
*/
private scheduleStop(stopAtTime: Date): void {
this.clearScheduledCommand();
const currentTime = new Date();
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
const callback = () => {
this.localStop();
};
if (stopAtTimeLocal > currentTime) {
const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate stop");
}
}
/**
* Schedule a seek at a specific time.
*
* Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready"
* (player finished buffering at the new position) -> pause and report ready
* to the server so the group can resume. This handles the common case
* where the player must rebuffer after the seek.
*/
private scheduleSeek(seekAtTime: Date, positionTicks: number): void {
this.clearScheduledCommand();
const currentTime = new Date();
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
this.localUnpause();
this.localSeek(positionMs);
this.onNotifyOsd?.("seek");
// Wait for the local player to report ready ("onReady" fires this),
// then pause and tell the server we're ready at the new position.
waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS)
.then(() => {
this.localPause();
this.sendBufferingRequest(false);
})
.catch((error) => {
console.warn(
`SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`,
error,
);
this.localSeek(positionMs);
});
};
if (seekAtTimeLocal > currentTime) {
const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate seek");
}
}
/**
* Clear scheduled command
*/
private clearScheduledCommand(): void {
if (this.scheduledCommandTimeout) {
clearTimeout(this.scheduledCommandTimeout);
this.scheduledCommandTimeout = null;
}
if (this.syncTimeout) {
clearTimeout(this.syncTimeout);
this.syncTimeout = null;
}
this.syncEnabled = false;
// Reset playback rate
if (this.playerControls && this.playerControls.getSpeed() !== 1.0) {
this.playerControls.setSpeed(1.0);
}
this.emit("syncing", false, "None");
}
// ============================================================================
// Local Playback Control
// ============================================================================
private localUnpause(): void {
this.playerControls?.play();
}
private localPause(): void {
this.playerControls?.pause();
}
private localSeek(positionMs: number): void {
console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`);
if (this.playerControls) {
this.playerControls.seekTo(positionMs);
console.log("SyncPlay PlaybackCore: seekTo called on playerControls");
} else {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!");
}
}
private localStop(): void {
this.playerControls?.pause();
}
// ============================================================================
// Time Sync
// ============================================================================
/**
* Estimate current position ticks given a past state
*/
estimateCurrentTicks(
ticks: number,
when: Date,
currentTime: Date = new Date(),
): number {
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
return (
ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond
);
}
/**
* Sync playback time during playback
*/
syncPlaybackTime(currentPositionMs: number): void {
if (!this.playerControls || !this.lastCommand) return;
// Only sync during unpause
if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return;
// Don't apply sync corrections if the active player isn't on the same
// playlist item that the group is playing (e.g. user switched item
// locally, or queue update in flight). Prevents seeking the wrong item.
const currentItemId = this.getCurrentPlaylistItemId?.();
if (
currentItemId &&
this.lastCommand.PlaylistItemId &&
this.lastCommand.PlaylistItemId !== currentItemId
) {
return;
}
const currentTime = new Date();
const currentPositionTicks = msToTicks(currentPositionMs);
const lastCommandWhen = this.lastCommand.When
? new Date(this.lastCommand.When)
: new Date();
// Estimate server position
const serverPositionTicks = this.estimateCurrentTicks(
this.lastCommand.PositionTicks ?? 0,
lastCommandWhen,
currentTime,
);
// Calculate difference
const diffMillis =
(serverPositionTicks - currentPositionTicks) / TicksPerMillisecond;
this.playbackDiffMillis = diffMillis;
this.emit("playback-diff", diffMillis);
// Rate-limit sync attempts
const elapsed = currentTime.getTime() - this.lastSyncTime.getTime();
if (elapsed < this.maxDelaySpeedToSync / 2) return;
this.lastSyncTime = currentTime;
if (!this.syncEnabled || !this.enableSyncCorrection) return;
const absDiffMillis = Math.abs(diffMillis);
// SpeedToSync
if (
this.useSpeedToSync &&
absDiffMillis >= this.minDelaySpeedToSync &&
absDiffMillis < this.maxDelaySpeedToSync
) {
let speedToSyncTime = this.speedToSyncDuration;
// Prevent negative speed
const MinSpeed = 0.2;
if (diffMillis <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
}
const speed = 1 + diffMillis / speedToSyncTime;
if (speed > 0) {
this.playerControls.setSpeed(speed);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`);
this.syncTimeout = setTimeout(() => {
this.playerControls?.setSpeed(1.0);
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, speedToSyncTime);
console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`);
}
}
// SkipToSync
else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localSeek(serverPositionMs);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`);
} else {
// Synced
if (this.syncAttempts > 0) {
console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`);
}
this.syncAttempts = 0;
}
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Reset PlaybackCore state — used when SyncPlay is disabled so we don't
* carry stale commands, scheduled timers, or sync state into the next
* session.
*/
reset(): void {
this.clearScheduledCommand();
this.lastCommand = null;
this.lastSyncTime = new Date();
this.syncAttempts = 0;
this.playbackDiffMillis = 0;
this.playerIsBuffering = false;
// Forget what we last told the server so the next session starts fresh.
this.lastBufferingSent = null;
this.inflightBufferingRequest = null;
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// Drop a pending pause-before-ready flag so it can't leak into the
// next group.
this.pendingInitialPause = false;
}
/**
* Destroy the playback core
*/
destroy(): void {
this.clearScheduledCommand();
this.removeAllListeners();
this.playerControls = null;
this.onNotifyOsd = null;
this.getCurrentPlaylistItemId = null;
}
}

View File

@@ -1,292 +0,0 @@
/**
* QueueCore
*
* Manages the shared playlist/queue for SyncPlay.
* Handles queue updates from the server.
*
* Based on jellyfin-web's QueueCore.js
*/
import { EventEmitter } from "./Helper";
import type {
GroupRepeatMode,
GroupShuffleMode,
PlayQueueUpdate,
SyncPlayQueueItem,
} from "./types";
/**
* QueueCore - Manages the shared playlist
*/
export class QueueCore extends EventEmitter {
// Queue state
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
private playlist: SyncPlayQueueItem[] = [];
// Callbacks
private onStartPlayback: (() => void) | null = null;
private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null =
null;
/**
* Set the start playback callback
*/
setStartPlaybackHandler(handler: (() => void) | null): void {
this.onStartPlayback = handler;
}
/**
* Set the ticks estimator function
*/
setTicksEstimator(
estimator: ((ticks: number, when: Date) => number) | null,
): void {
this.estimateCurrentTicks = estimator;
}
// ============================================================================
// Queue State
// ============================================================================
/**
* Get the current playlist
*/
getPlaylist(): SyncPlayQueueItem[] {
return [...this.playlist];
}
/**
* Check if playlist is empty
*/
isPlaylistEmpty(): boolean {
return this.playlist.length === 0;
}
/**
* Get current playing index
*/
getCurrentPlaylistIndex(): number {
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
}
/**
* Get current playlist item ID
*/
getCurrentPlaylistItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.PlaylistItemId ?? null;
}
/**
* Get current item's Jellyfin ID (the actual media item ID)
*/
getCurrentItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.ItemId ?? null;
}
/**
* Get the current item from the playlist
*/
getCurrentItem(): SyncPlayQueueItem | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index] ?? null;
}
/**
* Get the last update time
*/
getLastUpdate(): Date | null {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate) : null;
}
/**
* Get the last update time as timestamp
*/
getLastUpdateTime(): number {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate).getTime() : 0;
}
/**
* Get start position ticks
*/
getStartPositionTicks(): number {
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
}
/**
* Get repeat mode
*/
getRepeatMode(): GroupRepeatMode {
return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone";
}
/**
* Get shuffle mode
*/
getShuffleMode(): GroupShuffleMode {
return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted";
}
/**
* Get playlist as item IDs
*/
getPlaylistAsItemIds(): (string | undefined)[] {
return this.playlist.map((item) => item.ItemId);
}
// ============================================================================
// Queue Updates
// ============================================================================
/**
* Update the play queue from server
*/
async updatePlayQueue(update: PlayQueueUpdate): Promise<void> {
// Parse the last update time
const updateTime = update.LastUpdate
? new Date(update.LastUpdate).getTime()
: 0;
// Ignore old updates
if (updateTime <= this.getLastUpdateTime()) {
console.debug("SyncPlay QueueCore: ignoring old update", update);
return;
}
console.log("SyncPlay QueueCore: processing update", {
reason: update.Reason,
position: update.StartPositionTicks,
index: update.PlayingItemIndex,
});
// Check for position change (seek)
const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
const newPosition = update.StartPositionTicks ?? 0;
const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference
// Store the update
this.lastPlayQueueUpdate = update;
this.playlist = update.Playlist ?? [];
// Emit update event
this.emit("queue-update", update);
// Handle different update reasons
switch (update.Reason) {
case "NewPlaylist":
// Start playback with new playlist
this.onStartPlayback?.();
break;
case "SetCurrentItem":
case "NextItem":
case "PreviousItem":
// Item changed
this.emit("item-change", this.getCurrentPlaylistItemId());
break;
case "RemoveItems":
case "MoveItem":
case "Queue":
case "QueueNext":
// Playlist modified
this.emit("playlist-change", this.playlist);
break;
case "RepeatMode":
this.emit("repeat-mode-change", update.RepeatMode);
break;
case "ShuffleMode":
this.emit("shuffle-mode-change", update.ShuffleMode);
break;
default:
console.debug(
"SyncPlay QueueCore: unhandled update reason",
update.Reason,
);
break;
}
// Emit seek if position changed significantly (likely a seek from another device)
if (positionChanged && update.Reason !== "NewPlaylist") {
console.log(
`SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`,
);
this.emit("seek", newPosition);
}
}
/**
* Get estimated start position based on last command
*/
getEstimatedStartPosition(
lastCommandPositionTicks: number | null,
lastCommandWhen: Date | null,
): number {
if (lastCommandPositionTicks !== null && lastCommandWhen !== null) {
// Use playback command if recent enough
if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) {
return (
this.estimateCurrentTicks?.(
lastCommandPositionTicks,
lastCommandWhen,
) ?? lastCommandPositionTicks
);
}
}
// Fall back to queue update position
const startTicks = this.getStartPositionTicks();
const lastUpdate = this.getLastUpdate();
if (lastUpdate) {
return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks;
}
return startTicks;
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Clear the queue
*/
clear(): void {
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
/**
* Destroy the queue core
*/
destroy(): void {
this.clear();
this.removeAllListeners();
this.onStartPlayback = null;
this.estimateCurrentTicks = null;
}
}

View File

@@ -1,594 +0,0 @@
/**
* SyncPlayProvider
*
* React context provider for SyncPlay functionality.
* Manages the SyncPlay manager and exposes hooks for components.
*/
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter";
import i18n from "@/i18n";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SyncPlayController } from "./Controller";
import { ticksToMs } from "./Helper";
import { SyncPlayManager } from "./Manager";
import { PlaybackCore } from "./PlaybackCore";
import { QueueCore } from "./QueueCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayOsdAction,
SyncPlayStats,
} from "./types";
import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket";
// ============================================================================
// Context Types
// ============================================================================
interface SyncPlayContextValue {
// State
isEnabled: boolean;
isReady: boolean;
groupInfo: GroupInfoDto | null;
canJoinGroups: boolean;
canCreateGroups: boolean;
// Group management
joinGroup: (groupId: string) => Promise<void>;
createGroup: (groupName?: string) => Promise<void>;
leaveGroup: () => Promise<void>;
getGroups: () => Promise<GroupInfoDto[]>;
// Playback control delegation
controller: SyncPlayController | null;
// Player integration
setPlayerControls: (controls: PlayerControls | null) => void;
notifyReady: () => void;
notifyBuffering: () => void;
// Stats
getStats: () => SyncPlayStats;
// OSD state
osdAction: SyncPlayOsdAction | null;
isSyncing: boolean;
syncMethod: string;
/** In-flight Unpause/Pause request, before the server has echoed back. */
pendingPlaybackCommand: "Unpause" | "Pause" | null;
}
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
// ============================================================================
// Provider Component
// ============================================================================
interface SyncPlayProviderProps {
children: ReactNode;
}
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useAppRouter();
// Core modules - use state for manager so WebSocket hook re-runs when ready
const [manager, setManager] = useState<SyncPlayManager | null>(null);
const playbackCoreRef = useRef<PlaybackCore | null>(null);
const queueCoreRef = useRef<QueueCore | null>(null);
const controllerRef = useRef<SyncPlayController | null>(null);
// Track if we're already on the player page to avoid duplicate navigations
const isNavigatingToPlayerRef = useRef(false);
// State
const [isEnabled, setIsEnabled] = useState(false);
const [isReady, setIsReady] = useState(false);
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncMethod, setSyncMethod] = useState("None");
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
"Unpause" | "Pause" | null
>(null);
// Permission checks
const canJoinGroups = useMemo(() => {
const access = user?.Policy?.SyncPlayAccess;
return access !== "None" && access !== undefined;
}, [user?.Policy?.SyncPlayAccess]);
const canCreateGroups = useMemo(() => {
return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups";
}, [user?.Policy?.SyncPlayAccess]);
// Initialize manager
useEffect(() => {
if (!api) return;
// Create manager and cores
const manager = new SyncPlayManager(api);
const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore());
const queueCore = new QueueCore();
const controller = new SyncPlayController(api, manager, queueCore);
setManager(manager);
playbackCoreRef.current = playbackCore;
queueCoreRef.current = queueCore;
controllerRef.current = controller;
// Wire up manager callbacks
manager.setPlaybackCommandHandler((command: SendCommand) => {
playbackCore.applyCommand(command);
});
manager.setQueueUpdateHandler((update: PlayQueueUpdate) => {
queueCore.updatePlayQueue(update);
});
manager.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
// When SyncPlay is disabled, flush PlaybackCore's scheduled commands and
// cached state so we don't carry ghost commands into the next group.
manager.setDisableHandler(() => {
playbackCore.reset();
});
// Also clear the cached PlayQueue snapshot on disable. If we don't, then
// when the user later re-joins the same group, the server's first
// PlayQueue echo (which can carry the same LastUpdate as the snapshot we
// saw last session) gets dropped by QueueCore's stale-update guard, and
// the receiver never auto-navigates to the group's content.
manager.setQueueClearHandler(() => {
queueCore.clear();
});
// Wire up playback core callbacks
playbackCore.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
playbackCore.setOsdHandler((action) => {
setOsdAction(action);
// Clear after display
setTimeout(() => setOsdAction(null), 1500);
});
// Wire up queue core
queueCore.setTicksEstimator((ticks, when) => {
return playbackCore.estimateCurrentTicks(ticks, when);
});
// Navigate to player when group starts playing new content
queueCore.setStartPlaybackHandler(async () => {
const itemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!itemId) {
console.warn("SyncPlay: new playlist but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
console.log("SyncPlay: navigating to player for item", itemId);
isNavigatingToPlayerRef.current = true;
// Mirror jellyfin-web's `QueueCore.startPlayback` ordering:
// 1. followGroupPlayback (IgnoreWait:false) — tell server we follow
// 2. scheduleReadyRequestOnPlaybackStart — arm initial pause
// 3. playerWrapper.localPlay (== our router navigation) — start loading
// The arm-then-navigate order matters: scheduling must happen BEFORE
// navigation so the flag is set when the player attaches and fires
// its first `notifyReady`. Otherwise we race the player and the
// initial SyncPlayReady reports `IsPlaying:true`, defeating the
// server's "hold the group until everyone is parked" semantics.
await manager.followGroupPlayback();
playbackCore.scheduleReadyRequestOnPlaybackStart();
// Show toast notification
toast(i18n.t("syncplay.joining_playback"));
// Navigate to the player with the item. Use `replace` so repeated
// queue updates don't stack player screens on the history.
const queryParams = new URLSearchParams({
itemId: itemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true", // Mark this as a SyncPlay-initiated playback
}).toString();
router.push(`/player/direct-player?${queryParams}` as any);
// Reset navigation flag after a short delay
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Also handle item changes (next/previous in playlist)
queueCore.on("item-change", () => {
const newItemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!newItemId) {
console.warn("SyncPlay: item change but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
return;
}
console.log("SyncPlay: item changed, navigating to", newItemId);
isNavigatingToPlayerRef.current = true;
// Same pause-before-ready dance as NewPlaylist — the new item's
// player needs to park at the start position and report
// IsPlaying:false so the server holds the group until everyone is
// ready for the next Unpause. Mirrors jellyfin-web's
// `QueueCore.setCurrentPlaylistItem`.
playbackCore.scheduleReadyRequestOnPlaybackStart();
const queryParams = new URLSearchParams({
itemId: newItemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true",
}).toString();
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Handle seek events from other devices - pause first, then seek (like Jellyfin-web)
queueCore.on("seek", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek event received, pausing then seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Subscribe to manager events
manager.on("enabled", (...args: unknown[]) => {
const enabled = args[0] as boolean;
setIsEnabled(enabled);
if (!enabled) {
setIsReady(false);
setGroupInfoDto(null);
}
});
manager.on("syncing", (...args: unknown[]) => {
const syncing = args[0] as boolean;
const method = args[1] as string;
setIsSyncing(syncing);
setSyncMethod(method);
});
// Keep React-side groupInfo in sync with Manager mutations. Without this,
// CenterControls' `groupInfo.State === 'Waiting'` check is stale because
// Manager mutates the existing object reference rather than emitting a
// fresh one.
manager.on("group-info-change", (...args: unknown[]) => {
setGroupInfoDto(args[0] as GroupInfoDto);
});
// Expose pending Unpause/Pause to consumers (e.g. CenterControls renders
// a spinner instead of the play/pause button while a request is in
// flight — mirrors jellyfin-web's "schedule-play" indicator).
manager.on("pending-playback-change", (...args: unknown[]) => {
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
});
// When entering Waiting state, report ready through PlaybackCore
manager.on("waiting-for-ready", () => {
console.log(
"SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()",
);
playbackCore.onReady();
});
// Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek)
manager.on("seek-from-state-update", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek from StateUpdate, seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Initialize
manager.init();
return () => {
manager.destroy();
playbackCore.destroy();
queueCore.destroy();
setManager(null);
playbackCoreRef.current = null;
queueCoreRef.current = null;
controllerRef.current = null;
};
}, [api]);
// Update group info when enabled
useEffect(() => {
if (isEnabled && manager) {
setGroupInfoDto(manager.getGroupInfo());
setIsReady(manager.isSyncPlayReady());
}
}, [isEnabled, manager]);
// Connect to WebSocket messages - manager is now state so hook re-runs when ready
useSyncPlayWebSocket(manager);
// ============================================================================
// Group Management
// ============================================================================
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
if (!api) return [];
try {
const syncPlayApi = getSyncPlayApi(api);
const response = await syncPlayApi.syncPlayGetGroups();
return (response.data as unknown as GroupInfoDto[]) ?? [];
} catch (error) {
console.error("SyncPlay: failed to get groups", error);
return [];
}
}, [api]);
const joinGroup = useCallback(
async (groupId: string): Promise<void> => {
if (!api) return;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId: groupId,
},
});
} catch (error) {
console.error("SyncPlay: failed to join group", error);
throw error;
}
},
[api],
);
const createGroup = useCallback(
async (groupName?: string): Promise<void> => {
if (!api || !user) return;
const name = groupName || `${user.Name}'s Group`;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayCreateGroup({
newGroupRequestDto: {
GroupName: name,
},
});
} catch (error) {
console.error("SyncPlay: failed to create group", error);
throw error;
}
},
[api, user],
);
const leaveGroup = useCallback(async (): Promise<void> => {
if (!api) return;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayLeaveGroup();
} catch (error) {
console.error("SyncPlay: failed to leave group", error);
throw error;
}
}, [api]);
// Re-join the SyncPlay group when the app returns from background.
//
// Backgrounding tears down our WebSocket (see WebSocketProvider) and the
// server may drop us from the group after its inactivity timeout. Even
// when it doesn't, we likely missed any commands/state-updates broadcast
// while we were suspended. Re-issuing the join is idempotent on the
// server and gets us a fresh GroupJoined snapshot.
const lastGroupIdRef = useRef<string | null>(null);
useEffect(() => {
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
}, [groupInfo?.GroupId]);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!api) return;
const subscription = AppState.addEventListener("change", (nextAppState) => {
const previousAppState = appStateRef.current;
appStateRef.current = nextAppState;
const becameActive =
(previousAppState === "background" ||
previousAppState === "inactive") &&
nextAppState === "active";
if (!becameActive) return;
const groupId = lastGroupIdRef.current;
if (!groupId) return;
// Give the WebSocket a moment to reconnect (handled by
// WebSocketProvider on the same 'active' transition) so the
// server's GroupJoined broadcast actually reaches us.
setTimeout(() => {
console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`);
getSyncPlayApi(api)
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
.catch((error) => {
console.error("SyncPlay: failed to rejoin group", error);
});
}, 1000);
});
return () => {
subscription.remove();
};
}, [api]);
// ============================================================================
// Player Integration
// ============================================================================
const setPlayerControls = useCallback(
(controls: PlayerControls | null) => {
manager?.setPlayerControls(controls);
playbackCoreRef.current?.setPlayerControls(controls);
},
[manager],
);
const notifyReady = useCallback(() => {
console.log("SyncPlay: notifyReady called");
playbackCoreRef.current?.onReady();
}, []);
const notifyBuffering = useCallback(() => {
console.log("SyncPlay: notifyBuffering called");
playbackCoreRef.current?.onBuffering();
}, []);
// ============================================================================
// Stats
// ============================================================================
const getStats = useCallback((): SyncPlayStats => {
return (
manager?.getStats() ?? {
timeSyncDevice: "None",
timeSyncOffset: "0.00",
playbackDiff: "0.00",
syncMethod: "None",
}
);
}, [manager]);
// ============================================================================
// Context Value
// ============================================================================
const contextValue: SyncPlayContextValue = useMemo(
() => ({
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
controller: controllerRef.current,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
}),
[
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
],
);
return (
<SyncPlayContext.Provider value={contextValue}>
{children}
</SyncPlayContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to access SyncPlay state and actions
*/
export function useSyncPlay(): SyncPlayContextValue {
const context = useContext(SyncPlayContext);
if (!context) {
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
}
return context;
}
/**
* Hook to access the SyncPlay controller
*/
export function useSyncPlayController(): SyncPlayController | null {
const { controller } = useSyncPlay();
return controller;
}

View File

@@ -1,284 +0,0 @@
/**
* TimeSyncCore
*
* Manages time synchronization with the Jellyfin server.
* Uses NTP-like algorithm to calculate clock offset between client and server.
*
* Based on jellyfin-web's TimeSyncCore.js and TimeSync.js
*/
import type { Api } from "@jellyfin/sdk";
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
import type { TimeSyncMeasurement } from "./types";
// Time estimation constants
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // ms - fast polling initially
const PollingIntervalLowProfile = 60000; // ms - slow polling once synced
const GreedyPingCount = 3;
/**
* Stores a single time sync measurement
*/
class Measurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
constructor(data: TimeSyncMeasurement) {
this.requestSent = data.requestSent;
this.requestReceived = data.requestReceived;
this.responseSent = data.responseSent;
this.responseReceived = data.responseReceived;
}
/**
* Calculate time offset from server, in milliseconds.
* Offset = (t1 - t0 + t2 - t3) / 2
* where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received
*/
getOffset(): number {
return (
(this.requestReceived -
this.requestSent +
(this.responseSent - this.responseReceived)) /
2
);
}
/**
* Get round-trip delay, in milliseconds.
*/
getDelay(): number {
return (
this.responseReceived -
this.requestSent -
(this.responseSent - this.requestReceived)
);
}
/**
* Get ping time (half of round-trip), in milliseconds.
*/
getPing(): number {
return this.getDelay() / 2;
}
}
export type TimeSyncEventCallback = (
error: Error | null,
timeOffset: number | null,
ping: number | null,
) => void;
/**
* TimeSyncCore - Manages time synchronization with the server
*/
export class TimeSyncCore {
private api: Api;
private poller: ReturnType<typeof setTimeout> | null = null;
private pingStop = true;
private pollingInterval = PollingIntervalGreedy;
private pings = 0;
private measurement: Measurement | null = null;
private measurements: Measurement[] = [];
private extraTimeOffset = 0;
private onUpdateCallback: TimeSyncEventCallback | null = null;
constructor(api: Api) {
this.api = api;
}
/**
* Set callback for time sync updates
*/
onUpdate(callback: TimeSyncEventCallback): void {
this.onUpdateCallback = callback;
}
/**
* Check if time sync is ready (has at least one measurement)
*/
isReady(): boolean {
return this.measurement !== null;
}
/**
* Get the current time offset with server, in milliseconds.
*/
getTimeOffset(): number {
return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset;
}
/**
* Get current ping time to server, in milliseconds.
*/
getPing(): number {
return this.measurement?.getPing() ?? 0;
}
/**
* Set extra time offset for manual adjustment
*/
setExtraTimeOffset(offset: number): void {
this.extraTimeOffset = offset;
}
/**
* Convert server time to local time.
*/
remoteDateToLocal(remote: Date): Date {
// remote - local = offset, so local = remote - offset
return new Date(remote.getTime() - this.getTimeOffset());
}
/**
* Convert local time to server time.
*/
localDateToRemote(local: Date): Date {
// remote - local = offset, so remote = local + offset
return new Date(local.getTime() + this.getTimeOffset());
}
/**
* Get the display name of the sync device
*/
getActiveDeviceName(): string {
return "Server";
}
/**
* Make a ping request to the server to measure time offset
*/
private async requestPing(): Promise<TimeSyncMeasurement> {
const requestSent = Date.now();
const timeSyncApi = getTimeSyncApi(this.api);
const response = await timeSyncApi.getUtcTime();
const responseReceived = Date.now();
const data = response.data;
const requestReceived = new Date(data.RequestReceptionTime!).getTime();
const responseSent = new Date(data.ResponseTransmissionTime!).getTime();
return {
requestSent,
requestReceived,
responseSent,
responseReceived,
};
}
/**
* Update time offset with a new measurement
*/
private updateTimeOffset(measurement: Measurement): void {
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// Pick measurement with minimum delay (most accurate)
const sortedMeasurements = [...this.measurements].sort(
(a, b) => a.getDelay() - b.getDelay(),
);
this.measurement = sortedMeasurements[0];
}
/**
* Internal poller for ping requests
*/
private internalRequestPing(): void {
if (this.poller !== null || this.pingStop) {
return;
}
this.poller = setTimeout(async () => {
this.poller = null;
try {
const result = await this.requestPing();
this.onPingSuccess(result);
} catch (error) {
this.onPingError(error as Error);
}
// Schedule next ping
this.internalRequestPing();
}, this.pollingInterval);
}
/**
* Handle successful ping response
*/
private onPingSuccess(result: TimeSyncMeasurement): void {
const measurement = new Measurement(result);
this.updateTimeOffset(measurement);
// Slow down polling after initial greedy phase
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing());
}
/**
* Handle ping error
*/
private onPingError(error: Error): void {
console.error("SyncPlay TimeSyncCore: ping error", error);
this.onUpdateCallback?.(error, null, null);
}
/**
* Start the time sync poller
*/
startPing(): void {
this.pingStop = false;
this.internalRequestPing();
}
/**
* Stop the time sync poller
*/
stopPing(): void {
this.pingStop = true;
if (this.poller !== null) {
clearTimeout(this.poller);
this.poller = null;
}
}
/**
* Force an immediate update (reset to greedy mode)
*/
forceUpdate(): void {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/**
* Drop all accumulated measurements
*/
resetMeasurements(): void {
this.measurement = null;
this.measurements = [];
}
/**
* Clean up resources
*/
destroy(): void {
this.stopPing();
this.resetMeasurements();
this.onUpdateCallback = null;
}
}

View File

@@ -1,25 +0,0 @@
/**
* SyncPlay Module
*
* Synchronized playback for Jellyfin.
* Allows multiple users to watch content together in sync.
*/
export { SyncPlayController } from "./Controller";
// Helpers
export * from "./Helper";
// Core modules
export { SyncPlayManager } from "./Manager";
export { PlaybackCore } from "./PlaybackCore";
export { QueueCore } from "./QueueCore";
// Provider and hooks
export {
SyncPlayProvider,
useSyncPlay,
useSyncPlayController,
} from "./SyncPlayProvider";
export { TimeSyncCore } from "./TimeSyncCore";
// Types
export * from "./types";

View File

@@ -1,139 +0,0 @@
/**
* SyncPlay Types
*
* Re-exports Jellyfin SDK types and defines app-specific types.
* Following the pattern used in offline downloads.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
// ============================================================================
// Re-export SDK Types
// ============================================================================
// Group types
// Playback command types
// Queue types
// User access type
// Group update message types
export type {
GroupInfoDto,
GroupRepeatMode,
GroupShuffleMode,
GroupStateType,
GroupUpdate,
GroupUpdateType,
PlayQueueUpdate,
PlayQueueUpdateReason,
SendCommand,
SendCommandType,
SyncPlayGroupJoinedUpdate,
SyncPlayGroupLeftUpdate,
SyncPlayPlayQueueUpdate,
SyncPlayQueueItem,
SyncPlayStateUpdate,
SyncPlayUserAccessType,
SyncPlayUserJoinedUpdate,
SyncPlayUserLeftUpdate,
} from "@jellyfin/sdk/lib/generated-client/models";
// ============================================================================
// Constants
// ============================================================================
export const TicksPerMillisecond = 10000;
export const WaitForEventDefaultTimeout = 30000; // milliseconds
export const WaitForPlayerEventTimeout = 500; // milliseconds
// ============================================================================
// App-Specific Types (not in SDK)
// ============================================================================
/**
* Time sync measurement for NTP-like synchronization
*/
export interface TimeSyncMeasurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
}
/**
* Player controls interface for integrating with MPV player
*/
export interface PlayerControls {
play: () => void;
pause: () => void;
seekTo: (positionMs: number) => void;
setSpeed: (speed: number) => void;
getSpeed: () => number;
getCurrentPosition: () => number;
isPlaying: () => boolean;
isBuffering: () => boolean;
}
/**
* OSD action types for visual feedback
*/
export type SyncPlayOsdAction =
| "schedule-play"
| "unpause"
| "pause"
| "seek"
| "buffering"
| "wait-pause"
| "wait-unpause";
/**
* SyncPlay settings for sync correction algorithms
*/
export interface SyncPlaySettings {
// SpeedToSync settings
minDelaySpeedToSync: number;
maxDelaySpeedToSync: number;
speedToSyncDuration: number;
// SkipToSync settings
minDelaySkipToSync: number;
// Feature toggles
useSpeedToSync: boolean;
useSkipToSync: boolean;
enableSyncCorrection: boolean;
// Time sync
extraTimeOffset: number;
}
export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = {
minDelaySpeedToSync: 60.0,
maxDelaySpeedToSync: 3000.0,
speedToSyncDuration: 1000.0,
minDelaySkipToSync: 400.0,
useSpeedToSync: true,
useSkipToSync: true,
enableSyncCorrection: false,
extraTimeOffset: 0.0,
};
/**
* Stats for debugging/display
*/
export interface SyncPlayStats {
timeSyncDevice: string;
timeSyncOffset: string;
playbackDiff: string;
syncMethod: string;
}
/**
* Play options for starting playback
*/
export interface PlayOptions {
ids?: string[];
items?: BaseItemDto[];
startIndex?: number;
startPositionTicks?: number;
serverId?: string;
}

View File

@@ -1,87 +0,0 @@
/**
* useSyncPlayWebSocket
*
* Hook that connects the SyncPlay manager to WebSocket messages.
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
*
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
* rather than reading WebSocketProvider's `lastMessage` state. That state
* only holds the most recent message, so when the server emits bursts
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
* within a few ms), React's batching causes earlier messages to be
* overwritten before our effect can read them — most notably the
* GroupJoined message, which left the joining client thinking it hadn't
* joined while other members already saw it as a participant.
*
* Listening on the raw socket guarantees we see every frame in order.
*/
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { SyncPlayManager } from "./Manager";
import type { SendCommand } from "./types";
/**
* Hook to connect SyncPlay manager to WebSocket
*/
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
const { ws } = useWebSocketContext();
useEffect(() => {
if (!ws || !manager) return;
const handleMessage = (event: WebSocketMessageEvent) => {
let parsed: { MessageType?: string; Data?: unknown };
try {
parsed = JSON.parse(event.data as string);
} catch (error) {
console.error("SyncPlay: failed to parse WebSocket message", error);
return;
}
const { MessageType, Data } = parsed;
// Only handle SyncPlay messages here; everything else is handled
// elsewhere via WebSocketProvider's lastMessage.
if (!MessageType?.startsWith("SyncPlay")) return;
console.log(
`SyncPlay WebSocket [${MessageType}]:`,
JSON.stringify(Data).substring(0, 300),
);
switch (MessageType) {
case "SyncPlayCommand": {
const command = Data as SendCommand;
console.log(
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
command.Command === "Seek"
? `position=${command.PositionTicks}`
: "",
);
// Note: it's normal for controls to be missing here during the
// join → navigate → load window. Manager stashes the command and
// replays it on attach.
manager.processCommand(command);
break;
}
case "SyncPlayGroupUpdate": {
const update = Data as { Type?: string; Data?: unknown };
console.debug("SyncPlay: group update -", update.Type);
manager.processGroupUpdate(update);
break;
}
default:
break;
}
};
ws.addEventListener("message", handleMessage);
return () => {
ws.removeEventListener("message", handleMessage);
};
}, [ws, manager]);
}

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"
} }
} }

View File

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

View File

@@ -108,7 +108,7 @@
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads", "downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", "downloads_feature_description": "Download movies and tv-shows to view offline.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
@@ -384,7 +384,6 @@
"plugins": { "plugins": {
"plugins_title": "Plugins", "plugins_title": "Plugins",
"jellyseerr": { "jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL", "server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Seerr URL", "server_url_placeholder": "Seerr URL",
@@ -534,6 +533,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back", "back": "Back",
"delete": "Delete", "delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong", "something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -577,6 +577,8 @@
"audio": "Audio", "audio": "Audio",
"subtitle": "Subtitle", "subtitle": "Subtitle",
"play": "Play", "play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
@@ -649,7 +651,8 @@
"poster": "Poster", "poster": "Poster",
"cover": "Cover", "cover": "Cover",
"show_titles": "Show Titles", "show_titles": "Show Titles",
"show_stats": "Show Stats" "show_stats": "Show Stats",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -679,6 +682,7 @@
}, },
"player": { "player": {
"live": "LIVE", "live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -998,30 +1002,6 @@
"all": "All media (default)" "all": "All media (default)"
} }
}, },
"syncplay": {
"title": "SyncPlay",
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_group": "Create Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"group_id": "Group ID",
"leader": "Leader",
"members": "members",
"enabled": "SyncPlay enabled",
"disabled": "SyncPlay disabled",
"user_joined": "{{username}} joined the group",
"user_left": "{{username}} left the group",
"permission_required": "Permission required to use SyncPlay",
"group_does_not_exist": "Group does not exist",
"create_denied": "Permission denied to create group",
"join_denied": "Permission denied to join group",
"library_access_denied": "Library access denied",
"waiting_for_group": "Waiting for group...",
"joining_playback": "Joining group playback...",
"failed_to_start": "Failed to start SyncPlay group playback"
},
"companion_login": { "companion_login": {
"title": "Pair with TV", "title": "Pair with TV",
"align_qr": "Align the QR code within the frame", "align_qr": "Align the QR code within the frame",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,187 +1,228 @@
{ {
"login": { "login": {
"username_required": "Username Is Required", "username_required": "사용자 이름이 필요합니다",
"error_title": "Error", "error_title": "오류",
"login_title": "Log In", "login_title": "로그인",
"login_to_title": "Log in to", "login_to_title": "다음 서비스에 연결 중",
"username_placeholder": "Username", "select_user": "Select a user to log in",
"password_placeholder": "Password", "add_user_to_login": "Add a user to log in",
"login_button": "Log In", "add_user": "Add User",
"quick_connect": "Quick Connect", "username_placeholder": "사용자 이름",
"enter_code_to_login": "Enter code {{code}} to login", "password_placeholder": "비밀번호",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", "login_button": "로그인",
"got_it": "Got It", "quick_connect": "퀵 커넥트",
"connection_failed": "Connection Failed", "enter_code_to_login": "로그인 하기 위해 코드{{code}}를 입력하세요",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", "failed_to_initiate_quick_connect": "Quick Connect 연결을 시작하는 데 실패했습니다",
"an_unexpected_error_occured": "An Unexpected Error Occurred", "got_it": "성공",
"change_server": "Change Server", "connection_failed": "연결 실패",
"invalid_username_or_password": "Invalid Username or Password", "could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in", "an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", "change_server": "서버 변경",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.", "invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"there_is_a_server_error": "There is a server error", "user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "server_is_taking_too_long_to_respond_try_again_later": "서버 응답이 너무 느립니다. 나중에 다시 시도하세요",
"server_received_too_many_requests_try_again_later": "서버가 너무 많은 요청을 받았습니다. 나중에 다시 시도하세요.",
"there_is_a_server_error": "서버 에러",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered", "too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
"server": { "server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com", "server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect", "connect_button": "연결",
"previous_servers": "Previous Servers", "previous_servers": "이전 서버",
"clear_button": "Clear all", "clear_button": "모두 지우기",
"swipe_to_remove": "Swipe to remove", "swipe_to_remove": "스와이프해서 지우기",
"search_for_local_servers": "Search for Local Servers", "search_for_local_servers": "로컬 서버 찾기",
"searching": "Searching...", "searching": "찾는 중...",
"servers": "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": "전환하려면 4자리 PIN 필요함",
"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": "{{username}} PIN 입력",
"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...", "checking_server_connection": "서버 연결 체크중...",
"no_internet": "No Internet", "no_internet": "인터넷에 연결되지 않음",
"no_items": "No Items", "no_items": "항목 없음",
"no_internet_message": "No worries, you can still watch\ndownloaded content.", "no_internet_message": "걱정마세요. 다운로드 된 컨텐츠는 여전히 볼 수 있습니다.",
"checking_server_connection_message": "Checking connection to server", "checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads", "go_to_downloads": "Go to Downloads",
"retry": "Retry", "retry": "재시도",
"server_unreachable": "Server Unreachable", "server_unreachable": "서버에 연결할 수 없음",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.", "server_unreachable_message": "서버에 연결할 수 없습니다. 네트워크 상태를 체크하세요.",
"oops": "Oops!", "oops": "이런!",
"error_message": "Something went wrong.\nPlease log out and in again.", "error_message": "문제가 발생했습니다.\n로그아웃 후 다시 로그인해 주세요.",
"continue_watching": "Continue Watching", "continue_watching": "이어서 보기",
"next_up": "Next Up", "continue": "Continue",
"continue_and_next_up": "Continue & Next Up", "next_up": "다음 시청",
"recently_added_in": "Recently Added in {{libraryName}}", "continue_and_next_up": "이어서 보기 & 다음 시청",
"suggested_movies": "Suggested Movies", "recently_added_in": "최근에 추가된 {{libraryName}}",
"suggested_episodes": "Suggested Episodes", "suggested_movies": "추천 영화",
"suggested_episodes": "추천 에피소드",
"intro": { "intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin", "welcome_to_streamyfin": "스트리미핀에 오신 것을 환영합니다",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.", "a_free_and_open_source_client_for_jellyfin": "젤리핀을 위한 무료 오픈소스 클라이언트입니다.",
"features_title": "Features", "features_title": "기능",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "features_description": "스트리미핀은 다양한 기능을 제공하며 설정 메뉴에서 확인할 수 있는 여러 소프트웨어와 통합됩니다. 이러한 소프트웨어에는 다음이 포함됩니다:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "jellyseerr_feature_description": "Seerr 인스턴스에 연결하여 앱에서 직접 영화를 요청할 수 있습니다.",
"downloads_feature_title": "Downloads", "downloads_feature_title": "다운로드된 컨텐츠",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", "downloads_feature_description": "오프라인으로 보기위해 다운로드 하세요. 기본 다운로드 방식을 사용하거나, 백그라운드에서 파일을 다운로드하는 최적화 서버를 설치할 수 있습니다.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", "chromecast_feature_description": "영화와 TV 프로그램을 Chromecast 기기로 전송하기",
"centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_title": "중앙 설정 플러그인",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "centralised_settings_plugin_description": "Jellyfin 서버의 중앙 집중식 위치에서 설정을 구성합니다. 모든 사용자의 모든 클라이언트 설정이 자동으로 동기화됩니다.",
"done_button": "Done", "done_button": "확인",
"go_to_settings_button": "Go to Settings", "go_to_settings_button": "설정으로 이동",
"read_more": "Read More" "read_more": "자세히 보기"
}, },
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "설정",
"log_out_button": "Log Out", "log_out_button": "로그아웃",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": { "categories": {
"title": "Categories" "title": "카테고리"
}, },
"playback_controls": { "playback_controls": {
"title": "Playback & Controls" "title": "재생 & 컨트롤"
}, },
"audio_subtitles": { "audio_subtitles": {
"title": "Audio & Subtitles" "title": "오디오 & 자막"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "화면 스타일",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "merge_next_up_continue_watching": "[이어보기]와 [다음 보기] 합치기",
"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": "홈 WiFi에 연결되었을 때 로컬 URL로 자동 전환",
"local_url": "Local URL", "local_url": "로컬 URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)", "local_url_hint": "로컬 서버 주소를 입력하세요 (e.g., 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": "로컬 URL",
"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": "현재 사용 중인 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": "자동 전환 Wi-Fi 네트워크를 감지하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 활성화해 주세요."
}, },
"user_info": { "user_info": {
"user_info_title": "User Info", "user_info_title": "사용자 정보",
"user": "User", "user": "사용자",
"server": "Server", "server": "서버",
"token": "Token", "token": "토큰",
"app_version": "App Version" "app_version": "앱 버전"
}, },
"quick_connect": { "quick_connect": {
"quick_connect_title": "Quick Connect", "quick_connect_title": "퀵 커넥트",
"authorize_button": "Authorize Quick Connect", "authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "Enter the quick connect code...", "enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "Success", "success": "성공",
"quick_connect_autorized": "Quick Connect Authorized", "quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "Error", "error": "오류",
"invalid_code": "Invalid Code", "invalid_code": "유효하지 않은 코드",
"authorize": "Authorize" "authorize": "승인"
}, },
"media_controls": { "media_controls": {
"media_controls_title": "Media Controls", "media_controls_title": "미디어 컨트롤",
"forward_skip_length": "Forward Skip Length", "forward_skip_length": "앞으로 건너뛸 시간",
"rewind_length": "Rewind Length", "rewind_length": "뒤로 되감을 시간",
"seconds_unit": "s" "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", "gesture_controls_title": "제스처 제어",
"horizontal_swipe_skip": "Horizontal Swipe to Skip", "horizontal_swipe_skip": "좌/우로 스와이프하여 건너뛰기",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip", "horizontal_swipe_skip_description": "컨트롤 숨김상태에서 좌/우로 스와이프하여 건너뛰기",
"left_side_brightness": "Left Side Brightness Control", "left_side_brightness": "왼쪽 영역 밝기 조정 컨트롤",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness", "left_side_brightness_description": "왼쪽 영역을 위/아래 스와이프하여 밝기 조절",
"right_side_volume": "Right Side Volume Control", "right_side_volume": "오른쪽 영역 볼륨 컨트롤",
"right_side_volume_description": "Swipe up/down on right side to adjust volume", "right_side_volume_description": "오른족 영역을 위/아래로 스와이프 하여 볼륨 조절",
"hide_volume_slider": "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 the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider", "hide_brightness_slider": "Hide Brightness Slider",
@@ -196,7 +237,7 @@
"language": "Language", "language": "Language",
"transcode_mode": { "transcode_mode": {
"title": "Audio Transcoding", "title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", "description": "서라운드 오디오(7.1, TrueHD, DTS-HD)를 어떻게 처리할지 설정합니다",
"auto": "Auto", "auto": "Auto",
"stereo": "Force Stereo", "stereo": "Force Stereo",
"5_1": "Allow 5.1", "5_1": "Allow 5.1",
@@ -228,52 +269,68 @@
"outline_opacity": "Outline Opacity", "outline_opacity": "Outline Opacity",
"bold_text": "Bold Text", "bold_text": "Bold Text",
"colors": { "colors": {
"Black": "Black", "Black": "검정색",
"Gray": "Gray", "Gray": "회색",
"Silver": "Silver", "Silver": "은색",
"White": "White", "White": "흰색",
"Maroon": "Maroon", "Maroon": "밤색",
"Red": "Red", "Red": "빨간색",
"Fuchsia": "Fuchsia", "Fuchsia": "분홍색",
"Yellow": "Yellow", "Yellow": "노란색",
"Olive": "Olive", "Olive": "올리브 색",
"Green": "Green", "Green": "녹색",
"Teal": "Teal", "Teal": "청록색",
"Lime": "Lime", "Lime": "라임색",
"Purple": "Purple", "Purple": "보라색",
"Navy": "Navy", "Navy": "남색",
"Blue": "Blue", "Blue": "파란색",
"Aqua": "Aqua" "Aqua": "아쿠아색"
}, },
"thickness": { "thickness": {
"None": "None", "None": "없음",
"Thin": "Thin", "Thin": "얇게",
"Normal": "Normal", "Normal": "보통",
"Thick": "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"
}, },
@@ -288,20 +345,20 @@
"PORTRAIT_UP": "Portrait Up", "PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down", "PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape", "LANDSCAPE": "Landscape",
"LANDSCAPE_LEFT": "Landscape Left", "LANDSCAPE_LEFT": "왼쪽 가로 모드",
"LANDSCAPE_RIGHT": "Landscape Right", "LANDSCAPE_RIGHT": "오른쪽 가로 모드",
"OTHER": "Other", "OTHER": "Other",
"UNKNOWN": "Unknown" "UNKNOWN": "Unknown"
}, },
"safe_area_in_controls": "Safe Area in Controls", "safe_area_in_controls": "컨트롤 안전 영역",
"video_player": "Video Player", "video_player": "Video Player",
"video_players": { "video_players": {
"VLC_3": "VLC 3", "VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)" "VLC_4": "VLC 4 (Experimental + PiP)"
}, },
"show_custom_menu_links": "Show Custom Menu Links", "show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "Hide Libraries", "hide_libraries": "라이브러리 숨기기",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback", "disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality", "default_quality": "Default Quality",
@@ -334,24 +391,24 @@
"password": "Password", "password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}", "password_placeholder": "Enter password for Jellyfin user {{username}}",
"login_button": "Login", "login_button": "Login",
"total_media_requests": "Total Media Requests", "total_media_requests": "전체 미디어 요청 수",
"movie_quota_limit": "Movie Quota Limit", "movie_quota_limit": "영화 요청 한도",
"movie_quota_days": "Movie Quota Days", "movie_quota_days": "영화 요청 제한 기간",
"tv_quota_limit": "TV Quota Limit", "tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days", "tv_quota_days": "TV 요청 제한 기간",
"reset_jellyseerr_config_button": "Reset Seerr Config", "reset_jellyseerr_config_button": "Seerr 설정 초기화",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"plus_n_more": "+{{n}} More", "plus_n_more": "+{{n}}개 더",
"order_by": { "order_by": {
"DEFAULT": "Default", "DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average", "VOTE_COUNT_AND_AVERAGE": "평균 평점 및 투표 수",
"POPULARITY": "Popularity" "POPULARITY": "Popularity"
} }
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Enable Marlin Search", "enable_marlin_search": "Marlin 검색 활성화",
"url": "URL", "url": "URL",
"server_url_placeholder": "http(s)://domain.org:port", "server_url_placeholder": "http(s)://도메인:포트",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.", "marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read More About Marlin.", "read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save", "save_button": "Save",
@@ -374,28 +431,28 @@
"features_title": "Features", "features_title": "Features",
"home_sections_title": "Home Sections", "home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations", "enable_movie_recommendations": "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 Movies",
"recommended_series": "Recommended Series", "recommended_series": "추천 시리즈",
"toasts": { "toasts": {
"saved": "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": {
"storage_title": "Storage", "storage_title": "Storage",
"app_usage": "App {{usedSpace}}%", "app_usage": " {{usedSpace}}",
"device_usage": "Device {{availableSpace}}%", "device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used", "size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files", "delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
@@ -403,10 +460,16 @@
"enable_music_cache": "Enable Music Cache", "enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached", "music_cache_size": "{{size}} cached",
"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": "다운로드한 노래가 삭제되었습니다",
"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,11 +493,26 @@
"error_deleting_files": "Error Deleting Files", "error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled", "background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled" "background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
} }
}, },
"sessions": { "sessions": {
"title": "Sessions", "title": "Sessions",
"no_active_sessions": "No Active Sessions" "no_active_sessions": "세션 비활성화"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back", "back": "Back",
"delete": "Delete", "delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong", "something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -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": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
"subtitle": "Subtitle", "subtitle": "Subtitle",
"play": "Play", "play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying..." "verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",
@@ -556,6 +641,7 @@
"movies": "Movies", "movies": "Movies",
"series": "Series", "series": "Series",
"boxsets": "Box Sets", "boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items" "items": "Items"
}, },
"options": { "options": {
@@ -566,7 +652,8 @@
"poster": "Poster", "poster": "Poster",
"cover": "Cover", "cover": "Cover",
"show_titles": "Show Titles", "show_titles": "Show Titles",
"show_stats": "Show Stats" "show_stats": "Show Stats",
"options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Genres", "genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sort By", "sort_by": "Sort By",
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Sort Order", "sort_order": "Sort Order",
"tags": "Tags" "tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -591,6 +682,8 @@
"no_links": "No Links" "no_links": "No Links"
}, },
"player": { "player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Next Up", "next_up": "Next Up",
@@ -617,6 +738,11 @@
"series": "Series", "series": "Series",
"seasons": "Seasons", "seasons": "Seasons",
"season": "Season", "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", "no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview", "overview": "Overview",
"more_with": "More with {{name}}", "more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options", "media_options": "Media Options",
"quality": "Quality", "quality": "Quality",
"audio": "Audio", "audio": "Audio",
"subtitles": "Subtitle", "subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Show More", "show_more": "Show More",
"show_less": "Show Less", "show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In", "appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item", "could_not_load_item": "Could Not Load Item",
"none": "None", "none": "None",
"download": { "download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} Items", "download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only", "download_unwatched_only": "Unwatched Only",
"download_button": "Download" "download_button": "Download"
} },
"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", "next": "Next",
@@ -652,7 +795,18 @@
"movies": "Movies", "movies": "Movies",
"sports": "Sports", "sports": "Sports",
"for_kids": "For Kids", "for_kids": "For Kids",
"news": "News" "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", "confirm": "Confirm",
@@ -697,6 +851,12 @@
"decline": "Decline", "decline": "Decline",
"requested_by": "Requested by {{user}}", "requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User", "unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.", "jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -716,7 +876,8 @@
"search": "Search", "search": "Search",
"library": "Library", "library": "Library",
"custom_links": "Custom Links", "custom_links": "Custom Links",
"favorites": "Favorites" "favorites": "Favorites",
"settings": "Settings"
}, },
"music": { "music": {
"title": "Music", "title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show", "show": "This show",
"all": "All media (default)" "all": "All media (default)"
} }
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
} }
} }

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