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

@@ -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
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
@@ -73,6 +76,12 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/
.agents/skills/**
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
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";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -36,7 +33,6 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />}
<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 { useAtom } from "jotai";
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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={bottomSheetModalRef.current?.present}
onPress={() => bottomSheetModalRef.current?.present()}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -127,7 +133,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const _deleteShows = () =>
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -138,7 +144,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const _deleteOtherMedia = () =>
const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<OfflineModeProvider isOffline={true}>
<ScrollView
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
)}
</View>
</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>
);
}

View File

@@ -59,6 +59,7 @@ function SettingsMobile() {
<QuickConnect className='mb-4' />
{Platform.OS !== "ios" && (
<View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
@@ -70,6 +71,7 @@ function SettingsMobile() {
/>
</ListGroup>
</View>
)}
<View className='mb-4'>
<AppLanguageSelector />

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,8 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -129,7 +128,6 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr,
offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
@@ -139,24 +137,9 @@ export default function DirectPlayerPage() {
offline: string;
/** Playback position in ticks. */
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();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyReady,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -291,6 +274,11 @@ export default function DirectPlayerPage() {
};
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();
}
}, [itemId, offline, api, user?.Id]);
@@ -333,6 +321,12 @@ export default function DirectPlayerPage() {
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;
if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath;
@@ -405,6 +399,7 @@ export default function DirectPlayerPage() {
item,
user?.Id,
downloadedItem,
offline,
]);
useEffect(() => {
@@ -420,102 +415,8 @@ export default function DirectPlayerPage() {
reportPlaybackStart();
}, [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 () => {
lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
@@ -749,12 +650,10 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks);
// 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 = {
url: stream.url,
startPosition: startPos,
autoplay: shouldAutoplay,
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
@@ -973,20 +872,10 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.();
}, []);
const seek = useCallback(
(position: number) => {
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seekMs(position);
return;
}
const seek = useCallback((position: number) => {
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
}, []);
// TV audio track change handler
const handleAudioIndexChange = useCallback(

View File

@@ -25,7 +25,6 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -410,7 +409,6 @@ function Layout() {
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<SyncPlayProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
@@ -448,8 +446,7 @@ function Layout() {
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
@@ -539,7 +536,6 @@ function Layout() {
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</SyncPlayProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>

View File

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

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
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,
"configVersion": 1,
"workspaces": {
"": {
"name": "streamyfin",
@@ -10,7 +11,7 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@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=="],
"@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=="],

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 { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
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
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// 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.
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// the trigger sizes the wrapper while the Host overlays the real Menu.
return (
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
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}
>
<View>
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
{trigger}
</View>
<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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -68,14 +67,6 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom);
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
const effectiveColors = colors || globalColorAtom;
@@ -103,37 +94,6 @@ export const PlayButton: React.FC<Props> = ({
const handleNormalPlayFlow = useCallback(async () => {
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({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -330,8 +290,6 @@ export const PlayButton: React.FC<Props> = ({
goToPlayer,
isOffline,
t,
isSyncPlayEnabled,
syncPlayController,
]);
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 { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import {
type ChapterEntry,
chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -74,9 +76,22 @@ function ChapterListComponent({
transparent
animationType='slide'
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={(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}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
@@ -157,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
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
style={{
width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}}
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
style={
Platform.isTV
? { width: `${progress}%`, backgroundColor: "#ffffff" }
: { width: `${progress}%` }
}
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 { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
TouchableOpacity,
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
const { t } = useTranslation();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return;
const options: string[] = [
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
...(isOffline ? ["Delete Download"] : []),
"Cancel",
t("common.mark_as_played"),
t("common.mark_as_not_played"),
isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isOffline,
deleteFile,
item.Id,
t,
]);
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={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather

View File

@@ -37,7 +37,20 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto;
}, [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(
(person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -1,6 +1,6 @@
import { t } from "i18next";
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 { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
</View>
{/* Pair with Phone */}
{onStartPairing && (
{Platform.OS !== "ios" && onStartPairing && (
<View>
<Button
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 hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) {
return null;
}
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
return (
<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
title={t("search.request_movies")}
items={movieResults}
isFirstSection={hasMovies}
isFirstSection={false}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={!hasMovies && hasTv}
isFirstSection={false}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={!hasMovies && !hasTv && hasPersons}
isFirstSection={false}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
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
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
{sections.map((section) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
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}
onItemLongPress={onItemLongPress}
imageUrlGetter={

View File

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

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
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 scrollRef = useRef<HorizontalScrollRef>(null);

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'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>

View File

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

View File

@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
}
/>
</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
groups={autoPlayEpisodeOptions}
trigger={

View File

@@ -229,7 +229,10 @@ export const PlaybackControlsSettings: React.FC = () => {
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={!settings.autoPlayNextEpisode}
disabled={
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
>
<PlatformDropdown
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 { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,9 +75,6 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Chapter props
chapterPositions?: number[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[
{
position: "absolute",
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
right: insets.right,
left: insets.left,
bottom: Math.max(insets.bottom - 17, 0),
},
]}
className={"flex flex-col px-2"}
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<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
showButton={showSkipButton}
onPress={skipIntro}
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
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 File

@@ -1,19 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ICON_SIZES } from "./constants";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface CenterControlsProps {
showControls: boolean;
isPlaying: boolean;
@@ -47,27 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
// 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;
const insets = useControlsSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: insets.left,
right: insets.right,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
@@ -138,17 +121,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}>
{isSyncPlayScheduling ? (
// 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 ? (
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER}

View File

@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
{ 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" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(),
playbackPosition:
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(() => {
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>

View File

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

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
// Set the initial season index
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 seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black",
height: "100%",
width: "100%",
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View

View File

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

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0);
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20,
}
: {
top:
(settings?.safeAreaInControlsEnabled ?? true)
? 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,
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
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": {
"version": ">= 9.1.0",
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
@@ -52,39 +52,50 @@
}
},
"production": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
"image": "latest"
"image": "latest",
"config": "android-production.yml"
},
"ios": {
"config": "ios-production.yml"
}
},
"production-apk": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
"image": "latest",
"config": "android-production-apk.yml"
}
},
"production-apk-tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
"image": "latest",
"config": "android-production-tv.yml"
},
"env": {
"EXPO_TV": "1"
}
},
"production_tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"env": {
"EXPO_TV": "1"
},
"ios": {
"credentialsSource": "local"
"credentialsSource": "local",
"config": "ios-production.yml"
}
}
},
@@ -93,6 +104,11 @@
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"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 { 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) => {
if (!url) return null;
let blob: Blob;
const tmpFile = new File(
Paths.cache,
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
);
try {
// Fetch the data from the URL
const response = await fetch(url);
blob = await response.blob();
const downloaded = await File.downloadFileAsync(url, tmpFile, {
idempotent: true,
});
return await downloaded.base64();
} catch (error) {
console.warn("Error fetching image:", error);
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(

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
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(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex <= 0) return null;
const candidate = adjacentItems[currentIndex - 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/** The next item in the series */
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex < 0) return null;
const candidate = adjacentItems[currentIndex + 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/**
* Reports playback progress.

View File

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps {
@@ -81,9 +80,9 @@ export const useWebSocket = ({
playTrailers,
}: UseWebSocketProps) => {
const router = useRouter();
const { lastMessage, clearLastMessage } = useWebSocketContext();
const { lastMessage } = useWebSocketContext();
const { t } = useTranslation();
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
const { clearLastMessage } = useWebSocketContext();
useEffect(() => {
if (!lastMessage) return;
@@ -97,25 +96,6 @@ export const useWebSocket = ({
| Record<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") {
console.log("Command ~ PlayPause");
togglePlay();

View File

@@ -1,46 +1,20 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
apply plugin: 'expo-module-gradle-plugin'
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
expoModule {
canBePublished false
}
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
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)
setupNotifications()
setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
}
private func setupView() {
clipsToBounds = true
backgroundColor = .black
configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -142,21 +140,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
let session = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(
.playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
try session.setActive(true)
} catch {
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() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -271,6 +274,7 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -441,6 +445,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -520,9 +525,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
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)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
syncNowPlaying(isPlaying: !isPaused())
}
}

View File

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

View File

@@ -32,7 +32,7 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@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
*/
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) => {
// 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"] = [
"runs-as-current-user",
];

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
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
* 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 {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
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
* PERFORMANCE: Updates cache and invalidates derived caches
*/
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
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
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/
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 items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
}
}
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items;
}
/**
* Build or refresh the item index for O(1) lookups
*/
function ensureItemIndex(): void {
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
}
// Build new index from all items
itemIndex = new Map<string, DownloadedItem>();
const items = getAllDownloadedItems();
for (const item of items) {
if (item.item.Id) {
itemIndex.set(item.item.Id, item);
}
}
indexCacheVersion = cacheVersion;
}
/**
* 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 {
const db = getDownloadsDatabase();
if (db.movies[id]) {
return db.movies[id];
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
return episode;
}
}
}
}
if (db.other?.[id]) {
return db.other[id];
}
return undefined;
ensureItemIndex();
return itemIndex!.get(id);
}
/**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/
export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
}

View File

@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => {
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");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(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": "خطأ",
"login_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": "اسم المستخدم",
"password_placeholder": "كلمة المرور",
"login_button": "تسجيل الدخول",
@@ -30,48 +33,54 @@
"connect_button": "اتصل",
"previous_servers": "الخوادم السابقة",
"clear_button": "مسح",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "مرر للإزالة",
"search_for_local_servers": "البحث عن الخوادم المحلية",
"searching": "جاري البحث...",
"servers": "الخوادم",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"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.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"saved": "تم الحفظ",
"session_expired": "انتهت الجلسة",
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
"accounts_count": "الحسابات {{count}}",
"select_account": "اختر الحساب",
"add_account": "إضافة حساب",
"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": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
"title": "حفظ الحساب",
"save_for_later": "حفظ هذا الحساب",
"security_option": "‮خيارات الأمان",
"no_protection": "بدون حماية",
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
"pin_code": "رمز PIN",
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
"password": "أعد إدخال كلمة المرور",
"password_desc": "كلمة المرور مطلوبة عند التبديل",
"save_button": "حفظ",
"cancel_button": "إلغاء"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
"enter_pin": "‏أدخل رمز PIN",
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
"enter_4_digits": "ادخل 4 أرقام",
"invalid_pin": "PIN غير صالح",
"setup_pin": "تعيين رمز PIN",
"confirm_pin": "تأكيد رمز PIN",
"pins_dont_match": "رموز PIN غير متطابقة",
"forgot_pin": "نسيت رمز PIN؟",
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "أدخل كلمة المرور",
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
"invalid_password": "كلمة المرور غير صحيحة"
},
"home": {
"checking_server_connection": "التحقق من اتصال الخادم...",
@@ -86,8 +95,9 @@
"oops": "عفوًا!",
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
"continue_watching": "متابعة المشاهدة",
"continue": "Continue",
"next_up": "التالي",
"continue_and_next_up": "Continue & Next Up",
"continue_and_next_up": "تابع و التالي",
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
"suggested_movies": "أفلام مقترحة",
"suggested_episodes": "حلقات مقترحة",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "الإعدادات",
"log_out_button": "تسجيل الخروج",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "الأقسام"
},
@@ -120,36 +136,45 @@
},
"appearance": {
"title": "المظهر",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
"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": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"title": "الشبكة",
"local_network": "الشبكة المحلية",
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
"local_url": "رابط محلي",
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
"home_wifi_networks": "شبكات WiFi المنزل",
"add_current_network": "إضافة \"{{ssid}}\"",
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
"no_networks_configured": "لا توجد شبكات مكونة",
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
"current_wifi": "شبكة WiFi الحالية",
"using_url": "استخدام",
"local": "رابط محلي",
"remote": "الـ URL الخارجي",
"not_connected": "غير متصل",
"current_server": "الخادم الحالي",
"remote_url": "الـ URL الخارجي",
"active_url": "الرابط النشط",
"not_configured": "لم يتم تكوينه",
"network_added": "تمت إضافة الشبكة",
"network_already_added": "الشبكة مضافة مسبقاً",
"no_wifi_connected": "غير متصل بشبكة WiFi",
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
},
"user_info": {
"user_info_title": "معلومات المستخدم",
@@ -174,6 +199,22 @@
"rewind_length": "مدة الترجيع",
"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_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
@@ -182,10 +223,10 @@
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
"hide_brightness_slider": "إخفاء شريط السطوع",
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
},
"audio": {
"audio_title": "الصوت",
@@ -195,12 +236,12 @@
"none": "لا شيء",
"language": "اللغة",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
"title": "تحويل ترميز الصوت",
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
"auto": "تلقائي",
"stereo": "إجبار تشغيل ستيريو",
"5_1": "السماح بـ 5.1",
"passthrough": "تمرير الصوت"
}
},
"subtitles": {
@@ -251,29 +292,45 @@
"Normal": "عادي",
"Thick": "سميك"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"subtitle_color": "لون الترجمة",
"subtitle_background_color": "لون الخلفية",
"subtitle_font": "خط الترجمة",
"ksplayer_title": "إعدادات KSPlayer",
"hardware_decode": "فك الترميز بواسطة الجهاز",
"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": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
"title": "إعدادات ترجمة VLC",
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
"text_color": "لون النص",
"background_color": "لون الخلفية",
"background_opacity": "شفافية الخلفية",
"outline_color": "لون إطار الخط",
"outline_opacity": "شفافية إطار الخط",
"outline_thickness": "سمك إطار الخط",
"bold": "خط عريض",
"margin": "الهامش السفلي"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "مشغل الفيديو",
"video_player": "مشغل الفيديو",
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -305,8 +362,8 @@
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"default_playback_speed": "سرعة التشغيل الافتراضية",
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
"disabled": "معطل"
},
@@ -314,15 +371,15 @@
"downloads_title": "التنزيلات"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
"title": "الموسيقى",
"playback_title": "التشغيل",
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
"caching_title": "التخزين المؤقت",
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
},
"plugins": {
"plugins_title": "الإضافات",
@@ -357,39 +414,39 @@
"save_button": "حفظ",
"toasts": {
"saved": "تم الحفظ",
"refreshed": "Settings refreshed from server"
"refreshed": "تم تحديث الإعدادات من الخادم"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "تحديث الإعدادات من الخادم"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"enable_streamystats": "تفعيل Streamystats",
"disable_streamystats": "تعطيل Streamystats",
"enable_search": "استخدم للبحث",
"url": "الرابط",
"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.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
"save_button": "حفظ",
"save": "حفظ",
"features_title": "المميزات",
"home_sections_title": "أقسام الرئيسية",
"enable_movie_recommendations": "توصيات الأفلام",
"enable_series_recommendations": "توصيات المسلسلات",
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
"recommended_movies": "أفلام موصى بها",
"recommended_series": "مسلسلات موصى بها",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"saved": "تم الحفظ",
"refreshed": "تم تحديث الإعدادات من الخادم",
"disabled": "تم تعطيل Streamystats"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "تحديث الإعدادات من الخادم"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
}
},
"storage": {
@@ -398,15 +455,21 @@
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
"downloaded_songs_size": "تم تنزيل {{size}}",
"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": {
"title": "المقدمة",
@@ -430,6 +493,21 @@
"error_deleting_files": "خطأ في حذف الملفات",
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
"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": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
"back": "رجوع",
"delete": "حذف",
"delete_download": "Delete Download",
"something_went_wrong": "حدث خطأ ما",
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
"eta": "الوقت المتبقي {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو",
"audio": "الصوت",
"subtitle": "الترجمة",
"play": "تشغيل",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "لا شيء",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"track": "أغنية",
"cancel": "إلغاء",
"stop": "Stop",
"delete": "حذف",
"ok": "حسناً",
"remove": "إزالة",
"next": "التالي",
"back": "رجوع",
"continue": "متابعة",
"verifying": "جارٍ التحقق...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "بحث...",
@@ -521,10 +606,10 @@
"episodes": "حلقات",
"collections": "مجموعات",
"actors": "ممثلون",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"artists": "الفنانون",
"albums": "الألبومات",
"songs": "الأغاني",
"playlists": "قوائم التشغيل",
"request_movies": "طلب أفلام",
"request_series": "طلب مسلسلات",
"recently_added": "أضيف مؤخرًا",
@@ -556,6 +641,7 @@
"movies": "أفلام",
"series": "مسلسلات",
"boxsets": "مجموعات",
"playlists": "Playlists",
"items": "عناصر"
},
"options": {
@@ -566,15 +652,20 @@
"poster": "ملصق",
"cover": "غلاف",
"show_titles": "إظهار العناوين",
"show_stats": "إظهار الإحصائيات"
"show_stats": "إظهار الإحصائيات",
"options_title": "Options"
},
"filters": {
"genres": "الأنواع",
"years": "السنوات",
"sort_by": "ترتيب حسب",
"filter_by": "Filter By",
"filter_by": "تصفية حسب",
"sort_order": "اتجاه الترتيب",
"tags": "الوسوم"
"tags": "الوسوم",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "لا توجد روابط"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
@@ -604,11 +697,39 @@
"index": "الفِهْرِس:",
"continue_watching": "متابعة المشاهدة",
"go_back": "رجوع",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_title": "تم تنزيل هذا الملف",
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
"downloaded_file_yes": "نعم",
"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": {
"next_up": "التالي",
@@ -617,6 +738,11 @@
"series": "مسلسلات",
"seasons": "مواسم",
"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": "لا توجد حلقات لهذا الموسم",
"overview": "نظرة عامة",
"more_with": "المزيد مع {{name}}",
@@ -624,13 +750,24 @@
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
"video": "فيديو",
"more_details": "المزيد من التفاصيل",
"media_options": "Media Options",
"media_options": "خيارات الوسائط",
"quality": "الجودة",
"audio": "الصوت",
"subtitles": "الترجمة",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "عرض المزيد",
"show_less": "عرض أقل",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "ظهر في",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "تعذر تحميل العنصر",
"none": "لا شيء",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "تنزيل {{item_count}} عناصر",
"download_unwatched_only": "غير المشاهدة فقط",
"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": {
"next": "التالي",
@@ -652,7 +795,18 @@
"movies": "أفلام",
"sports": "رياضة",
"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": {
"confirm": "تأكيد",
@@ -697,6 +851,12 @@
"decline": "رفض",
"requested_by": "مطلوب من {{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": {
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
@@ -716,130 +876,162 @@
"search": "بحث",
"library": "المكتبة",
"custom_links": "روابط مخصصة",
"favorites": "المفضلة"
"favorites": "المفضلة",
"settings": "Settings"
},
"music": {
"title": "Music",
"title": "الموسيقى",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
"suggestions": "الإقتراحات",
"albums": "الألبومات",
"artists": "الفنانون",
"playlists": "قوائم التشغيل",
"tracks": "الأغاني"
},
"filters": {
"all": "All"
"all": "الكل"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"recently_added": "أضيف مؤخرًا",
"recently_played": "تم تشغيله مؤخرًا",
"frequently_played": "الأكثر تشغيلاً",
"explore": "اكتشف",
"top_tracks": "أفضل الأغاني",
"play": "تشغيل",
"shuffle": "ترتيب عشوائي",
"play_top_tracks": "تشغيل أفضل الأغاني",
"no_suggestions": "لا توجد مقترحات متاحة",
"no_albums": "لا توجد ألبومات",
"no_artists": "لا يوجد فنانون",
"no_playlists": "لا توجد قوائم تشغيل",
"album_not_found": "الألبوم غير موجود",
"artist_not_found": "الفنان غير موجود",
"playlist_not_found": "قائمة التشغيل غير موجودة",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
"go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites",
"remove_from_favorites": "Remove from Favorites",
"remove_from_playlist": "Remove from Playlist"
"play_next": "تشغيل التالي",
"add_to_queue": "إضافة إلى قائمة الانتظار",
"add_to_playlist": "أضف إلى قائمة التشغيل",
"download": "تنزيل",
"downloaded": "تم التنزيل",
"downloading": "جارٍ التنزيل...",
"cached": "تم التخزين مؤقتاً",
"delete_download": "حذف ملف التنزيل",
"delete_cache": "إزالة من التخزين المؤقت",
"go_to_artist": "انتقال إلى الفنان",
"go_to_album": "انتقال إلى الألبوم",
"add_to_favorites": "إضافة إلى المفضلة",
"remove_from_favorites": "إزالة من المفضلة",
"remove_from_playlist": "إزالة من قائمة التشغيل"
},
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
"create_playlist": "إنشاء قائمة التشغيل",
"playlist_name": "اسم قائمة التشغيل",
"enter_name": "أدخل اسم قائمة التشغيل",
"create": "إنشاء",
"search_playlists": "البحث عن قوائم التشغيل...",
"added_to": "تمت الإضافة إلى {{name}}",
"added": "تمت الإضافة إلى قائمة التشغيل",
"removed_from": "تمت الإزالة من {{name}}",
"removed": "تمت الازالة من قائمة التشغيل",
"created": "تم إنشاء قائمة التشغيل",
"create_new": "إنشاء قائمة تشغيل جديدة",
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
"failed_to_create": "فشل إنشاء قائمة التشغيل",
"delete_playlist": "حذف قائمة التشغيل",
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
"deleted": "تم حذف قائمة التشغيل",
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "ترتيب حسب",
"alphabetical": "أبجدي",
"date_created": "تاريخ الإنشاء"
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
"title": "قوائم المشاهدة",
"my_watchlists": "قوائم المشاهدة الخاصة بي",
"public_watchlists": "قوائم مشاهدة عامة",
"create_title": "إنشاء قائمة مشاهدة",
"edit_title": "تعديل قائمة المشاهدة",
"create_button": "إنشاء قائمة مشاهدة",
"save_button": "حفظ التغييرات",
"delete_button": "حذف",
"remove_button": "إزالة",
"cancel_button": "إلغاء",
"name_label": "الاسم",
"name_placeholder": "أدخل اسم قائمة المشاهدة",
"description_label": "الوصف",
"description_placeholder": "أدخل الوصف (اختياري)",
"is_public_label": "قائمة مشاهدة عامة",
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
"allowed_type_label": "نوع المحتوى",
"sort_order_label": "الترتيب الافتراضي",
"empty_title": "لا توجد قوائم مشاهدة",
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
"not_configured_title": "لم يتم ضبط Streamystats",
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
"go_to_settings": "الذهاب إلى الإعدادات",
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
"select_watchlist": "تحديد قائمة المشاهدة",
"create_new": "إنشاء قائمة مشاهدة جديدة",
"item": "عنصر",
"items": "عناصر",
"public": "عامة",
"private": "خاصة",
"you": "أنت",
"by_owner": "بواسطة مستخدم آخر",
"not_found": "قائمة المشاهدة غير موجودة",
"delete_confirm_title": "حذف قائمة المشاهدة",
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"remove_item_title": "إزالة من قائمة المشاهدة",
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
"loading": "تحميل قوائم المشاهدة...",
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "سرعة التشغيل",
"apply_to": "تطبيق على",
"speed": "السرعة",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
"media": "الوسائط هذه فقط",
"show": "هذا المسلسل",
"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",
"login_title": "Inicia sessió",
"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",
"password_placeholder": "Contrasenya",
"login_button": "Inicia sessió",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Oops!",
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
"continue_watching": "Continua veient",
"continue": "Continue",
"next_up": "A continuació",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Afegit recentment a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Configuració",
"log_out_button": "Tanca sessió",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Durada del rebobinat",
"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_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error en suprimir fitxers",
"background_downloads_enabled": "Descàrregues en segon pla activades",
"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": {
@@ -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.",
"back": "Enrere",
"delete": "Suprimeix",
"delete_download": "Delete Download",
"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",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Vídeo",
"audio": "Àudio",
"subtitle": "Subtítols",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "pel·lícules",
"series": "sèries",
"boxsets": "col·leccions",
"playlists": "Playlists",
"items": "elements"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Cartell",
"cover": "Coberta",
"show_titles": "Mostrar títols",
"show_stats": "Mostrar estadístiques"
"show_stats": "Mostrar estadístiques",
"options_title": "Options"
},
"filters": {
"genres": "Gèneres",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar per",
"filter_by": "Filter By",
"sort_order": "Ordre",
"tags": "Etiquetes"
"tags": "Etiquetes",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "No hi ha enllaços"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"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ó.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "A continuació",
@@ -617,6 +738,11 @@
"series": "Sèries",
"seasons": "Temporades",
"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",
"overview": "Descripció general",
"more_with": "Més amb {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Qualitat",
"audio": "Àudio",
"subtitles": "Subtítols",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra més",
"show_less": "Mostra menys",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Va aparèixer a",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No s'ha pogut carregar l'element",
"none": "Cap",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descarrega {{item_count}} elements",
"download_unwatched_only": "Unwatched Only",
"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": {
"next": "Següent",
@@ -652,7 +795,18 @@
"movies": "Pel·lícules",
"sports": "Esports",
"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": {
"confirm": "Confirma",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Cercar",
"library": "Biblioteca",
"custom_links": "Enllaços personalitzats",
"favorites": "Preferits"
"favorites": "Preferits",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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",
"login_title": "Přihlásit se",
"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",
"password_placeholder": "Heslo",
"login_button": "Přihlásit se",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Jejda!",
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
"continue_watching": "Pokračovat ve sledování",
"continue": "Continue",
"next_up": "Další nahoru",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nedávno přidané v {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Nastavení",
"log_out_button": "Odhlásit se",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Délka zpětného větru",
"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_title": "Ovládání gest",
"horizontal_swipe_skip": "Horizontální přejetím přeskočit",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Chyba při mazání souborů",
"background_downloads_enabled": "Stahování na pozadí povoleno",
"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": {
@@ -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.",
"back": "Zpět",
"delete": "Vymazat",
"delete_download": "Delete Download",
"something_went_wrong": "Něco se pokazilo",
"could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video",
"audio": "Zvuk",
"subtitle": "Podtitulek",
"play": "Hrát",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Hledat...",
@@ -556,6 +641,7 @@
"movies": "Filmy",
"series": "Série",
"boxsets": "Sada boxů",
"playlists": "Playlists",
"items": "Položky"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakát",
"cover": "Kryt",
"show_titles": "Zobrazit názvy",
"show_stats": "Zobrazit statistiky"
"show_stats": "Zobrazit statistiky",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Seřadit podle",
"filter_by": "Filter By",
"sort_order": "Řazení",
"tags": "Štítky"
"tags": "Štítky",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Žádné odkazy"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Chyba",
"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í.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "Další nahoru",
@@ -617,6 +738,11 @@
"series": "Série",
"seasons": "Série",
"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",
"overview": "Přehled",
"more_with": "Více s {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Kvalita",
"audio": "Zvuk",
"subtitles": "Podtitulek",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Zobrazit více",
"show_less": "Zobrazit méně",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Zobrazeno v",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nelze načíst položku",
"none": "Nic",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Stáhnout položky {{item_count}}",
"download_unwatched_only": "Pouze nezhlédnuté",
"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": {
"next": "Další",
@@ -652,7 +795,18 @@
"movies": "Filmy",
"sports": "Sporty",
"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": {
"confirm": "Potvrdit",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Hledat",
"library": "Knihovna",
"custom_links": "Vlastní odkazy",
"favorites": "Oblíbené"
"favorites": "Oblíbené",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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",
"login_title": "Log ind",
"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",
"password_placeholder": "Adgangskode",
"login_button": "Log ind",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
"continue_watching": "Fortsæt med at se",
"continue": "Continue",
"next_up": "Næste",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Senest tilføjet i {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Indstillinger",
"log_out_button": "Log ud",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Spol tilbage længde",
"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_title": "Bevægelsesstyring",
"horizontal_swipe_skip": "Vandret Stryg for at springe over",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fejl ved sletning af filer",
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
"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": {
@@ -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.",
"back": "Tilbage",
"delete": "Slet",
"delete_download": "Delete Download",
"something_went_wrong": "Noget gik galt",
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video",
"audio": "Lyd",
"subtitle": "Undertekster",
"play": "Afspil",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Søg...",
@@ -556,6 +641,7 @@
"movies": "film",
"series": "serier",
"boxsets": "box sæt",
"playlists": "Playlists",
"items": "elementer"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakat",
"cover": "Omslag",
"show_titles": "Vis titler",
"show_stats": "Vis statistik"
"show_stats": "Vis statistik",
"options_title": "Options"
},
"filters": {
"genres": "Genrer",
@@ -574,7 +661,11 @@
"sort_by": "Sortér efter",
"filter_by": "Filter By",
"sort_order": "Sorteringsrækkefølge",
"tags": "Mærker"
"tags": "Mærker",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ingen links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fejl",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "Næste",
@@ -617,6 +738,11 @@
"series": "Serier",
"seasons": "Sæsoner",
"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",
"overview": "Oversigt",
"more_with": "Mere med {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Kvalitet",
"audio": "Lyd",
"subtitles": "Undertekster",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Vis mere",
"show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Medvirket i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kunne ikke indlæse elementet",
"none": "Ingen",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} elementer",
"download_unwatched_only": "Kun Usete",
"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": {
"next": "Næste",
@@ -652,7 +795,18 @@
"movies": "Film",
"sports": "Sport",
"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": {
"confirm": "Bekræft",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Søg",
"library": "Bibliotek",
"custom_links": "Tilpassede links",
"favorites": "Favoritter"
"favorites": "Favoritter",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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",
"login_title": "Anmelden",
"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",
"password_placeholder": "Passwort",
"login_button": "Anmelden",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} Konten",
"select_account": "Konto auswählen",
"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": {
"title": "Konto speichern",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
"continue_watching": "Weiterschauen",
"continue": "Continue",
"next_up": "Als nächstes",
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Einstellungen",
"log_out_button": "Abmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorien"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aussehen",
"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": {
"title": "Netzwerk",
@@ -174,6 +199,22 @@
"rewind_length": "Rückspullänge",
"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_title": "Gestensteuerung",
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
@@ -256,7 +297,23 @@
"subtitle_font": "Untertitel-Schriftart",
"ksplayer_title": "KSPlayer Einstellungen",
"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": {
"title": "VLC Untertitel-Einstellungen",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Musik-Cache geleert",
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
"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": {
"title": "Einführung",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fehler beim Löschen von Dateien",
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
"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": {
@@ -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.",
"back": "Zurück",
"delete": "Löschen",
"delete_download": "Download löschen",
"something_went_wrong": "Etwas ist schiefgelaufen",
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar",
"video": "Video",
"audio": "Audio",
"subtitle": "Untertitel",
"play": "Abspielen",
"mark_as_played": "Als gesehen markieren",
"mark_as_not_played": "Als ungesehen markieren",
"none": "Keine",
"track": "Spur",
"cancel": "Abbrechen",
"stop": "Stop",
"delete": "Löschen",
"ok": "OK",
"remove": "Entfernen",
"next": "Weiter",
"back": "Zurück",
"continue": "Fortsetzen",
"verifying": "Verifiziere..."
"verifying": "Verifiziere...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Suchen...",
@@ -556,6 +641,7 @@
"movies": "Filme",
"series": "Serien",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "Elemente"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Titel anzeigen",
"show_stats": "Statistiken anzeigen"
"show_stats": "Statistiken anzeigen",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sortieren nach",
"filter_by": "Filtern nach",
"sort_order": "Sortierreihenfolge",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Keine Links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fehler",
"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.",
@@ -609,7 +702,34 @@
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"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": {
"next_up": "Als Nächstes",
@@ -618,6 +738,11 @@
"series": "Serien",
"seasons": "Staffeln",
"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",
"overview": "Überblick",
"more_with": "Mehr mit {{name}}",
@@ -628,10 +753,21 @@
"media_options": "Medienoptionen",
"quality": "Qualität",
"audio": "Audio",
"subtitles": "Untertitel",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Erschien in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Konnte Element nicht laden",
"none": "Keine",
"download": {
@@ -642,7 +778,13 @@
"download_x_item": "{{item_count}} Elemente herunterladen",
"download_unwatched_only": "Nur Ungesehene",
"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": {
"next": "Nächste",
@@ -653,7 +795,18 @@
"movies": "Filme",
"sports": "Sport",
"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": {
"confirm": "Bestätigen",
@@ -698,6 +851,12 @@
"decline": "Ablehnen",
"requested_by": "Angefragt von {{user}}",
"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": {
"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.",
@@ -717,7 +876,8 @@
"search": "Suche",
"library": "Bibliothek",
"custom_links": "Links",
"favorites": "Favoriten"
"favorites": "Favoriten",
"settings": "Settings"
},
"music": {
"title": "Musik",
@@ -842,5 +1002,36 @@
"show": "Nur diese Serie",
"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": "Σφάλμα",
"login_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": "Όνομα Χρήστη",
"password_placeholder": "Κωδικός",
"login_button": "Σύνδεση",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ωχ!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Συνέχεια Παρακολούθησης",
"continue": "Continue",
"next_up": "Επόμενο Επάνω",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Ρυθμίσεις",
"log_out_button": "Αποσύνδεση",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Επαναφορά Μήκους",
"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_title": "Έλεγχοι Χειρονομιών",
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
"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": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.",
"back": "Πίσω",
"delete": "Διαγραφή",
"delete_download": "Delete Download",
"something_went_wrong": "Κάτι Πήγε Λάθος",
"could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο",
"audio": "Ήχος",
"subtitle": "Υπότιτλος",
"play": "Αναπαραγωγή",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Αναζήτηση...",
@@ -556,6 +641,7 @@
"movies": "Ταινίες",
"series": "Σειρά",
"boxsets": "Σύνολα Πλαισίων",
"playlists": "Playlists",
"items": "Στοιχεία"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Αφίσα",
"cover": "Εξώφυλλο",
"show_titles": "Εμφάνιση Τίτλων",
"show_stats": "Εμφάνιση Στατιστικών"
"show_stats": "Εμφάνιση Στατιστικών",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Ταξινόμηση Κατά",
"filter_by": "Filter By",
"sort_order": "Σειρά Ταξινόμησης",
"tags": "Ετικέτες"
"tags": "Ετικέτες",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Δεν Υπάρχουν Σύνδεσμοι"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "Επόμενο Επάνω",
@@ -617,6 +738,11 @@
"series": "Σειρά",
"seasons": "Περίοδοι",
"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": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
"overview": "Επισκόπηση",
"more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Ποιότητα",
"audio": "Ήχος",
"subtitles": "Υπότιτλος",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Εμφάνιση Περισσότερων",
"show_less": "Εμφάνιση Λιγότερων",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Εμφανίστηκε Σε",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
"none": "Κανένα",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Λήψη Αντικειμένων {{item_count}}",
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
"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": {
"next": "Επόμενο",
@@ -652,7 +795,18 @@
"movies": "Ταινίες",
"sports": "Αθλητισμός",
"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": {
"confirm": "Επιβεβαίωση",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
@@ -716,7 +876,8 @@
"search": "Αναζήτηση",
"library": "Βιβλιοθήκη",
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
"favorites": "Αγαπημένα"
"favorites": "Αγαπημένα",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"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.",
"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.",
@@ -384,7 +384,6 @@
"plugins": {
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"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.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -577,6 +577,8 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
@@ -649,7 +651,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_stats": "Show Stats",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -679,6 +682,7 @@
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"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.",
@@ -998,30 +1002,6 @@
"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": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",

View File

@@ -4,6 +4,9 @@
"error_title": "Error",
"login_title": "Iniciar sesión",
"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",
"password_placeholder": "Contraseña",
"login_button": "Iniciar sesión",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} cuentas",
"select_account": "Seleccione una 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": {
"title": "Guardar Cuenta",
@@ -86,6 +95,7 @@
"oops": "¡Vaya!",
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
"continue_watching": "Seguir viendo",
"continue": "Continue",
"next_up": "A continuación",
"continue_and_next_up": "Continuar y siguiente",
"recently_added_in": "Recientemente añadido en {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Configuración",
"log_out_button": "Cerrar sesión",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorías"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Apariencia",
"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": {
"title": "Cadena",
@@ -174,6 +199,22 @@
"rewind_length": "Longitud de retroceso",
"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_title": "Controles de gestos",
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
@@ -256,7 +297,23 @@
"subtitle_font": "Fuente de los subtítulos",
"ksplayer_title": "Ajustes de KSPlayer",
"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": {
"title": "Configuración de subtítulos VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Caché de música eliminado",
"delete_all_downloaded_songs": "Eliminar todas las descargas",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error al eliminar archivos",
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
"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": {
@@ -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.",
"back": "Atrás",
"delete": "Borrar",
"delete_download": "Delete Download",
"something_went_wrong": "Algo ha salido mal",
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
"eta": "{{eta}} restante",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo",
"audio": "Audio",
"subtitle": "Subtítulos",
"play": "Jugar",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nada",
"track": "Pista",
"cancel": "Cancelar",
"stop": "Stop",
"delete": "Borrar",
"ok": "Aceptar",
"remove": "Eliminar",
"next": "Siguiente",
"back": "Atrás",
"continue": "Continuar",
"verifying": "Verificando..."
"verifying": "Verificando...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Buscar...",
@@ -556,6 +641,7 @@
"movies": "Películas",
"series": "Series",
"boxsets": "Colecciones",
"playlists": "Playlists",
"items": "Elementos"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Póster",
"cover": "Portada",
"show_titles": "Mostrar títulos",
"show_stats": "Mostrar estadísticas"
"show_stats": "Mostrar estadísticas",
"options_title": "Options"
},
"filters": {
"genres": "Géneros",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar por",
"filter_by": "Filtrar por",
"sort_order": "Ordenar",
"tags": "Etiquetas"
"tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Sin enlaces"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
"downloaded_file_yes": "Sí",
"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": {
"next_up": "A continuación",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Temporadas",
"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",
"overview": "Resumen",
"more_with": "Más con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opciones de medios",
"quality": "Calidad",
"audio": "Audio",
"subtitles": "Subtítulos",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostrar más",
"show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apareció en",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No se pudo cargar el ítem",
"none": "Ninguno",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descargar {{item_count}} ítems",
"download_unwatched_only": "No visto",
"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": {
"next": "Siguiente",
@@ -652,7 +795,18 @@
"movies": "Películas",
"sports": "Deportes",
"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": {
"confirm": "Confirmar",
@@ -697,6 +851,12 @@
"decline": "Rechazar",
"requested_by": "Solicitado por {{user}}",
"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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Buscar",
"library": "Bibliotecas",
"custom_links": "Enlaces personalizados",
"favorites": "Favoritos"
"favorites": "Favoritos",
"settings": "Settings"
},
"music": {
"title": "Música",
@@ -841,5 +1002,36 @@
"show": "Este programa",
"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",
"login_title": "Kirjaudu sisään",
"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",
"password_placeholder": "Salasana",
"login_button": "Kirjaudu sisään",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
"continue_watching": "Jatka katsomista",
"continue": "Continue",
"next_up": "Seuraavaksi",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Asetukset",
"log_out_button": "Kirjaudu ulos",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategoriat"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Ulkoasu",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Taaksepäin hyppäämisen pituus",
"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_title": "Ele Ohjaus",
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Esittely",
@@ -430,6 +493,21 @@
"error_deleting_files": "Virhe tiedostojen poistamisessa",
"background_downloads_enabled": "Taustalataukset käytössä",
"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": {
@@ -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.",
"back": "Takaisin",
"delete": "Poista",
"delete_download": "Delete Download",
"something_went_wrong": "Jotain meni pieleen",
"could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta",
"eta": "Arvio {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video",
"audio": "Ääni",
"subtitle": "Tekstitys",
"play": "Toista",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Ei mitään",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Haku...",
@@ -556,6 +641,7 @@
"movies": "elokuvat",
"series": "sarjat",
"boxsets": "bokset",
"playlists": "Playlists",
"items": "kohteet"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Juliste",
"cover": "Kansi",
"show_titles": "Näytä otsikot",
"show_stats": "Näytä tilastot"
"show_stats": "Näytä tilastot",
"options_title": "Options"
},
"filters": {
"genres": "Genret",
@@ -574,7 +661,11 @@
"sort_by": "Lajittele",
"filter_by": "Filter By",
"sort_order": "Lajittelujärjestys",
"tags": "Tunnisteet"
"tags": "Tunnisteet",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ei Linkkejä"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Virhe",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
"downloaded_file_yes": "Kyllä",
"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": {
"next_up": "Seuraavaksi",
@@ -617,6 +738,11 @@
"series": "Sarjat",
"seasons": "Kaudet",
"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",
"overview": "Yleiskatsaus",
"more_with": "Enemmän {{name}} kanssa",
@@ -627,10 +753,21 @@
"media_options": "Media-asetukset",
"quality": "Laatu",
"audio": "Ääni",
"subtitles": "Tekstitys",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Näytä Lisää",
"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",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kohdetta Ei Voitu Ladata",
"none": "Ei mitään",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Lataa {{item_count}} Kohteita",
"download_unwatched_only": "Vain Katsomattomat",
"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": {
"next": "Seuraava",
@@ -652,7 +795,18 @@
"movies": "Elokuvat",
"sports": "Urheilu",
"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": {
"confirm": "Vahvista",
@@ -697,6 +851,12 @@
"decline": "Hylkää",
"requested_by": "Käyttäjän {{user}} pyynnöstä",
"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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Haku",
"library": "Kirjasto",
"custom_links": "Mukautetut linkit",
"favorites": "Suosikit"
"favorites": "Suosikit",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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",
"login_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",
"password_placeholder": "Mot de passe",
"login_button": "Se connecter",
@@ -42,7 +45,13 @@
"accounts_count": "Comptes {{count}}",
"select_account": "Sélectionnez 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": {
"title": "Sauvegarder le compte",
@@ -86,6 +95,7 @@
"oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder",
"continue": "Continue",
"next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Paramètres",
"log_out_button": "Déconnexion",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Catégories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Apparence",
"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": {
"title": "Réseau",
@@ -174,6 +199,22 @@
"rewind_length": "Durée de retour en arrière",
"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_title": "Commandes gestuelles",
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
@@ -256,7 +297,23 @@
"subtitle_font": "Police des sous-titres",
"ksplayer_title": "Paramètres de KSPlayer",
"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": {
"title": "Paramètres des sous-titres VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Cache de musique effacé",
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"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": {
"title": "Introduction",
@@ -430,6 +493,21 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers",
"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"
},
"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": {
@@ -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.",
"back": "Retour",
"delete": "Supprimer",
"delete_download": "Delete Download",
"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",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo",
"audio": "Audio",
"subtitle": "Sous-titres",
"play": "Lecture",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Aucun",
"track": "Suivre",
"cancel": "Annuler",
"stop": "Stop",
"delete": "Supprimer",
"ok": "Ok",
"remove": "Retirer",
"next": "Suivant",
"back": "Précédent",
"continue": "Continuer",
"verifying": "Vérification..."
"verifying": "Vérification...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Rechercher...",
@@ -556,6 +641,7 @@
"movies": "Films",
"series": "Séries",
"boxsets": "Coffrets ",
"playlists": "Playlists",
"items": "Médias"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Affiche",
"cover": "Couverture",
"show_titles": "Afficher les titres",
"show_stats": "Afficher les statistiques"
"show_stats": "Afficher les statistiques",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Trier par",
"filter_by": "Filtrer par",
"sort_order": "Ordre de tri",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Aucuns liens"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Erreur",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
"downloaded_file_yes": "Oui",
"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": {
"next_up": "À suivre",
@@ -617,6 +738,11 @@
"series": "Séries",
"seasons": "Saisons",
"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",
"overview": "Aperçu",
"more_with": "Plus avec {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Options média",
"quality": "Qualité",
"audio": "Audio",
"subtitles": "Sous-titres",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparu dans",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossible de charger le média",
"none": "Aucun",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Télécharger {{item_count}} médias",
"download_unwatched_only": "Non visionné uniquement",
"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": {
"next": "Suivant",
@@ -652,7 +795,18 @@
"movies": "Films",
"sports": "Sports",
"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": {
"confirm": "Confirmer",
@@ -697,6 +851,12 @@
"decline": "Refuser",
"requested_by": "Demandé par {{user}}",
"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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Recherche",
"library": "Bibliothèque",
"custom_links": "Liens personnalisés",
"favorites": "Favoris"
"favorites": "Favoris",
"settings": "Settings"
},
"music": {
"title": "Musique",
@@ -788,8 +949,8 @@
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"title": "Listes de lecture",
"my_watchlists": "Mes listes de lecture",
"public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist",
@@ -802,7 +963,7 @@
"name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description",
"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",
"allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut",
@@ -841,5 +1002,36 @@
"show": "Cette série",
"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": "שגיאה",
"login_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": "שם משתמש",
"password_placeholder": "סיסמה",
"login_button": "התחבר",
@@ -39,10 +42,16 @@
"please_login_again": "Your saved session has expired. Please log in again.",
"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.",
"accounts_count": "{{count}} accounts",
"accounts_count": "{{count}} חשבונות",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "אופס!",
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
"continue_watching": "המשך לצפות",
"continue": "Continue",
"next_up": "הבא בתור",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
@@ -109,19 +119,34 @@
"settings": {
"settings_title": "הגדרות",
"log_out_button": "התנתק",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
"title": "קטגוריות"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
"title": "שמע וכתוביות"
},
"appearance": {
"title": "Appearance",
"title": "מראה",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "אורך הזזה אחורה",
"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_title": "פקדי מחוות",
"horizontal_swipe_skip": "החלקה אופקית לדילוג",
@@ -188,7 +229,7 @@
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "אודיו",
"audio_title": "שמע",
"set_audio_track": "בחר רצועת שמע מהפריט הקודם",
"audio_language": "שפת שמע",
"audio_hint": "בחר שפת שמע אוטומטית.",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -271,8 +328,8 @@
"margin": "Bottom Margin"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"title": "נגן וידאו",
"video_player": "נגן וידאו",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
@@ -314,7 +371,7 @@
"downloads_title": "הורדות"
},
"music": {
"title": "Music",
"title": "מוזיקה",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
@@ -406,10 +463,16 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
"title": "הקדמה",
"show_intro": "הצג פתיח",
"reset_intro": "אפס פתיח"
},
@@ -430,6 +493,21 @@
"error_deleting_files": "שגיאה במחיקת קבצים",
"background_downloads_enabled": "הורדה ברקע מופעלת",
"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": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.",
"back": "חזרה",
"delete": "מחק",
"delete_download": "Delete Download",
"something_went_wrong": "משהו השתבש",
"could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin",
"eta": "זמן משוער {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין",
"video": "וידאו",
"audio": "אודיו",
"audio": "שמע",
"subtitle": "כתובית",
"play": "נגן",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "ללא",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "חפש...",
@@ -521,9 +606,9 @@
"episodes": "פרקים",
"collections": "אוספים",
"actors": "שחקנים",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"artists": "אומנים",
"albums": "אלבומים",
"songs": "שירים",
"playlists": "Playlists",
"request_movies": "סרטים מבוקשים",
"request_series": "סדרות מבוקשים",
@@ -556,6 +641,7 @@
"movies": "סרטים",
"series": "סדרות",
"boxsets": "אוסף",
"playlists": "Playlists",
"items": "פריטים"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "פוסטר",
"cover": "עטיפה",
"show_titles": "הצג כותרות",
"show_stats": "הצג סטטיסטיקה"
"show_stats": "הצג סטטיסטיקה",
"options_title": "Options"
},
"filters": {
"genres": "סגנונות",
@@ -574,7 +661,11 @@
"sort_by": "מיין לפי",
"filter_by": "Filter By",
"sort_order": "סדר מיון",
"tags": "תגים"
"tags": "תגים",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "אין קישורים"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
@@ -606,9 +699,37 @@
"go_back": "חזור",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_yes": "כן",
"downloaded_file_no": "לא",
"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": {
"next_up": "הבא בתור",
@@ -617,6 +738,11 @@
"series": "סדרות",
"seasons": "עונות",
"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": "אין פרקים בעונה זו",
"overview": "סקירה",
"more_with": "עוד עם {{name}}",
@@ -626,11 +752,22 @@
"more_details": "פרטים נוספים",
"media_options": "Media Options",
"quality": "איכות",
"audio": "אודיו",
"subtitles": "כתובית",
"audio": "שמע",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "הצג עוד",
"show_less": "הצג פחות",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "הופיע ב-",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "נכשל בטעינת פריט",
"none": "ללא",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "הורד {{item_count}} פריטים",
"download_unwatched_only": "רק שלא נצפו",
"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": {
"next": "הבא",
@@ -652,7 +795,18 @@
"movies": "סרטים",
"sports": "ספורט",
"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": {
"confirm": "אשר",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
@@ -716,13 +876,14 @@
"search": "חיפוש",
"library": "ספריה",
"custom_links": "קישורים מותאמים אישית",
"favorites": "מועדפים"
"favorites": "מועדפים",
"settings": "Settings"
},
"music": {
"title": "Music",
"title": "מוזיקה",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"albums": "אלבומים",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
@@ -798,9 +959,9 @@
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_label": "שם",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_label": "תיאור",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
@@ -817,10 +978,10 @@
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"item": "פריט",
"items": "פריטים",
"public": "ציבורי",
"private": "פרטי",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
@@ -835,11 +996,42 @@
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"speed": "מהירות",
"scope": {
"media": "This media only",
"show": "This show",
"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",
"login_title": "Bejelentkezés",
"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",
"password_placeholder": "Jelszó",
"login_button": "Bejelentkezés",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Hoppá!",
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
"continue_watching": "Nézd Tovább",
"continue": "Continue",
"next_up": "Következő",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Beállítások",
"log_out_button": "Kijelentkezés",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Visszatekerés Hossza",
"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_title": "Gesztusvezérlés",
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Hiba a Fájlok Törlésekor",
"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": {
@@ -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.",
"back": "Vissza",
"delete": "Törlés",
"delete_download": "Delete Download",
"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",
"eta": "Várható Idő: {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Videó",
"audio": "Hang",
"subtitle": "Felirat",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Keresés...",
@@ -556,6 +641,7 @@
"movies": "Filmek",
"series": "Sorozatok",
"boxsets": "Gyűjtemények",
"playlists": "Playlists",
"items": "Elemek"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poszter",
"cover": "Borító",
"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": {
"genres": "Műfajok",
@@ -574,7 +661,11 @@
"sort_by": "Rendezés",
"filter_by": "Filter By",
"sort_order": "Rendezés Iránya",
"tags": "Címkék"
"tags": "Címkék",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nincsenek Linkek"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Hiba",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "Következő",
@@ -617,6 +738,11 @@
"series": "Sorozat",
"seasons": "Évadok",
"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",
"overview": "Áttekintés",
"more_with": "További {{name}} Alkotások",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Minőség",
"audio": "Hang",
"subtitles": "Felirat",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Több 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:",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
"none": "Nincs",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}} Elem Letöltése",
"download_unwatched_only": "Csak Nem Megtekintett",
"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": {
"next": "Következő",
@@ -652,7 +795,18 @@
"movies": "Filmek",
"sports": "Sport",
"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": {
"confirm": "Megerősítés",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Keresés",
"library": "Könyvtár",
"custom_links": "Egyéni Linkek",
"favorites": "Kedvencek"
"favorites": "Kedvencek",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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",
"login_title": "Accesso",
"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",
"password_placeholder": "Password",
"login_button": "Accedi",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"continue": "Continue",
"next_up": "Prossimo",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Impostazioni",
"log_out_button": "Esci",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorie"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aspetto",
"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": {
"title": "Network",
@@ -136,7 +161,7 @@
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"current_wifi": "WiFi Attuale",
"using_url": "Sta utilizzando",
"local": "Local URL",
"remote": "Remote URL",
@@ -174,6 +199,22 @@
"rewind_length": "Lunghezza del riavvolgimento",
"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_title": "Controlli Gesture",
"horizontal_swipe_skip": "Scorrimento orizzontale per saltare",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"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": {
@@ -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.",
"back": "Indietro",
"delete": "Cancella",
"delete_download": "Delete Download",
"something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "Tempo stimato di completamento {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile",
"video": "Video",
"audio": "Audio",
"subtitle": "Sottotitoli",
"play": "Gioca",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nulla",
"track": "Traccia",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"playlists": "Playlists",
"items": "elementi"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Copertina",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche"
"show_stats": "Mostra statistiche",
"options_title": "Options"
},
"filters": {
"genres": "Generi",
@@ -574,7 +661,11 @@
"sort_by": "Ordina per",
"filter_by": "Filter By",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
"tags": "Tag",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nessun link"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Errore",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "Il prossimo",
@@ -617,6 +738,11 @@
"series": "Serie",
"seasons": "Stagioni",
"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",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opzioni Media",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Scarica {{item_count}} elementi",
"download_unwatched_only": "Solo Non Visti",
"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": {
"next": "Prossimo",
@@ -652,7 +795,18 @@
"movies": "Film",
"sports": "Sport",
"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": {
"confirm": "Conferma",
@@ -697,6 +851,12 @@
"decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}",
"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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti"
"favorites": "Preferiti",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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": "エラー",
"login_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": "ユーザー名",
"password_placeholder": "パスワード",
"login_button": "ログイン",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select 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": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る",
"continue": "Continue",
"next_up": "次の動画",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "{{libraryName}}に最近追加された",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "設定",
"log_out_button": "ログアウト",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "カテゴリ"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"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": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "巻き戻しの長さ",
"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_title": "ジェスチャーコントロール",
"horizontal_swipe_skip": "水平方向にスワイプしてスキップ",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"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": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "イントロ",
@@ -430,6 +493,21 @@
"error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"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": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。",
"back": "戻る",
"delete": "削除",
"delete_download": "Delete Download",
"something_went_wrong": "問題が発生しました",
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません",
"video": "映像",
"audio": "音声",
"subtitle": "字幕",
"play": "再生",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "検索...",
@@ -556,6 +641,7 @@
"movies": "映画",
"series": "シリーズ",
"boxsets": "ボックスセット",
"playlists": "Playlists",
"items": "アイテム"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "ポスター",
"cover": "カバー",
"show_titles": "タイトルの表示",
"show_stats": "統計を表示"
"show_stats": "統計を表示",
"options_title": "Options"
},
"filters": {
"genres": "ジャンル",
@@ -574,7 +661,11 @@
"sort_by": "ソート",
"filter_by": "Filter By",
"sort_order": "ソート順",
"tags": "タグ"
"tags": "タグ",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "リンクがありません"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"next_up": "次",
@@ -617,6 +738,11 @@
"series": "シリーズ",
"seasons": "シーズン",
"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": "このシーズンのエピソードはありません",
"overview": "ストーリー",
"more_with": "{{name}}の詳細",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "画質",
"audio": "音声",
"subtitles": "字幕",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "もっと見る",
"show_less": "少なく表示",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "出演作品",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_unwatched_only": "未視聴のみ",
"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": {
"next": "次",
@@ -652,7 +795,18 @@
"movies": "映画",
"sports": "スポーツ",
"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": {
"confirm": "確認",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
@@ -716,7 +876,8 @@
"search": "検索",
"library": "ライブラリ",
"custom_links": "カスタムリンク",
"favorites": "お気に入り"
"favorites": "お気に入り",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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": {
"username_required": "Username Is Required",
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"username_required": "사용자 이름이 필요합니다",
"error_title": "오류",
"login_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": "사용자 이름",
"password_placeholder": "비밀번호",
"login_button": "로그인",
"quick_connect": "퀵 커넥트",
"enter_code_to_login": "로그인 하기 위해 코드{{code}}를 입력하세요",
"failed_to_initiate_quick_connect": "Quick Connect 연결을 시작하는 데 실패했습니다",
"got_it": "성공",
"connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"change_server": "서버 변경",
"invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"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_description": "Please update Jellyfin to the latest version"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "Previous Servers",
"clear_button": "Clear all",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...",
"servers": "Servers",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"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.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"connect_button": "연결",
"previous_servers": "이전 서버",
"clear_button": "모두 지우기",
"swipe_to_remove": "스와이프해서 지우기",
"search_for_local_servers": "로컬 서버 찾기",
"searching": "찾는 중...",
"servers": "서버",
"saved": "저장됨",
"session_expired": "세션 만료됨",
"please_login_again": "사용자 세션이 만료되었습니다. 다시 로그인하십시오.",
"remove_saved_login": "저장된 로그인 정보 삭제",
"remove_saved_login_description": "해당 서버에 저장된 자격 증명이 삭제됩니다. 다음에 접속할 때는 사용자 이름과 비밀번호를 다시 입력해야 합니다.",
"accounts_count": "{{count}} 계정",
"select_account": "계정 선택",
"add_account": "계정 추가",
"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": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
"title": "계정 저장",
"save_for_later": "이 계정 저장",
"security_option": "보안 설정",
"no_protection": "보안 없음",
"no_protection_desc": "인증 없이 빠른 로그인",
"pin_code": "PIN 코드",
"pin_code_desc": "전환하려면 4자리 PIN 필요함",
"password": "암호 확인",
"password_desc": "전환하려면 비밀번호 필요함",
"save_button": "저장",
"cancel_button": "취소"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
"enter_pin": "PIN 입력",
"enter_pin_for": "{{username}} PIN 입력",
"enter_4_digits": "4자리 입력",
"invalid_pin": "잘못된 PIN",
"setup_pin": "PIN 설정",
"confirm_pin": "PIN 확인",
"pins_dont_match": "PIN이 일치하지 않습니다",
"forgot_pin": "PIN을 잊으셨나요?",
"forgot_pin_desc": "저장된 계정 정보가 삭제됩니다"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "비밀번호 입력",
"enter_password_for": "{{username}}의 비밀번호 입력",
"invalid_password": "잘못된 비밀번호"
},
"home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
"checking_server_connection": "서버 연결 체크중...",
"no_internet": "인터넷에 연결되지 않음",
"no_items": "항목 없음",
"no_internet_message": "걱정마세요. 다운로드 된 컨텐츠는 여전히 볼 수 있습니다.",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"retry": "재시도",
"server_unreachable": "서버에 연결할 수 없음",
"server_unreachable_message": "서버에 연결할 수 없습니다. 네트워크 상태를 체크하세요.",
"oops": "이런!",
"error_message": "문제가 발생했습니다.\n로그아웃 후 다시 로그인해 주세요.",
"continue_watching": "이어서 보기",
"continue": "Continue",
"next_up": "다음 시청",
"continue_and_next_up": "이어서 보기 & 다음 시청",
"recently_added_in": "최근에 추가된 {{libraryName}}",
"suggested_movies": "추천 영화",
"suggested_episodes": "추천 에피소드",
"intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features",
"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.",
"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.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"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.",
"done_button": "Done",
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
"welcome_to_streamyfin": "스트리미핀에 오신 것을 환영합니다",
"a_free_and_open_source_client_for_jellyfin": "젤리핀을 위한 무료 오픈소스 클라이언트입니다.",
"features_title": "기능",
"features_description": "스트리미핀은 다양한 기능을 제공하며 설정 메뉴에서 확인할 수 있는 여러 소프트웨어와 통합됩니다. 이러한 소프트웨어에는 다음이 포함됩니다:",
"jellyseerr_feature_description": "Seerr 인스턴스에 연결하여 앱에서 직접 영화를 요청할 수 있습니다.",
"downloads_feature_title": "다운로드된 컨텐츠",
"downloads_feature_description": "오프라인으로 보기위해 다운로드 하세요. 기본 다운로드 방식을 사용하거나, 백그라운드에서 파일을 다운로드하는 최적화 서버를 설치할 수 있습니다.",
"chromecast_feature_description": "영화와 TV 프로그램을 Chromecast 기기로 전송하기",
"centralised_settings_plugin_title": "중앙 설정 플러그인",
"centralised_settings_plugin_description": "Jellyfin 서버의 중앙 집중식 위치에서 설정을 구성합니다. 모든 사용자의 모든 클라이언트 설정이 자동으로 동기화됩니다.",
"done_button": "확인",
"go_to_settings_button": "설정으로 이동",
"read_more": "자세히 보기"
},
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"settings_title": "설정",
"log_out_button": "로그아웃",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
"title": "카테고리"
},
"playback_controls": {
"title": "Playback & Controls"
"title": "재생 & 컨트롤"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
"title": "오디오 & 자막"
},
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"title": "화면 스타일",
"merge_next_up_continue_watching": "[이어보기]와 [다음 보기] 합치기",
"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": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"title": "네트워크",
"local_network": "로컬 네트워크",
"auto_switch_enabled": "홈 네트워크 자동 전환",
"auto_switch_description": "홈 WiFi에 연결되었을 때 로컬 URL로 자동 전환",
"local_url": "로컬 URL",
"local_url_hint": "로컬 서버 주소를 입력하세요 (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
"home_wifi_networks": " WiFi 네트워크",
"add_current_network": "\"{{ssid}}\" 추가",
"not_connected_to_wifi": "WiFi에 연결되지 않음",
"no_networks_configured": "구성된 네트워크가 없습니다",
"add_network_hint": "자동 전환을 위한 홈 WiFi 추가",
"current_wifi": "현재 WiFi",
"using_url": "사용중",
"local": "로컬 URL",
"remote": "원격 URL",
"not_connected": "연결되지 않았습니다",
"current_server": "현재 서버",
"remote_url": "원격 URL",
"active_url": "현재 사용 중인 URL",
"not_configured": "설정되지 않음",
"network_added": "네트워크 추가됨",
"network_already_added": "네트워크 이미 추가됨",
"no_wifi_connected": "WiFi에 연결되지 않음",
"permission_denied": "위치 권한이 거부되었습니다",
"permission_denied_explanation": "자동 전환 Wi-Fi 네트워크를 감지하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 활성화해 주세요."
},
"user_info": {
"user_info_title": "User Info",
"user": "User",
"server": "Server",
"token": "Token",
"app_version": "App Version"
"user_info_title": "사용자 정보",
"user": "사용자",
"server": "서버",
"token": "토큰",
"app_version": "앱 버전"
},
"quick_connect": {
"quick_connect_title": "Quick Connect",
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
"quick_connect_title": "퀵 커넥트",
"authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공",
"quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "오류",
"invalid_code": "유효하지 않은 코드",
"authorize": "승인"
},
"media_controls": {
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"seconds_unit": "s"
"media_controls_title": "미디어 컨트롤",
"forward_skip_length": "앞으로 건너뛸 시간",
"rewind_length": "뒤로 되감을 시간",
"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_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"gesture_controls_title": "제스처 제어",
"horizontal_swipe_skip": "좌/우로 스와이프하여 건너뛰기",
"horizontal_swipe_skip_description": "컨트롤 숨김상태에서 좌/우로 스와이프하여 건너뛰기",
"left_side_brightness": "왼쪽 영역 밝기 조정 컨트롤",
"left_side_brightness_description": "왼쪽 영역을 위/아래 스와이프하여 밝기 조절",
"right_side_volume": "오른쪽 영역 볼륨 컨트롤",
"right_side_volume_description": "오른족 영역을 위/아래로 스와이프 하여 볼륨 조절",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
@@ -196,7 +237,7 @@
"language": "Language",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"description": "서라운드 오디오(7.1, TrueHD, DTS-HD)를 어떻게 처리할지 설정합니다",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
@@ -228,52 +269,68 @@
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"colors": {
"Black": "Black",
"Gray": "Gray",
"Silver": "Silver",
"White": "White",
"Maroon": "Maroon",
"Red": "Red",
"Fuchsia": "Fuchsia",
"Yellow": "Yellow",
"Olive": "Olive",
"Green": "Green",
"Teal": "Teal",
"Lime": "Lime",
"Purple": "Purple",
"Navy": "Navy",
"Blue": "Blue",
"Aqua": "Aqua"
"Black": "검정색",
"Gray": "회색",
"Silver": "은색",
"White": "흰색",
"Maroon": "밤색",
"Red": "빨간색",
"Fuchsia": "분홍색",
"Yellow": "노란색",
"Olive": "올리브 색",
"Green": "녹색",
"Teal": "청록색",
"Lime": "라임색",
"Purple": "보라색",
"Navy": "남색",
"Blue": "파란색",
"Aqua": "아쿠아색"
},
"thickness": {
"None": "None",
"Thin": "Thin",
"Normal": "Normal",
"Thick": "Thick"
"None": "없음",
"Thin": "얇게",
"Normal": "보통",
"Thick": "굵게"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"subtitle_color": "자막 색상",
"subtitle_background_color": "배경 색상",
"subtitle_font": "자막 폰트",
"ksplayer_title": "KSPlayer 설정",
"hardware_decode": "하드웨어 디코딩",
"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": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
"title": "VLC 자막 설정",
"hint": "VLC 플레이어의 자막 표시 방식을 설정하세요. 변경 사항은 다음 재생 시 적용됩니다.",
"text_color": "글자색",
"background_color": "배경 색상",
"background_opacity": "배경 투명도",
"outline_color": "외곽선 색상",
"outline_opacity": "외곽선 투명도",
"outline_thickness": "외곽선 굵기",
"bold": "굵은 글씨",
"margin": "아래쪽 여백"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "비디오 플레이어",
"video_player": "비디오 플레이어",
"video_player_description": "iOS 사용자는 비디오 플레이어를 선택하세요.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -288,20 +345,20 @@
"PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape",
"LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right",
"LANDSCAPE_LEFT": "왼쪽 가로 모드",
"LANDSCAPE_RIGHT": "오른쪽 가로 모드",
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe Area in Controls",
"safe_area_in_controls": "컨트롤 안전 영역",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "라이브러리 숨기기",
"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",
"default_quality": "Default Quality",
@@ -334,24 +391,24 @@
"password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}",
"login_button": "Login",
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"total_media_requests": "전체 미디어 요청 수",
"movie_quota_limit": "영화 요청 한도",
"movie_quota_days": "영화 요청 제한 기간",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"tv_quota_days": "TV 요청 제한 기간",
"reset_jellyseerr_config_button": "Seerr 설정 초기화",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"plus_n_more": "+{{n}}개 더",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"VOTE_COUNT_AND_AVERAGE": "평균 평점 및 투표 수",
"POPULARITY": "Popularity"
}
},
"marlin_search": {
"enable_marlin_search": "Enable Marlin Search",
"enable_marlin_search": "Marlin 검색 활성화",
"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.",
"read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save",
@@ -374,28 +431,28 @@
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"enable_series_recommendations": "시리즈 추천",
"enable_promoted_watchlists": "추천 관심 목록",
"hide_watchlists_tab": "관심 목록 탭 숨기기",
"home_sections_hint": "홈 페이지에서 Streamystats의 개인 맞춤 추천 및 추천 관심 목록을 표시합니다.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"recommended_series": "추천 시리즈",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"refreshed": "서버에서 설정을 새로고침했습니다",
"disabled": "Streamystats 비활성화됨"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "서버에서 설정 새로고침"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
"watchlist_enabler": "관심 목록 통합 기능 활성화",
"watchlist_button": "관심 목록 연동 켜기/끄기"
}
},
"storage": {
"storage_title": "Storage",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Device {{availableSpace}}%",
"app_usage": " {{usedSpace}}",
"device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache",
@@ -403,10 +460,16 @@
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"music_cache_cleared": "음악 캐시가 삭제되었습니다",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"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": {
"title": "Intro",
@@ -430,11 +493,26 @@
"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": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
"no_active_sessions": "세션 비활성화"
},
"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.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"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",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Search...",
@@ -556,6 +641,7 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_stats": "Show Stats",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"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.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"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": {
"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}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"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": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"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": {
"next": "Next",
@@ -652,7 +795,18 @@
"movies": "Movies",
"sports": "Sports",
"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": {
"confirm": "Confirm",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "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": {
"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.",
@@ -716,7 +876,8 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites"
"favorites": "Favorites",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"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": "Fout",
"login_title": "Aanmelden",
"login_to_title": "Aanmelden bij",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"login_button": "Aanmelden",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Account selecteren",
"add_account": "Account toevoegen",
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd."
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd.",
"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": {
"title": "Account opslaan",
@@ -86,6 +95,7 @@
"oops": "Oeps!",
"error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.",
"continue_watching": "Verder Kijken",
"continue": "Continue",
"next_up": "Volgende",
"continue_and_next_up": "Doorgaan & Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Instellingen",
"log_out_button": "Afmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorieën"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Weergave",
"merge_next_up_continue_watching": "Doorgaan met kijken & Volgende samenvoegen",
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
"hide_remote_session_button": "Verberg Knop voor Externe Sessie",
"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": {
"title": "Netwerk",
@@ -174,6 +199,22 @@
"rewind_length": "Duur terugspoelen",
"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_title": "Gebaar Bediening",
"horizontal_swipe_skip": "Horizontale Swipe om over te slaan",
@@ -256,7 +297,23 @@
"subtitle_font": "Lettertype ondertitels",
"ksplayer_title": "KSPlayer Instellingen",
"hardware_decode": "Hardware Acceleratie",
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt.",
"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": {
"title": "VLC ondertitel instellingen",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Muziek cache gewist",
"delete_all_downloaded_songs": "Verwijder alle gedownloade liedjes",
"downloaded_songs_size": "{{size}} gedownload",
"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": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fout bij het verwijderen van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld"
},
"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": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Voor de nieuwe update moet de content opnieuw worden gedownload. Verwijder alle gedownloade content en probeer het opnieuw.",
"back": "Terug",
"delete": "Verwijder",
"delete_download": "Delete Download",
"something_went_wrong": "Er ging iets mis",
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar",
"video": "Video",
"audio": "Audio",
"subtitle": "Ondertitel",
"play": "Afspelen",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Geen",
"track": "Spoor",
"cancel": "Annuleren",
"stop": "Stop",
"delete": "Verwijderen",
"ok": "Oké",
"remove": "Verwijderen",
"next": "Volgende",
"back": "Terug",
"continue": "Doorgaan",
"verifying": "Verifiëren..."
"verifying": "Verifiëren...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Zoek...",
@@ -556,6 +641,7 @@
"movies": "Films",
"series": "Series",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "items"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Omslag",
"show_titles": "Toon titels",
"show_stats": "Toon statistieken"
"show_stats": "Toon statistieken",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sorteren op",
"filter_by": "Filteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels"
"tags": "Labels",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Geen links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Wil je het gedownloade bestand afspelen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nee",
"downloaded_file_cancel": "Annuleren"
"downloaded_file_cancel": "Annuleren",
"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": {
"next_up": "Volgende",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Seizoenen",
"season": "Seizoen",
"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": "Geen afleveringen voor dit seizoen",
"overview": "Overzicht",
"more_with": "Meer met {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media opties",
"quality": "Kwaliteit",
"audio": "Audio",
"subtitles": "Ondertitel",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Toon meer",
"show_less": "Toon minder",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Verschenen in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} items",
"download_unwatched_only": "Alleen niet bekeken",
"download_button": "Downloaden"
}
},
"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": {
"next": "Volgende ",
@@ -652,7 +795,18 @@
"movies": "Films",
"sports": "Sport",
"for_kids": "Voor kinderen",
"news": "Nieuws"
"news": "Nieuws",
"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": {
"confirm": "Bevestig",
@@ -697,6 +851,12 @@
"decline": "Weigeren",
"requested_by": "Aangevraagd door {{user}}",
"unknown_user": "Onbekende gebruiker",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislukt. Probeer opnieuw.",
@@ -716,7 +876,8 @@
"search": "Zoeken",
"library": "Bibliotheek",
"custom_links": "Aangepaste links",
"favorites": "Favorieten"
"favorites": "Favorieten",
"settings": "Settings"
},
"music": {
"title": "Muziek",
@@ -841,5 +1002,36 @@
"show": "Deze serie",
"all": "Alle media (standaard)"
}
},
"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