mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 13:08:33 +01:00
Compare commits
53 Commits
feature/sy
...
feat/setti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8718244f40 | ||
|
|
2245c84d2c | ||
|
|
ba0b646576 | ||
|
|
bf6010b3c7 | ||
|
|
8071e76fc6 | ||
|
|
8a88706776 | ||
|
|
db9540f90a | ||
|
|
9f3014a430 | ||
|
|
da47ad0502 | ||
|
|
059c8bb854 | ||
|
|
583fac6938 | ||
|
|
f6333b49d3 | ||
|
|
f56b2bc4eb | ||
|
|
ac5dcbe751 | ||
|
|
752d1179bc | ||
|
|
6a38c393e6 | ||
|
|
e476e0b4d9 | ||
|
|
e783227ba6 | ||
|
|
a0e6b31500 | ||
|
|
4e3f5a72e0 | ||
|
|
25c0663d2f | ||
|
|
81e97740f7 | ||
|
|
1349ad7dc0 | ||
|
|
4df9690d22 | ||
|
|
3dfae1a1b4 | ||
|
|
c7e3ceeefb | ||
|
|
46d96d5965 | ||
|
|
7d16e7d5c7 | ||
|
|
ceb9b5a1ae | ||
|
|
1144ff5049 | ||
|
|
4d508a4315 | ||
|
|
915a4febbb | ||
|
|
88163eb531 | ||
|
|
46bd2a784e | ||
|
|
0a36fdfbec | ||
|
|
45d1f752d6 | ||
|
|
54ee507209 | ||
|
|
338fb9713b | ||
|
|
939fd2512d | ||
|
|
32c99de874 | ||
|
|
c232e433bf | ||
|
|
07e2faff07 | ||
|
|
8507699cdd | ||
|
|
21fb056586 | ||
|
|
1d79b513f3 | ||
|
|
863dffd944 | ||
|
|
6aa0868bfd | ||
|
|
6b7ee0514f | ||
|
|
c663bd0413 | ||
|
|
52e6f56220 | ||
|
|
c981f59a50 | ||
|
|
62fc6f9a70 | ||
|
|
eb8dd51b4e |
25
.eas/build/android-production-apk.yml
Normal file
25
.eas/build/android-production-apk.yml
Normal 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
|
||||||
27
.eas/build/android-production-tv.yml
Normal file
27
.eas/build/android-production-tv.yml
Normal 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
|
||||||
38
.eas/build/android-production.yml
Normal file
38
.eas/build/android-production.yml
Normal 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
|
||||||
44
.eas/build/ios-production.yml
Normal file
44
.eas/build/ios-production.yml
Normal 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
216
.github/workflows/release.yml
vendored
Normal 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
9
.gitignore
vendored
@@ -18,6 +18,9 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
|
# Gradle caches (top-level + per-module native projects)
|
||||||
|
**/.gradle/
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/mpv-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
@@ -73,6 +76,12 @@ modules/background-downloader/android/build/*
|
|||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
|
||||||
|
!.eas/build/
|
||||||
|
!.eas/build/**
|
||||||
.claude/
|
.claude/
|
||||||
.agents/skills/**
|
.agents/skills/**
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
|
# CI-injected Google Play service account key (written at build time)
|
||||||
|
google-service-account.json
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
const SyncPlayButtonComponent = Platform.isTV
|
|
||||||
? null
|
|
||||||
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
@@ -36,7 +33,6 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -247,6 +243,28 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/account/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.account.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/notifications/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.notifications.title"),
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
|
|||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
onPress={() => bottomSheetModalRef.current?.present()}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const _deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -127,7 +133,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const _deleteShows = () =>
|
const deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -138,7 +144,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const _deleteOtherMedia = () =>
|
const deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia
|
otherMedia
|
||||||
.filter((item) => item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={true}>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='purple' onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
{otherMedia.length > 0 && (
|
||||||
|
<Button color='purple' onPress={deleteOtherMedia}>
|
||||||
|
{t("home.downloads.delete_all_other_media_button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { SettingsHero } from "@/components/settings/index/SettingsHero";
|
||||||
|
import { SettingsRow } from "@/components/settings/index/SettingsRow";
|
||||||
|
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
|
||||||
|
import { SettingsSection } from "@/components/settings/index/SettingsSection";
|
||||||
|
import {
|
||||||
|
SETTINGS_CATALOG,
|
||||||
|
type SettingsTarget,
|
||||||
|
} from "@/components/settings/index/settingsCatalog";
|
||||||
|
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
|
||||||
|
import {
|
||||||
|
QuickConnectSheet,
|
||||||
|
type QuickConnectSheetRef,
|
||||||
|
} from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
// TV-specific settings component
|
|
||||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
// Mobile settings component
|
|
||||||
function SettingsMobile() {
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||||
|
const results = useSettingsSearch(query);
|
||||||
|
const searching = query.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => logout()}>
|
||||||
onPress={() => {
|
|
||||||
logout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -41,96 +47,95 @@ function SettingsMobile() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTarget = (target: SettingsTarget) => {
|
||||||
|
if (target.type === "action") {
|
||||||
|
if (target.action === "quickConnect") {
|
||||||
|
quickConnectRef.current?.present();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(target.route as any);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 32,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{!searching && (
|
||||||
className='p-4 flex flex-col'
|
<SettingsHero
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
onPress={() => router.push("/settings/account/page" as any)}
|
||||||
>
|
|
||||||
<View className='mb-4'>
|
|
||||||
<UserInfo />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
|
||||||
|
|
||||||
<View className='mb-4'>
|
|
||||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() =>
|
|
||||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
|
||||||
}
|
|
||||||
title={t("pairing.pair_with_phone")}
|
|
||||||
textColor='blue'
|
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
)}
|
||||||
</View>
|
<SettingsSearchBar value={query} onChange={setQuery} />
|
||||||
|
|
||||||
<View className='mb-4'>
|
{searching ? (
|
||||||
|
<SettingsSection title={t("home.settings.search_results")}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<View className='px-4 py-3'>
|
||||||
|
<Text className='text-[#9899A1]'>
|
||||||
|
{t("home.settings.search_no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
results.map((r, i) => (
|
||||||
|
<SettingsRow
|
||||||
|
key={r.id}
|
||||||
|
title={r.title}
|
||||||
|
icon={r.icon}
|
||||||
|
value={r.subtitle}
|
||||||
|
onPress={() => handleTarget(r.target)}
|
||||||
|
isLast={i === results.length - 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className='mx-3 mb-5'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
</View>
|
</View>
|
||||||
|
{SETTINGS_CATALOG.map((section) => {
|
||||||
<View className='mb-4'>
|
const entries = section.entries.filter(
|
||||||
<ListGroup title={t("home.settings.categories.title")}>
|
(e) => !e.platforms || e.platforms.includes(os),
|
||||||
<ListItem
|
);
|
||||||
onPress={() => router.push("/settings/playback-controls/page")}
|
if (entries.length === 0) return null;
|
||||||
showArrow
|
return (
|
||||||
title={t("home.settings.playback_controls.title")}
|
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<SettingsRow
|
||||||
|
key={e.id}
|
||||||
|
title={t(e.titleKey)}
|
||||||
|
icon={e.icon}
|
||||||
|
onPress={() => handleTarget(e.target)}
|
||||||
|
isLast={i === entries.length - 1}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
))}
|
||||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
</SettingsSection>
|
||||||
showArrow
|
);
|
||||||
title={t("home.settings.audio_subtitles.title")}
|
})}
|
||||||
/>
|
<SettingsSection>
|
||||||
<ListItem
|
<View className='p-3'>
|
||||||
onPress={() => router.push("/settings/music/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.music.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/appearance/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.appearance.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.plugins.plugins_title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/intro/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.intro.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/network/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.network.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.logs.logs_title")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<StorageSettings />
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<QuickConnectSheet ref={quickConnectRef} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
// Use TV settings component on TV platforms
|
|
||||||
if (Platform.isTV && SettingsTV) {
|
if (Platform.isTV && SettingsTV) {
|
||||||
return <SettingsTV />;
|
return <SettingsTV />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsMobile />;
|
return <SettingsMobile />;
|
||||||
}
|
}
|
||||||
|
|||||||
60
app/(auth)/(tabs)/(home)/settings/account/page.tsx
Normal file
60
app/(auth)/(tabs)/(home)/settings/account/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as Application from "expo-application";
|
||||||
|
import { setStringAsync } from "expo-clipboard";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, ScrollView } from "react-native";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const success = useHaptic("success");
|
||||||
|
const version = Application.nativeApplicationVersion ?? "N/A";
|
||||||
|
const token = api?.accessToken ?? "";
|
||||||
|
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
|
||||||
|
|
||||||
|
const copyToken = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await setStringAsync(token);
|
||||||
|
success();
|
||||||
|
Alert.alert(t("home.settings.account.copied"));
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t("home.settings.account.copy_failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<ListGroup title={t("home.settings.user_info.user_info_title")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.user_info.user")}
|
||||||
|
value={user?.Name}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.user_info.server")}
|
||||||
|
value={api?.basePath}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.user_info.token")}
|
||||||
|
value={revealed ? token : masked}
|
||||||
|
onPress={() => setRevealed((r) => !r)}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.account.copy_token")}
|
||||||
|
textColor='blue'
|
||||||
|
onPress={copyToken}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.user_info.app_version")}
|
||||||
|
value={version}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
app/(auth)/(tabs)/(home)/settings/notifications/page.tsx
Normal file
68
app/(auth)/(tabs)/(home)/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Linking, ScrollView, Switch, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const [perm, setPerm] =
|
||||||
|
useState<Notifications.NotificationPermissionsStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Notifications.getPermissionsAsync().then(setPerm);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPermission = async () => {
|
||||||
|
const res = await Notifications.requestPermissionsAsync();
|
||||||
|
setPerm(res);
|
||||||
|
// Only bounce to system settings when the OS will not prompt again.
|
||||||
|
if (!res.granted && res.canAskAgain === false) {
|
||||||
|
Linking.openSettings();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings || perm === null) return null;
|
||||||
|
|
||||||
|
if (!perm.granted) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
|
||||||
|
<Text className='text-white text-lg font-semibold mt-4 text-center'>
|
||||||
|
{t("home.settings.notifications.disabled_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-[#9899A1] text-center mt-2'>
|
||||||
|
{t("home.settings.notifications.disabled_description")}
|
||||||
|
</Text>
|
||||||
|
<Button color='purple' className='mt-6' onPress={requestPermission}>
|
||||||
|
{t("home.settings.notifications.enable_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<ListGroup title={t("home.settings.notifications.events_title")}>
|
||||||
|
<ListItem title={t("home.settings.notifications.master")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.notificationsEnabled}
|
||||||
|
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={t("home.settings.notifications.downloads")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.notifyDownloads}
|
||||||
|
disabled={!settings.notificationsEnabled}
|
||||||
|
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,18 +20,16 @@ export default function PlaybackControlsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View className='mb-4'>
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
<MpvBufferSettings />
|
<MpvBufferSettings />
|
||||||
<MpvVoSettings />
|
<MpvVoSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
// Update local state with new values
|
// Update local state with new values
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
setUrl(newUrl);
|
setUrl(newUrl);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
|
|||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View className='pl-1.5'>
|
<View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const Layout = () => {
|
|||||||
keyboardDismissMode='none'
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
|
tabBarActiveTintColor: "#FFFFFF",
|
||||||
|
tabBarInactiveTintColor: "#9CA3AF",
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
|
||||||
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -129,7 +128,6 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
syncPlay: syncPlayStr,
|
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -139,24 +137,9 @@ export default function DirectPlayerPage() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
/** Whether playback was initiated by SyncPlay */
|
|
||||||
syncPlay?: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
|
||||||
const openedViaSyncPlay = syncPlayStr === "true";
|
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
// SyncPlay integration
|
|
||||||
const syncPlay = useSyncPlay();
|
|
||||||
const {
|
|
||||||
isEnabled: isSyncPlayEnabled,
|
|
||||||
controller: syncPlayController,
|
|
||||||
setPlayerControls,
|
|
||||||
notifyReady,
|
|
||||||
notifyBuffering,
|
|
||||||
} = syncPlay;
|
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
@@ -291,6 +274,11 @@ export default function DirectPlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
|
setItem(null);
|
||||||
|
setDownloadedItem(null);
|
||||||
|
// Clear the previous episode's stream so the loader gate stays closed
|
||||||
|
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||||
|
setStream(null);
|
||||||
fetchItemData();
|
fetchItemData();
|
||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
@@ -333,6 +321,12 @@ export default function DirectPlayerPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure item matches the current itemId to avoid race conditions
|
||||||
|
if (item.Id !== itemId) {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem?.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
@@ -405,6 +399,7 @@ export default function DirectPlayerPage() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
|
offline,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -420,102 +415,8 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
// SyncPlay: Connect player controls when video is ready
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVideoLoaded || !videoRef.current || offline) {
|
|
||||||
setPlayerControls(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controls: PlayerControls = {
|
|
||||||
play: () => videoRef.current?.play(),
|
|
||||||
pause: () => videoRef.current?.pause(),
|
|
||||||
seekTo: (positionMs: number) => {
|
|
||||||
const positionSec = positionMs / 1000;
|
|
||||||
console.log(
|
|
||||||
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
|
||||||
);
|
|
||||||
videoRef.current?.seekTo(positionSec);
|
|
||||||
},
|
|
||||||
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
|
||||||
getSpeed: () => currentPlaybackSpeed,
|
|
||||||
getCurrentPosition: () => progress.get(),
|
|
||||||
isPlaying: () => isPlaying,
|
|
||||||
isBuffering: () => isBuffering,
|
|
||||||
};
|
|
||||||
|
|
||||||
setPlayerControls(controls);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
setPlayerControls(null);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
isVideoLoaded,
|
|
||||||
offline,
|
|
||||||
isPlaying,
|
|
||||||
isBuffering,
|
|
||||||
currentPlaybackSpeed,
|
|
||||||
progress,
|
|
||||||
setPlayerControls,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// SyncPlay: Report buffering/ready state to server.
|
|
||||||
//
|
|
||||||
// CRITICAL: We must report `buffering` to the server *during* initial
|
|
||||||
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
|
||||||
// and proceeds without waiting for us. jellyfin-web reports this for
|
|
||||||
// free via the HTML5 video element's `waiting` event; for us, the
|
|
||||||
// initial load itself is the buffering window.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSyncPlayEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLocallyReady = isVideoLoaded && !isBuffering;
|
|
||||||
if (isLocallyReady) {
|
|
||||||
notifyReady();
|
|
||||||
} else {
|
|
||||||
notifyBuffering();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isSyncPlayEnabled,
|
|
||||||
isVideoLoaded,
|
|
||||||
isBuffering,
|
|
||||||
notifyReady,
|
|
||||||
notifyBuffering,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// SyncPlay: Pause playback when group is waiting
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSyncPlayEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupState = syncPlay.groupInfo?.State;
|
|
||||||
const isLocalReady = isVideoLoaded && !isBuffering;
|
|
||||||
const isWaitingForGroup = groupState === "Waiting";
|
|
||||||
|
|
||||||
// Pause playback when waiting for group
|
|
||||||
if (isLocalReady && isWaitingForGroup && isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isSyncPlayEnabled,
|
|
||||||
syncPlay.groupInfo?.State,
|
|
||||||
isVideoLoaded,
|
|
||||||
isBuffering,
|
|
||||||
isPlaying,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
// Route through SyncPlay when active
|
|
||||||
if (isSyncPlayEnabled && syncPlayController) {
|
|
||||||
syncPlayController.playPause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
@@ -749,12 +650,10 @@ export default function DirectPlayerPage() {
|
|||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
|
||||||
const shouldAutoplay = !openedViaSyncPlay;
|
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: shouldAutoplay,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
@@ -973,20 +872,10 @@ export default function DirectPlayerPage() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback(
|
const seek = useCallback((position: number) => {
|
||||||
(position: number) => {
|
|
||||||
// Route through SyncPlay when active
|
|
||||||
if (isSyncPlayEnabled && syncPlayController) {
|
|
||||||
console.log("SyncPlay: seek requested via SyncPlay", position);
|
|
||||||
syncPlayController.seekMs(position);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MPV expects seconds, convert from ms
|
// MPV expects seconds, convert from ms
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
},
|
}, []);
|
||||||
[isSyncPlayEnabled, syncPlayController],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV audio track change handler
|
// TV audio track change handler
|
||||||
const handleAudioIndexChange = useCallback(
|
const handleAudioIndexChange = useCallback(
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
|||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -410,7 +409,6 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<SyncPlayProvider>
|
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<MusicPlayerProvider>
|
<MusicPlayerProvider>
|
||||||
<GlobalModalProvider>
|
<GlobalModalProvider>
|
||||||
@@ -448,8 +446,7 @@ function Layout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "",
|
title: "",
|
||||||
headerTransparent:
|
headerTransparent: Platform.OS === "ios",
|
||||||
Platform.OS === "ios",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<Stack.Screen name='+not-found' />
|
||||||
@@ -539,7 +536,6 @@ function Layout() {
|
|||||||
</GlobalModalProvider>
|
</GlobalModalProvider>
|
||||||
</MusicPlayerProvider>
|
</MusicPlayerProvider>
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
</SyncPlayProvider>
|
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
|
|||||||
26
bun.lock
26
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
|
"expo-clipboard": "~56.0.3",
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -102,8 +104,10 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
|
"@types/node": "^24",
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
@@ -364,7 +368,7 @@
|
|||||||
|
|
||||||
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
||||||
|
|
||||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
|
||||||
|
|
||||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||||
|
|
||||||
@@ -586,6 +590,8 @@
|
|||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
||||||
|
|
||||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||||
@@ -602,7 +608,7 @@
|
|||||||
|
|
||||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
"@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||||
|
|
||||||
@@ -742,6 +748,8 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||||
@@ -954,6 +962,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -1826,7 +1836,7 @@
|
|||||||
|
|
||||||
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
||||||
|
|
||||||
@@ -2030,6 +2040,8 @@
|
|||||||
|
|
||||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -2128,6 +2140,8 @@
|
|||||||
|
|
||||||
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
|
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
||||||
@@ -2246,6 +2260,8 @@
|
|||||||
|
|
||||||
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
@@ -2302,6 +2318,8 @@
|
|||||||
|
|
||||||
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
|
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
type LayoutChangeEvent,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
|
||||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
|
||||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
|
||||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
|
||||||
const [triggerSize, setTriggerSize] = useState<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = e.nativeEvent.layout;
|
|
||||||
setTriggerSize((prev) =>
|
|
||||||
prev && prev.width === width && prev.height === height
|
|
||||||
? prev
|
|
||||||
: { width, height },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
|
||||||
// height depends on the parent while the parent depends on the Host — a
|
|
||||||
// circular dependency that collapses to 0 for any selector nested more than
|
|
||||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
|
||||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
|
||||||
// fills a concrete box.
|
|
||||||
return (
|
return (
|
||||||
<View style={triggerSize ?? { opacity: 0 }}>
|
<View>
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
|
||||||
<View
|
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
|
||||||
pointerEvents='none'
|
|
||||||
aria-hidden
|
|
||||||
onLayout={handleMeasureTrigger}
|
|
||||||
>
|
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
@@ -68,14 +67,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
|
|
||||||
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
|
|
||||||
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
|
|
||||||
// then performs the navigation uniformly for everyone, matching
|
|
||||||
// jellyfin-web's playbackManager intercept (Controller.play).
|
|
||||||
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
|
||||||
useSyncPlay();
|
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
const effectiveColors = colors || globalColorAtom;
|
const effectiveColors = colors || globalColorAtom;
|
||||||
|
|
||||||
@@ -103,37 +94,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
// SyncPlay intercept: in a group, route playback through sthe server so
|
|
||||||
// every member gets the same PlayQueue: NewPlaylist update and navigates
|
|
||||||
// together. Skips local navigation and the Chromecast prompt entirely —
|
|
||||||
// SyncPlay + Chromecast isn't a supported combination yet, same as
|
|
||||||
// jellyfin-web.
|
|
||||||
if (isSyncPlayEnabled && syncPlayController && item.Id) {
|
|
||||||
try {
|
|
||||||
// Pass the full `item` (not just the ID) so the SyncPlay controller
|
|
||||||
// can run `translateItemsForPlayback` with full context — this is
|
|
||||||
// what jellyfin-web does, and it lets us expand Series / Season /
|
|
||||||
// BoxSet into real episode/track IDs before broadcasting the queue.
|
|
||||||
// Without expansion, receivers (jellyfin-web in particular) get
|
|
||||||
// container IDs they can't play and silently fail to open the
|
|
||||||
// player.
|
|
||||||
await syncPlayController.play({
|
|
||||||
items: [item],
|
|
||||||
ids: [item.Id],
|
|
||||||
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("SyncPlay: failed to start group playback", error);
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("syncplay.failed_to_start", {
|
|
||||||
defaultValue: "Failed to start SyncPlay group playback",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -330,8 +290,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
goToPlayer,
|
goToPlayer,
|
||||||
isOffline,
|
isOffline,
|
||||||
t,
|
t,
|
||||||
isSyncPlayEnabled,
|
|
||||||
syncPlayController,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import {
|
import {
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
chapterStartsMs,
|
chapterStartsMs,
|
||||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterListProps) {
|
}: ChapterListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const safeArea = useControlsSafeAreaInsets();
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
@@ -74,9 +76,22 @@ function ChapterListComponent({
|
|||||||
transparent
|
transparent
|
||||||
animationType='slide'
|
animationType='slide'
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
|
// iOS defaults <Modal> to portrait-only; without this it rotates the app
|
||||||
|
// back to portrait when opened from the landscape player. Android ignores it.
|
||||||
|
supportedOrientations={["portrait", "landscape"]}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
<Pressable
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
style={[
|
||||||
|
styles.sheet,
|
||||||
|
{
|
||||||
|
marginLeft: safeArea.left,
|
||||||
|
marginRight: safeArea.right,
|
||||||
|
paddingBottom: safeArea.bottom,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -157,14 +172,12 @@ const styles = StyleSheet.create({
|
|||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.background,
|
backgroundColor: Colors.background,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={
|
||||||
width: `${progress}%`,
|
Platform.isTV
|
||||||
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
||||||
}}
|
: { width: `${progress}%` }
|
||||||
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
}
|
||||||
|
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
const options: string[] = [
|
||||||
"Mark as Played",
|
t("common.mark_as_played"),
|
||||||
"Mark as Not Played",
|
t("common.mark_as_not_played"),
|
||||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
isFavorite
|
||||||
...(isOffline ? ["Delete Download"] : []),
|
? t("music.track_options.remove_from_favorites")
|
||||||
"Cancel",
|
: t("music.track_options.add_to_favorites"),
|
||||||
|
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||||
|
t("common.cancel"),
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = isOffline
|
||||||
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -37,7 +37,20 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
return { ...item, People: people } as BaseItemDto;
|
return { ...item, People: people } as BaseItemDto;
|
||||||
}, [item, people]);
|
}, [item, people]);
|
||||||
|
|
||||||
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
// Jellyfin can list the same person several times (e.g. an actor also
|
||||||
|
// credited as writer). Dedupe by Id so the same actor section isn't rendered
|
||||||
|
// twice and we still surface 3 distinct people.
|
||||||
|
const topPeople = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique: BaseItemPerson[] = [];
|
||||||
|
for (const person of people) {
|
||||||
|
if (!person.Id || seen.has(person.Id)) continue;
|
||||||
|
seen.add(person.Id);
|
||||||
|
unique.push(person);
|
||||||
|
if (unique.length >= 3) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}, [people]);
|
||||||
|
|
||||||
const renderActorSection = useCallback(
|
const renderActorSection = useCallback(
|
||||||
(person: BaseItemPerson, idx: number, total: number) => {
|
(person: BaseItemPerson, idx: number, total: number) => {
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
|
const hasSubtitle = Boolean(effectiveSubtitle);
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -58,7 +59,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Pair with Phone */}
|
{/* Pair with Phone */}
|
||||||
{onStartPairing && (
|
{Platform.OS !== "ios" && onStartPairing && (
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={onStartPairing}
|
onPress={onStartPairing}
|
||||||
|
|||||||
@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const hasMovies = movieResults && movieResults.length > 0;
|
|
||||||
const hasTv = tvResults && tvResults.length > 0;
|
|
||||||
const hasPersons = personResults && personResults.length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||||
|
keeps focus while typing, otherwise the first result would re-grab
|
||||||
|
focus on every keystroke as results re-render. The user navigates
|
||||||
|
down to the grid manually. */}
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={hasMovies}
|
isFirstSection={false}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={!hasMovies && hasTv}
|
isFirstSection={false}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
isFirstSection={false}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
module). It renders the native search bar + grid keyboard and
|
module). It renders the native search bar + grid keyboard and
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
our own results grid renders below. */}
|
our own results grid renders below. */}
|
||||||
|
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||||
|
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||||
|
margins squeeze the bar's width and clip that trailing hint, so let
|
||||||
|
the native view span the full width and own its own insets. */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
|
||||||
height: SEARCH_AREA_HEIGHT,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section, index) => (
|
{sections.map((section) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
isFirstSection={index === 0}
|
// Never auto-focus a result. The native search field owns focus
|
||||||
|
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||||
|
// every keystroke as results re-render. User navigates down to the
|
||||||
|
// grid manually.
|
||||||
|
isFirstSection={false}
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentInset={{
|
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||||
left: edgePadding,
|
// contentOffset only applies on initial mount; since this FlatList is
|
||||||
right: edgePadding,
|
// reused across searches (stable key), a second search left the inset
|
||||||
}}
|
// without the offset and the grid snapped flush to the left edge.
|
||||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: edgePadding,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const isOffline = useOfflineMode();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
// Read the live (cached) downloads DB inside the query rather than the
|
||||||
|
// provider's downloadedItems snapshot, so refetches after
|
||||||
|
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||||
|
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={t("home.settings.chromecast.title")}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<SettingsSwitchRow
|
||||||
<Switch
|
title={t("home.settings.chromecast.enable_h265")}
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -32,85 +31,65 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
||||||
>
|
>
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
updateSettings({ enableHorizontalSwipeSkip })
|
updateSettings({ enableHorizontalSwipeSkip })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.gesture_controls.left_side_brightness")}
|
title={t("home.settings.gesture_controls.left_side_brightness")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.left_side_brightness_description",
|
"home.settings.gesture_controls.left_side_brightness_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.gesture_controls.right_side_volume")}
|
title={t("home.settings.gesture_controls.right_side_volume")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.right_side_volume_description",
|
"home.settings.gesture_controls.right_side_volume_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
updateSettings({ enableRightSideVolumeSwipe })
|
updateSettings({ enableRightSideVolumeSwipe })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
updateSettings({ hideVolumeSlider })
|
updateSettings({ hideVolumeSlider })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
updateSettings({ hideBrightnessSlider })
|
updateSettings({ hideBrightnessSlider })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</OptionGroup>
|
</OptionGroup>
|
||||||
|
|
||||||
<OptionGroup title='Options'>
|
<OptionGroup title={t("library.options.options_title")}>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
label={t("library.options.show_titles")}
|
label={t("library.options.show_titles")}
|
||||||
value={settings.showTitles}
|
value={settings.showTitles}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -27,35 +26,27 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled} {...props}>
|
<DisabledSetting disabled={disabled} {...props}>
|
||||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
<ListItem
|
<SettingsStepperRow
|
||||||
title={t("home.settings.media_controls.forward_skip_length")}
|
title={t("home.settings.media_controls.forward_skip_length")}
|
||||||
disabled={pluginSettings?.forwardSkipTime?.locked}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
>
|
|
||||||
<Stepper
|
|
||||||
value={settings.forwardSkipTime}
|
value={settings.forwardSkipTime}
|
||||||
disabled={pluginSettings?.forwardSkipTime?.locked}
|
|
||||||
step={5}
|
step={5}
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
min={0}
|
min={0}
|
||||||
max={60}
|
max={60}
|
||||||
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsStepperRow
|
||||||
title={t("home.settings.media_controls.rewind_length")}
|
title={t("home.settings.media_controls.rewind_length")}
|
||||||
disabled={pluginSettings?.rewindSkipTime?.locked}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
>
|
|
||||||
<Stepper
|
|
||||||
value={settings.rewindSkipTime}
|
value={settings.rewindSkipTime}
|
||||||
disabled={pluginSettings?.rewindSkipTime?.locked}
|
|
||||||
step={5}
|
step={5}
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
min={0}
|
min={0}
|
||||||
max={60}
|
max={60}
|
||||||
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
||||||
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
||||||
@@ -45,24 +41,16 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
|
<ListGroup title={t("home.settings.buffer.title")}>
|
||||||
<ListItem title={t("home.settings.buffer.cache_mode")}>
|
<SettingsSelectRow
|
||||||
<PlatformDropdown
|
|
||||||
groups={cacheModeOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{currentCacheModeLabel}
|
|
||||||
</Text>
|
|
||||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.buffer.cache_mode")}
|
title={t("home.settings.buffer.cache_mode")}
|
||||||
|
valueLabel={currentCacheModeLabel}
|
||||||
|
groups={cacheModeOptions}
|
||||||
|
dropdownTitle={t("home.settings.buffer.cache_mode")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={t("home.settings.buffer.buffer_duration")}>
|
<SettingsStepperRow
|
||||||
<Stepper
|
title={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
step={5}
|
step={5}
|
||||||
min={5}
|
min={5}
|
||||||
@@ -70,10 +58,9 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||||
appendValue='s'
|
appendValue='s'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={t("home.settings.buffer.max_cache_size")}>
|
<SettingsStepperRow
|
||||||
<Stepper
|
title={t("home.settings.buffer.max_cache_size")}
|
||||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||||
step={25}
|
step={25}
|
||||||
min={50}
|
min={50}
|
||||||
@@ -81,20 +68,16 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
|
<SettingsStepperRow
|
||||||
<Stepper
|
title={t("home.settings.buffer.max_backward_cache")}
|
||||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||||
step={25}
|
step={25}
|
||||||
min={25}
|
min={25}
|
||||||
max={200}
|
max={200}
|
||||||
onUpdate={(value) =>
|
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
|
||||||
updateSettings({ mpvDemuxerMaxBackBytes: value })
|
|
||||||
}
|
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||||
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
||||||
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
||||||
@@ -46,21 +43,13 @@ export const MpvVoSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
|
<ListGroup title={t("home.settings.vo_driver.title")}>
|
||||||
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
|
<SettingsSelectRow
|
||||||
<PlatformDropdown
|
|
||||||
groups={voDriverOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{currentVoDriverLabel}
|
|
||||||
</Text>
|
|
||||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.vo_driver.vo_mode")}
|
title={t("home.settings.vo_driver.vo_mode")}
|
||||||
|
valueLabel={currentVoDriverLabel}
|
||||||
|
groups={voDriverOptions}
|
||||||
|
dropdownTitle={t("home.settings.vo_driver.vo_mode")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
<ListItem
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
|
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||||
|
>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||||
|
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
@@ -116,138 +113,77 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<ListItem
|
<SettingsSelectRow
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
>
|
valueLabel={
|
||||||
<PlatformDropdown
|
t(
|
||||||
groups={orientationOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
orientationTranslations[
|
orientationTranslations[
|
||||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||||
],
|
],
|
||||||
) || "Unknown Orientation"}
|
) || "Unknown Orientation"
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.orientation")}
|
groups={orientationOptions}
|
||||||
|
dropdownTitle={t("home.settings.other.orientation")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSelectRow
|
||||||
title={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
>
|
valueLabel={settings.defaultBitrate?.key}
|
||||||
<PlatformDropdown
|
|
||||||
groups={bitrateOptions}
|
groups={bitrateOptions}
|
||||||
trigger={
|
dropdownTitle={t("home.settings.other.default_quality")}
|
||||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{settings.defaultBitrate?.key}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.default_quality")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSelectRow
|
||||||
title={t("home.settings.other.default_playback_speed")}
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||||
>
|
valueLabel={
|
||||||
<PlatformDropdown
|
PLAYBACK_SPEEDS.find(
|
||||||
groups={playbackSpeedOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{PLAYBACK_SPEEDS.find(
|
|
||||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||||
)?.label ?? "1x"}
|
)?.label ?? "1x"
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.default_playback_speed")}
|
groups={playbackSpeedOptions}
|
||||||
|
dropdownTitle={t("home.settings.other.default_playback_speed")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
updateSettings({ disableHapticFeedback })
|
updateSettings({ disableHapticFeedback })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSwitchRow
|
||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
updateSettings({ autoPlayNextEpisode })
|
updateSettings({ autoPlayNextEpisode })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<SettingsSelectRow
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
disabled={
|
||||||
>
|
!settings.autoPlayNextEpisode ||
|
||||||
<PlatformDropdown
|
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||||
groups={autoPlayEpisodeOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
|
groups={autoPlayEpisodeOptions}
|
||||||
|
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import {
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type BottomSheetMethods,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@/utils/expoUiBottomSheet";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
import { PinInput } from "../inputs/PinInput";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
export type QuickConnectSheetRef = { present: () => void };
|
||||||
|
|
||||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
||||||
|
(_props, ref) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const modalRef = useRef<BottomSheetMethods>(null);
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
const snapPoints = useMemo(
|
const snapPoints = useMemo(
|
||||||
@@ -33,22 +37,21 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const isAndroid = Platform.OS === "android";
|
const isAndroid = Platform.OS === "android";
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
useImperativeHandle(
|
||||||
(props: BottomSheetBackdropProps) => (
|
ref,
|
||||||
<BottomSheetBackdrop
|
() => ({
|
||||||
{...props}
|
present: () => {
|
||||||
disappearsOnIndex={-1}
|
setQuickConnectCode("");
|
||||||
appearsOnIndex={0}
|
modalRef.current?.present();
|
||||||
/>
|
},
|
||||||
),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
if (quickConnectCode) {
|
if (!quickConnectCode) return;
|
||||||
try {
|
try {
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
code: quickConnectCode,
|
code: quickConnectCode,
|
||||||
@@ -61,7 +64,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
modalRef.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -76,39 +79,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, [
|
||||||
}, [api, user, quickConnectCode]);
|
api,
|
||||||
|
user,
|
||||||
|
quickConnectCode,
|
||||||
|
t,
|
||||||
|
successHapticFeedback,
|
||||||
|
errorHapticFeedback,
|
||||||
|
]);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
|
||||||
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
// Reset the code when opening the sheet
|
|
||||||
setQuickConnectCode("");
|
|
||||||
bottomSheetModalRef?.current?.present();
|
|
||||||
}}
|
|
||||||
title={t("home.settings.quick_connect.authorize_button")}
|
|
||||||
textColor='blue'
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={modalRef}
|
||||||
|
enablePanDownToClose
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
backgroundColor: "white",
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
android_keyboardInputMode='adjustResize'
|
|
||||||
topInset={isAndroid ? 0 : undefined}
|
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -142,6 +132,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
QuickConnectSheet.displayName = "QuickConnectSheet";
|
||||||
|
|||||||
81
components/settings/index/SettingsHero.tsx
Normal file
81
components/settings/index/SettingsHero.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
||||||
|
|
||||||
|
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const connected = Boolean(api && user);
|
||||||
|
const imageUrl =
|
||||||
|
api && user?.Id
|
||||||
|
? (getUserImageUrl({
|
||||||
|
serverAddress: api.basePath,
|
||||||
|
userId: user.Id,
|
||||||
|
primaryImageTag: user.PrimaryImageTag,
|
||||||
|
}) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
const host = api?.basePath?.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
className='mx-3 mb-4 rounded-2xl overflow-hidden'
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#241b33", "#15151a"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center p-4'>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: 52, height: 52, borderRadius: 26 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#a855f7", "#6d28d9"]}
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-white text-[22px] font-bold'>
|
||||||
|
{(user?.Name?.[0] ?? "?").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<View className='flex-1 ml-3'>
|
||||||
|
<Text
|
||||||
|
className='text-white text-[18px] font-bold'
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{user?.Name ?? ""}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row items-center mt-0.5'>
|
||||||
|
<View
|
||||||
|
className='w-2 h-2 rounded-full mr-1.5'
|
||||||
|
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
|
||||||
|
/>
|
||||||
|
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
|
||||||
|
{host}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
components/settings/index/SettingsRow.tsx
Normal file
62
components/settings/index/SettingsRow.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type React from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
|
export interface SettingsRowProps {
|
||||||
|
title: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
value?: string;
|
||||||
|
showChevron?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
|
||||||
|
|
||||||
|
export const SettingsRow: React.FC<SettingsRowProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
showChevron = true,
|
||||||
|
onPress,
|
||||||
|
isLast = false,
|
||||||
|
}) => {
|
||||||
|
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
haptic();
|
||||||
|
onPress();
|
||||||
|
}}
|
||||||
|
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
|
||||||
|
isLast ? "" : "border-b border-[#ffffff14]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
|
||||||
|
style={{ backgroundColor: `${ACCENT}29` }}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={17} color='#c79bff' />
|
||||||
|
</View>
|
||||||
|
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{value ? (
|
||||||
|
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{showChevron ? (
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={17}
|
||||||
|
color='#5A5960'
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
components/settings/index/SettingsSearchBar.tsx
Normal file
28
components/settings/index/SettingsSearchBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import type React from "react";
|
||||||
|
import { TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export const SettingsSearchBar: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}> = ({ value, onChange }) => (
|
||||||
|
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
|
||||||
|
<Ionicons name='search' size={16} color='#76767c' />
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder={t("home.settings.search_placeholder")}
|
||||||
|
placeholderTextColor='#76767c'
|
||||||
|
className='flex-1 ml-2 text-white text-[15px]'
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType='search'
|
||||||
|
/>
|
||||||
|
{value.length > 0 ? (
|
||||||
|
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
|
||||||
|
<Ionicons name='close-circle' size={18} color='#76767c' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
19
components/settings/index/SettingsSection.tsx
Normal file
19
components/settings/index/SettingsSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export const SettingsSection: React.FC<{
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ title, children }) => (
|
||||||
|
<View className='mb-5'>
|
||||||
|
{title ? (
|
||||||
|
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
38
components/settings/index/SettingsSelectRow.tsx
Normal file
38
components/settings/index/SettingsSelectRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import {
|
||||||
|
type OptionGroup,
|
||||||
|
PlatformDropdown,
|
||||||
|
} from "@/components/PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
valueLabel?: string;
|
||||||
|
groups: OptionGroup[];
|
||||||
|
dropdownTitle?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsSelectRow: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
valueLabel,
|
||||||
|
groups,
|
||||||
|
dropdownTitle,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<ListItem title={title} disabled={disabled}>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={groups}
|
||||||
|
title={dropdownTitle}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
39
components/settings/index/SettingsStepperRow.tsx
Normal file
39
components/settings/index/SettingsStepperRow.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
value: number;
|
||||||
|
step: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
onUpdate: (value: number) => void;
|
||||||
|
appendValue?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsStepperRow: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onUpdate,
|
||||||
|
appendValue,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
||||||
|
<Stepper
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
appendValue={appendValue}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
23
components/settings/index/SettingsSwitchRow.tsx
Normal file
23
components/settings/index/SettingsSwitchRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Switch } from "react-native";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsSwitchRow: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
||||||
|
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
22
components/settings/index/searchFilter.test.ts
Normal file
22
components/settings/index/searchFilter.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { matchesQuery, normalize } from "./searchFilter";
|
||||||
|
|
||||||
|
test("normalize strips accents and lowercases", () => {
|
||||||
|
expect(normalize("Légèreté")).toBe("legerete");
|
||||||
|
expect(normalize(" AUDIO ")).toBe("audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matchesQuery matches title case/accent-insensitively", () => {
|
||||||
|
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matchesQuery returns true for empty query", () => {
|
||||||
|
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
|
||||||
|
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
|
||||||
|
});
|
||||||
14
components/settings/index/searchFilter.ts
Normal file
14
components/settings/index/searchFilter.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const normalize = (s: string): string =>
|
||||||
|
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
||||||
|
|
||||||
|
export interface Searchable {
|
||||||
|
title: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const matchesQuery = (item: Searchable, query: string): boolean => {
|
||||||
|
const q = normalize(query);
|
||||||
|
if (!q) return true;
|
||||||
|
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
|
||||||
|
return hay.includes(q);
|
||||||
|
};
|
||||||
125
components/settings/index/settingsCatalog.ts
Normal file
125
components/settings/index/settingsCatalog.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export type SettingsTarget =
|
||||||
|
| { type: "route"; route: string }
|
||||||
|
| { type: "action"; action: "quickConnect" };
|
||||||
|
|
||||||
|
export interface SettingsEntry {
|
||||||
|
id: string;
|
||||||
|
titleKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
target: SettingsTarget;
|
||||||
|
/** extra search terms (English); the title is always searched too */
|
||||||
|
keywords?: string[];
|
||||||
|
/** when set, entry only shows on these platforms */
|
||||||
|
platforms?: ("ios" | "android")[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSectionDef {
|
||||||
|
id: string;
|
||||||
|
titleKey: string;
|
||||||
|
entries: SettingsEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the Settings index: drives both rendering and search.
|
||||||
|
*
|
||||||
|
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
|
||||||
|
* entry to the right section (and, if it lives inside a sub-page, an entry in
|
||||||
|
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
|
||||||
|
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
|
||||||
|
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
|
||||||
|
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
|
||||||
|
*/
|
||||||
|
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
|
||||||
|
{
|
||||||
|
id: "playback",
|
||||||
|
titleKey: "home.settings.categories.playback",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "playback-controls",
|
||||||
|
titleKey: "home.settings.playback_controls.title",
|
||||||
|
icon: "play",
|
||||||
|
target: { type: "route", route: "/settings/playback-controls/page" },
|
||||||
|
keywords: ["speed", "skip", "autoplay", "orientation"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audio-subtitles",
|
||||||
|
titleKey: "home.settings.audio_subtitles.title",
|
||||||
|
icon: "chatbox-ellipses",
|
||||||
|
target: { type: "route", route: "/settings/audio-subtitles/page" },
|
||||||
|
keywords: ["subtitle", "audio", "language"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music",
|
||||||
|
titleKey: "home.settings.music.title",
|
||||||
|
icon: "musical-notes",
|
||||||
|
target: { type: "route", route: "/settings/music/page" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "personalization",
|
||||||
|
titleKey: "home.settings.categories.personalization",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "appearance",
|
||||||
|
titleKey: "home.settings.appearance.title",
|
||||||
|
icon: "color-palette",
|
||||||
|
target: { type: "route", route: "/settings/appearance/page" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
titleKey: "home.settings.notifications.title",
|
||||||
|
icon: "notifications",
|
||||||
|
target: { type: "route", route: "/settings/notifications/page" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "advanced",
|
||||||
|
titleKey: "home.settings.categories.advanced",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "quick-connect",
|
||||||
|
titleKey: "home.settings.quick_connect.quick_connect_title",
|
||||||
|
icon: "key",
|
||||||
|
target: { type: "action", action: "quickConnect" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pair",
|
||||||
|
titleKey: "pairing.pair_with_phone",
|
||||||
|
icon: "phone-portrait",
|
||||||
|
target: {
|
||||||
|
type: "route",
|
||||||
|
route: "/(auth)/(tabs)/(home)/companion-login",
|
||||||
|
},
|
||||||
|
platforms: ["android"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plugins",
|
||||||
|
titleKey: "home.settings.plugins.plugins_title",
|
||||||
|
icon: "extension-puzzle",
|
||||||
|
target: { type: "route", route: "/settings/plugins/page" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "network",
|
||||||
|
titleKey: "home.settings.network.title",
|
||||||
|
icon: "wifi",
|
||||||
|
target: { type: "route", route: "/settings/network/page" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logs",
|
||||||
|
titleKey: "home.settings.logs.logs_title",
|
||||||
|
icon: "document-text",
|
||||||
|
target: { type: "route", route: "/settings/logs/page" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intro",
|
||||||
|
titleKey: "home.settings.intro.title",
|
||||||
|
icon: "information-circle",
|
||||||
|
target: { type: "route", route: "/settings/intro/page" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
266
components/settings/index/settingsSearchIndex.ts
Normal file
266
components/settings/index/settingsSearchIndex.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
export interface SearchableOption {
|
||||||
|
titleKey: string;
|
||||||
|
parentRoute: string;
|
||||||
|
parentTitleKey: string;
|
||||||
|
keywords?: string[];
|
||||||
|
platforms?: ("ios" | "android")[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal options of sub-pages, for deep search ("index + internal settings").
|
||||||
|
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
|
||||||
|
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
|
||||||
|
*/
|
||||||
|
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
|
||||||
|
// --- Playback & Controls -------------------------------------------------
|
||||||
|
// MediaToggles
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.media_controls.forward_skip_length",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["skip", "forward", "seconds"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.media_controls.rewind_length",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["rewind", "back", "seconds"],
|
||||||
|
},
|
||||||
|
// GestureControls
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["gesture", "swipe", "skip", "seek"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.gesture_controls.left_side_brightness",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["gesture", "swipe", "brightness", "left"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.gesture_controls.right_side_volume",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["gesture", "swipe", "volume", "right"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.gesture_controls.hide_volume_slider",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["volume", "slider", "hide", "gesture"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["brightness", "slider", "hide", "gesture"],
|
||||||
|
},
|
||||||
|
// PlaybackControlsSettings (home.settings.other.*)
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.video_orientation",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["orientation", "rotate", "landscape", "portrait"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.safe_area_in_controls",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["safe", "area", "notch", "controls", "inset"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.default_quality",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["quality", "bitrate", "resolution"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.default_playback_speed",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["playback", "speed", "rate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.disable_haptic_feedback",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["haptic", "vibration", "feedback"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.auto_play_next_episode",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["autoplay", "auto", "next", "episode"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.max_auto_play_episode_count",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["autoplay", "auto", "episode", "count", "limit"],
|
||||||
|
},
|
||||||
|
// MpvBufferSettings
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.buffer.cache_mode",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["cache", "buffer", "mode"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.buffer.buffer_duration",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["buffer", "duration", "cache", "seconds"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.buffer.max_cache_size",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["buffer", "cache", "size", "memory"],
|
||||||
|
},
|
||||||
|
// MpvVoSettings (Android only)
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.vo_driver.vo_mode",
|
||||||
|
parentRoute: "/settings/playback-controls/page",
|
||||||
|
parentTitleKey: "home.settings.playback_controls.title",
|
||||||
|
keywords: ["video", "output", "driver", "gpu", "mpv"],
|
||||||
|
platforms: ["android"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Audio & Subtitles ---------------------------------------------------
|
||||||
|
// AudioToggles
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.audio.set_audio_track",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["audio", "track", "remember", "previous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.audio.audio_language",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["audio", "language", "default"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.audio.transcode_mode.title",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
|
||||||
|
},
|
||||||
|
// SubtitleToggles
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.subtitles.subtitle_language",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["subtitle", "caption", "language", "default"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.subtitles.subtitle_mode",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["subtitle", "caption", "mode", "forced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.subtitles.set_subtitle_track",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["subtitle", "caption", "track", "remember", "previous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.subtitles.subtitle_size",
|
||||||
|
parentRoute: "/settings/audio-subtitles/page",
|
||||||
|
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||||
|
keywords: ["subtitle", "size", "caption", "scale", "font"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Music ---------------------------------------------------------------
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.music.prefer_downloaded",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "downloaded", "offline", "local"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.music.lookahead_enabled",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "lookahead", "cache", "prefetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.music.lookahead_count",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "lookahead", "cache", "count", "tracks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.music.max_cache_size",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "cache", "size", "storage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.storage.clear_music_cache",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "cache", "clear", "storage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.storage.delete_all_downloaded_songs",
|
||||||
|
parentRoute: "/settings/music/page",
|
||||||
|
parentTitleKey: "home.settings.music.title",
|
||||||
|
keywords: ["music", "downloaded", "delete", "songs", "storage"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Appearance ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.show_custom_menu_links",
|
||||||
|
parentRoute: "/settings/appearance/page",
|
||||||
|
parentTitleKey: "home.settings.appearance.title",
|
||||||
|
keywords: ["menu", "links", "custom", "navigation"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
|
||||||
|
parentRoute: "/settings/appearance/page",
|
||||||
|
parentTitleKey: "home.settings.appearance.title",
|
||||||
|
keywords: ["continue", "watching", "next", "up", "merge", "home"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.other.hide_libraries",
|
||||||
|
parentRoute: "/settings/appearance/page",
|
||||||
|
parentTitleKey: "home.settings.appearance.title",
|
||||||
|
keywords: ["hide", "libraries", "library", "home"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.appearance.hide_remote_session_button",
|
||||||
|
parentRoute: "/settings/appearance/page",
|
||||||
|
parentTitleKey: "home.settings.appearance.title",
|
||||||
|
keywords: ["remote", "session", "button", "hide", "cast"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Network -------------------------------------------------------------
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.network.remote_url",
|
||||||
|
parentRoute: "/settings/network/page",
|
||||||
|
parentTitleKey: "home.settings.network.title",
|
||||||
|
keywords: ["remote", "url", "server", "address"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.network.active_url",
|
||||||
|
parentRoute: "/settings/network/page",
|
||||||
|
parentTitleKey: "home.settings.network.title",
|
||||||
|
keywords: ["active", "url", "server", "connection"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.network.auto_switch_enabled",
|
||||||
|
parentRoute: "/settings/network/page",
|
||||||
|
parentTitleKey: "home.settings.network.title",
|
||||||
|
keywords: ["auto", "switch", "local", "wifi", "network"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "home.settings.network.local_url",
|
||||||
|
parentRoute: "/settings/network/page",
|
||||||
|
parentTitleKey: "home.settings.network.title",
|
||||||
|
keywords: ["local", "url", "lan", "server", "address"],
|
||||||
|
},
|
||||||
|
];
|
||||||
46
components/settings/index/useSettingsSearch.ts
Normal file
46
components/settings/index/useSettingsSearch.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { matchesQuery } from "./searchFilter";
|
||||||
|
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
|
||||||
|
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
subtitle?: string;
|
||||||
|
target: SettingsTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsSearch = (query: string): SearchResult[] => {
|
||||||
|
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
for (const section of SETTINGS_CATALOG) {
|
||||||
|
for (const e of section.entries) {
|
||||||
|
if (e.platforms && !e.platforms.includes(os)) continue;
|
||||||
|
const title = t(e.titleKey);
|
||||||
|
if (matchesQuery({ title, keywords: e.keywords }, query)) {
|
||||||
|
results.push({ id: e.id, title, icon: e.icon, target: e.target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const o of SETTINGS_SEARCH_INDEX) {
|
||||||
|
if (o.platforms && !o.platforms.includes(os)) continue;
|
||||||
|
const title = t(o.titleKey);
|
||||||
|
if (matchesQuery({ title, keywords: o.keywords }, query)) {
|
||||||
|
results.push({
|
||||||
|
id: `${o.parentRoute}#${o.titleKey}`,
|
||||||
|
title,
|
||||||
|
icon: "search",
|
||||||
|
subtitle: t(o.parentTitleKey),
|
||||||
|
target: { type: "route", route: o.parentRoute },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}, [query, os]);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* SyncPlay UI Components
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
|
||||||
export { SyncPlayButton } from "./SyncPlayButton";
|
|
||||||
export {
|
|
||||||
SyncPlayIndicator,
|
|
||||||
useSyncPlayIndicatorState,
|
|
||||||
} from "./SyncPlayIndicator";
|
|
||||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
@@ -75,9 +75,6 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chapter props
|
|
||||||
chapterPositions?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
chapterPositions = [],
|
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
left: insets.left,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
bottom: Math.max(insets.bottom - 17, 0),
|
||||||
bottom:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? Math.max(insets.bottom - 17, 0)
|
|
||||||
: 0,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
{hasChapters && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setChapterListVisible(true)}
|
|
||||||
hitSlop={10}
|
|
||||||
className='justify-center mr-4'
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.open")}
|
|
||||||
>
|
|
||||||
<Ionicons name='bookmarks' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center ml-4'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
import { ICON_SIZES } from "./constants";
|
import { ICON_SIZES } from "./constants";
|
||||||
|
|
||||||
// SyncPlay cyan color (matches Jellyfin-web)
|
|
||||||
const SYNC_PLAY_COLOR = "#00a4dc";
|
|
||||||
|
|
||||||
interface CenterControlsProps {
|
interface CenterControlsProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -47,27 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
|
|
||||||
// SyncPlay state from global provider
|
|
||||||
const {
|
|
||||||
isEnabled: isSyncPlayEnabled,
|
|
||||||
groupInfo,
|
|
||||||
pendingPlaybackCommand,
|
|
||||||
} = useSyncPlay();
|
|
||||||
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
|
|
||||||
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
|
|
||||||
// play/pause request is in flight to the server.
|
|
||||||
const isSyncPlayScheduling =
|
|
||||||
isSyncPlayEnabled && pendingPlaybackCommand !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
right: insets.right,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -138,17 +121,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
|
|
||||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
{isSyncPlayScheduling ? (
|
{!isBuffering ? (
|
||||||
// SyncPlay command in flight - rotating spinner ("schedule-play")
|
|
||||||
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
|
|
||||||
) : isSyncPlayWaiting ? (
|
|
||||||
// SyncPlay waiting indicator - clock icon, still pressable to toggle
|
|
||||||
<Ionicons
|
|
||||||
name='time'
|
|
||||||
size={ICON_SIZES.CENTER}
|
|
||||||
color={SYNC_PLAY_COLOR}
|
|
||||||
/>
|
|
||||||
) : !isBuffering ? (
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={ICON_SIZES.CENTER}
|
size={ICON_SIZES.CENTER}
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
|
|||||||
hasNextChapter,
|
hasNextChapter,
|
||||||
goToPreviousChapter,
|
goToPreviousChapter,
|
||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
chapterPositions,
|
|
||||||
} = useChapterNavigation({
|
} = useChapterNavigation({
|
||||||
chapters: item.Chapters,
|
chapters: item.Chapters,
|
||||||
progress,
|
progress,
|
||||||
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
|
|||||||
{ applyLanguagePreferences: true },
|
{ applyLanguagePreferences: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||||
|
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||||
|
router.setParams({
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue?.toString(),
|
bitrateValue: bitrateValue?.toString(),
|
||||||
playbackPosition:
|
playbackPosition:
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
});
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
},
|
},
|
||||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
[
|
||||||
|
settings,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
offline,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
chapterPositions={chapterPositions}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -17,10 +16,10 @@ import {
|
|||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getDownloadedEpisodesForSeason,
|
getDownloadedEpisodesForSeason,
|
||||||
getDownloadedSeasonNumbers,
|
getDownloadedSeasonNumbers,
|
||||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Read the live (cached) downloads DB inside the query rather than the
|
||||||
|
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||||
|
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||||
|
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||||
|
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop:
|
paddingTop: insets.top,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
paddingLeft: insets.left,
|
||||||
paddingLeft:
|
paddingRight: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
|
||||||
paddingRight:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const { orientation, lockOrientation } = useOrientation();
|
const { orientation, lockOrientation } = useOrientation();
|
||||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
top: insets.top,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
|
|
||||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { settings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
left: Math.max(insets.left, 48) + 20,
|
left: Math.max(insets.left, 48) + 20,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
top:
|
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
|
||||||
left:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV
|
||||||
|
|||||||
26
eas.json
26
eas.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 9.1.0",
|
"version": ">= 16.0.0",
|
||||||
"appVersionSource": "remote"
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -52,39 +52,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production.yml"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production-apk.yml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production-tv.yml"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"credentialsSource": "local"
|
"credentialsSource": "local",
|
||||||
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,6 +104,11 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"appleTeamId": "MWD5K362T8",
|
"appleTeamId": "MWD5K362T8",
|
||||||
"ascAppId": "6593660679"
|
"ascAppId": "6593660679"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "completed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
|
|||||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
18
hooks/useControlsSafeAreaInsets.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||||
|
* resolves to an empty payload under RN's New Architecture.
|
||||||
|
*/
|
||||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
let blob: Blob;
|
const tmpFile = new File(
|
||||||
|
Paths.cache,
|
||||||
|
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Fetch the data from the URL
|
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||||
const response = await fetch(url);
|
idempotent: true,
|
||||||
blob = await response.blob();
|
});
|
||||||
|
return await downloaded.base64();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Error fetching image:", error);
|
console.warn("Error fetching image:", error);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (tmpFile.exists) tmpFile.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a FileReader instance
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
// Convert blob to base64
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
reader.onloadend = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
// Extract the base64 string (remove the data URL prefix)
|
|
||||||
const base64 = reader.result.split(",")[1];
|
|
||||||
resolve(base64);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to convert image to base64"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveImage = useCallback(
|
const saveImage = useCallback(
|
||||||
|
|||||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive prev/next from the current item's real position in the adjacent
|
||||||
|
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||||
|
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||||
|
* episode it can still return the current item as the first/last entry — so
|
||||||
|
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||||
|
*/
|
||||||
|
const currentIndex = useMemo(
|
||||||
|
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||||
|
[adjacentItems, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A neighbour is only navigable if it has an actual media file (not a
|
||||||
|
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||||
|
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||||
|
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||||
|
|
||||||
const previousItem = useMemo(() => {
|
const previousItem = useMemo(() => {
|
||||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
if (!adjacentItems || currentIndex <= 0) return null;
|
||||||
return null;
|
const candidate = adjacentItems[currentIndex - 1];
|
||||||
}
|
return isNavigable(candidate) ? candidate : null;
|
||||||
|
}, [adjacentItems, currentIndex, item]);
|
||||||
if (adjacentItems.length === 2) {
|
|
||||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacentItems[0];
|
|
||||||
}, [adjacentItems, item]);
|
|
||||||
|
|
||||||
/** The next item in the series */
|
/** The next item in the series */
|
||||||
const nextItem = useMemo(() => {
|
const nextItem = useMemo(() => {
|
||||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
if (!adjacentItems || currentIndex < 0) return null;
|
||||||
return null;
|
const candidate = adjacentItems[currentIndex + 1];
|
||||||
}
|
return isNavigable(candidate) ? candidate : null;
|
||||||
|
}, [adjacentItems, currentIndex, item]);
|
||||||
if (adjacentItems.length === 2) {
|
|
||||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacentItems[2];
|
|
||||||
}, [adjacentItems, item]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports playback progress.
|
* Reports playback progress.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
|
||||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
interface UseWebSocketProps {
|
interface UseWebSocketProps {
|
||||||
@@ -81,9 +80,9 @@ export const useWebSocket = ({
|
|||||||
playTrailers,
|
playTrailers,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
const { lastMessage } = useWebSocketContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
const { clearLastMessage } = useWebSocketContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
if (!lastMessage) return;
|
||||||
@@ -97,25 +96,6 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
|
|
||||||
const isSyncPlayCommand =
|
|
||||||
lastMessage.MessageType === "SyncPlayCommand" ||
|
|
||||||
lastMessage.MessageType === "SyncPlayGroupUpdate";
|
|
||||||
const isPlaybackCommand = [
|
|
||||||
"PlayPause",
|
|
||||||
"Pause",
|
|
||||||
"Unpause",
|
|
||||||
"Stop",
|
|
||||||
"Seek",
|
|
||||||
"NextTrack",
|
|
||||||
"PreviousTrack",
|
|
||||||
].includes(command ?? "");
|
|
||||||
|
|
||||||
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
|
|
||||||
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
|||||||
@@ -1,46 +1,20 @@
|
|||||||
plugins {
|
apply plugin: 'expo-module-gradle-plugin'
|
||||||
id 'com.android.library'
|
|
||||||
id 'kotlin-android'
|
|
||||||
}
|
|
||||||
|
|
||||||
group = 'expo.modules.backgrounddownloader'
|
group = 'expo.modules.backgrounddownloader'
|
||||||
version = '1.0.0'
|
version = '1.0.0'
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
expoModule {
|
||||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
canBePublished false
|
||||||
|
}
|
||||||
apply from: expoModulesCorePlugin
|
|
||||||
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
|
||||||
useDefaultAndroidSdkVersions()
|
|
||||||
useCoreDependencies()
|
|
||||||
useExpoPublishing()
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "expo.modules.backgrounddownloader"
|
namespace "expo.modules.backgrounddownloader"
|
||||||
|
defaultConfig {
|
||||||
compileOptions {
|
versionCode 1
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
versionName "1.0.0"
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,13 +76,11 @@ class MpvPlayerView: ExpoView {
|
|||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
setupView()
|
setupView()
|
||||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
private func setupView() {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
configureAudioSession()
|
|
||||||
|
|
||||||
videoContainer = UIView()
|
videoContainer = UIView()
|
||||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -142,21 +140,26 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try audioSession.setCategory(
|
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||||
.playback,
|
try session.setActive(true)
|
||||||
mode: .moviePlayback,
|
|
||||||
policy: .longFormAudio,
|
|
||||||
options: []
|
|
||||||
)
|
|
||||||
try audioSession.setActive(true)
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to configure audio session: \(error)")
|
print("Failed to configure audio session: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Audio Session & Notifications
|
|
||||||
|
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||||
|
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||||
|
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||||
|
private func tearDownAudioSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||||
|
}
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
@@ -271,6 +274,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
|
configureAudioSession()
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -441,6 +445,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
|
tearDownAudioSession()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,9 +525,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
|
||||||
nowPlayingManager.activateAudioSession()
|
|
||||||
syncNowPlaying(isPlaying: !isPaused())
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||||
|
|
||||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||||
const url = props.source?.url ?? "";
|
const url = props.source?.url ?? "";
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<iframe
|
<iframe
|
||||||
title='MPV Player'
|
title={t("player.mpv_player_title")}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
src={url}
|
src={url}
|
||||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
|
"expo-clipboard": "~56.0.3",
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -124,8 +125,10 @@
|
|||||||
"@biomejs/biome": "2.4.16",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
|
"@types/node": "^24",
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ const { withEntitlementsPlist } = require("expo/config-plugins");
|
|||||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||||
*/
|
*/
|
||||||
const withTVUserManagement = (config) => {
|
const withTVUserManagement = (config) => {
|
||||||
|
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||||
|
// be present in the provisioning profile, so injecting it into mobile builds
|
||||||
|
// breaks signing ("Entitlement ... not found and could not be included in
|
||||||
|
// profile"). The entitlement is only needed for tvOS
|
||||||
|
// TVUserManager.currentUserIdentifier.
|
||||||
|
if (process.env.EXPO_TV !== "1") {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
return withEntitlementsPlist(config, (config) => {
|
return withEntitlementsPlist(config, (config) => {
|
||||||
// Only add for tvOS builds (check if building for TV)
|
|
||||||
// The entitlement is needed for TVUserManager.currentUserIdentifier to work
|
|
||||||
config.modResults["com.apple.developer.user-management"] = [
|
config.modResults["com.apple.developer.user-management"] = [
|
||||||
"runs-as-current-user",
|
"runs-as-current-user",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
|||||||
|
|
||||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||||
|
|
||||||
|
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||||
|
let cachedDb: DownloadsDatabase | null = null;
|
||||||
|
let cacheVersion = 0;
|
||||||
|
|
||||||
|
// Performance optimization: Cache the flattened items array
|
||||||
|
let cachedItems: DownloadedItem[] | null = null;
|
||||||
|
let itemsCacheVersion = -1;
|
||||||
|
|
||||||
|
// Performance optimization: Index for O(1) item lookups by ID
|
||||||
|
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||||
|
let indexCacheVersion = -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the downloads database from storage
|
* Get the downloads database from storage
|
||||||
|
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||||
|
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||||
|
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||||
|
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||||
*/
|
*/
|
||||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||||
|
// Return cached database if available
|
||||||
|
if (cachedDb !== null) {
|
||||||
|
return cachedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse from storage and cache the result
|
||||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||||
if (file) {
|
if (file) {
|
||||||
return JSON.parse(file) as DownloadsDatabase;
|
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||||
|
return cachedDb;
|
||||||
}
|
}
|
||||||
return { movies: {}, series: {}, other: {} };
|
|
||||||
|
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||||
|
cachedDb = emptyDb;
|
||||||
|
return emptyDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the downloads database to storage
|
* Save the downloads database to storage
|
||||||
|
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||||
*/
|
*/
|
||||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||||
|
// Update the cache with the new database
|
||||||
|
cachedDb = db;
|
||||||
|
// Invalidate derived caches (items array and index)
|
||||||
|
cachedItems = null;
|
||||||
|
itemIndex = null;
|
||||||
|
cacheVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all downloaded items as a flat array
|
* Get all downloaded items as a flat array
|
||||||
|
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||||
*/
|
*/
|
||||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||||
|
// Return cached items if available and up-to-date
|
||||||
|
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||||
|
return cachedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the items array from the database
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
const items: DownloadedItem[] = [];
|
const items: DownloadedItem[] = [];
|
||||||
|
|
||||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cachedItems = items;
|
||||||
|
itemsCacheVersion = cacheVersion;
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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 {
|
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||||
const db = getDownloadsDatabase();
|
ensureItemIndex();
|
||||||
|
return itemIndex!.get(id);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
|||||||
*/
|
*/
|
||||||
export function clearAllDownloadedItems(): void {
|
export function clearAllDownloadedItems(): void {
|
||||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||||
|
// saveDownloadsDatabase already invalidates caches
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type * as NotificationsType from "expo-notifications";
|
import type * as NotificationsType from "expo-notifications";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
// Conditionally import expo-notifications only on non-TV platforms
|
// Conditionally import expo-notifications only on non-TV platforms
|
||||||
const Notifications = Platform.isTV
|
const Notifications = Platform.isTV
|
||||||
@@ -67,6 +68,14 @@ export async function sendDownloadNotification(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Platform.isTV || !Notifications) return;
|
if (Platform.isTV || !Notifications) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = storage.getString("settings");
|
||||||
|
const s = raw ? JSON.parse(raw) : {};
|
||||||
|
if (s.notificationsEnabled === false || s.notifyDownloads === false) return;
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors; fall through to sending (defaults are enabled)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ const initialApi = (() => {
|
|||||||
|
|
||||||
const initialUser = (() => {
|
const initialUser = (() => {
|
||||||
try {
|
try {
|
||||||
|
// Only return a stored user if we also have a token. Otherwise the
|
||||||
|
// user atom would be populated while the api atom is null (e.g. after
|
||||||
|
// a logout that left stale user JSON in storage), which causes
|
||||||
|
// useProtectedRoute to keep us inside the (auth) group instead of
|
||||||
|
// redirecting to /login.
|
||||||
|
const token = storage.getString("token");
|
||||||
|
if (!token) return null;
|
||||||
const userStr = storage.getString("user");
|
const userStr = storage.getString("user");
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
return JSON.parse(userStr) as UserDto;
|
return JSON.parse(userStr) as UserDto;
|
||||||
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
clearTVDiscoverySafely();
|
clearTVDiscoverySafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "خطأ",
|
"error_title": "خطأ",
|
||||||
"login_title": "تسجيل الدخول",
|
"login_title": "تسجيل الدخول",
|
||||||
"login_to_title": "تسجيل الدخول إلى",
|
"login_to_title": "تسجيل الدخول إلى",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "اسم المستخدم",
|
"username_placeholder": "اسم المستخدم",
|
||||||
"password_placeholder": "كلمة المرور",
|
"password_placeholder": "كلمة المرور",
|
||||||
"login_button": "تسجيل الدخول",
|
"login_button": "تسجيل الدخول",
|
||||||
@@ -30,48 +33,54 @@
|
|||||||
"connect_button": "اتصل",
|
"connect_button": "اتصل",
|
||||||
"previous_servers": "الخوادم السابقة",
|
"previous_servers": "الخوادم السابقة",
|
||||||
"clear_button": "مسح",
|
"clear_button": "مسح",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "مرر للإزالة",
|
||||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
||||||
"searching": "جاري البحث...",
|
"searching": "جاري البحث...",
|
||||||
"servers": "الخوادم",
|
"servers": "الخوادم",
|
||||||
"saved": "Saved",
|
"saved": "تم الحفظ",
|
||||||
"session_expired": "Session Expired",
|
"session_expired": "انتهت الجلسة",
|
||||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
|
||||||
"remove_saved_login": "Remove Saved Login",
|
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
|
||||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
|
||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "الحسابات {{count}}",
|
||||||
"select_account": "Select Account",
|
"select_account": "اختر الحساب",
|
||||||
"add_account": "Add Account",
|
"add_account": "إضافة حساب",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "حفظ الحساب",
|
||||||
"save_for_later": "Save this account",
|
"save_for_later": "حفظ هذا الحساب",
|
||||||
"security_option": "Security Option",
|
"security_option": "خيارات الأمان",
|
||||||
"no_protection": "No protection",
|
"no_protection": "بدون حماية",
|
||||||
"no_protection_desc": "Quick login without authentication",
|
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
|
||||||
"pin_code": "PIN code",
|
"pin_code": "رمز PIN",
|
||||||
"pin_code_desc": "4-digit PIN required when switching",
|
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
|
||||||
"password": "Re-enter password",
|
"password": "أعد إدخال كلمة المرور",
|
||||||
"password_desc": "Password required when switching",
|
"password_desc": "كلمة المرور مطلوبة عند التبديل",
|
||||||
"save_button": "Save",
|
"save_button": "حفظ",
|
||||||
"cancel_button": "Cancel"
|
"cancel_button": "إلغاء"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"enter_pin": "Enter PIN",
|
"enter_pin": "أدخل رمز PIN",
|
||||||
"enter_pin_for": "Enter PIN for {{username}}",
|
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
|
||||||
"enter_4_digits": "Enter 4 digits",
|
"enter_4_digits": "ادخل 4 أرقام",
|
||||||
"invalid_pin": "Invalid PIN",
|
"invalid_pin": "PIN غير صالح",
|
||||||
"setup_pin": "Set Up PIN",
|
"setup_pin": "تعيين رمز PIN",
|
||||||
"confirm_pin": "Confirm PIN",
|
"confirm_pin": "تأكيد رمز PIN",
|
||||||
"pins_dont_match": "PINs don't match",
|
"pins_dont_match": "رموز PIN غير متطابقة",
|
||||||
"forgot_pin": "Forgot PIN?",
|
"forgot_pin": "نسيت رمز PIN؟",
|
||||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"enter_password": "Enter Password",
|
"enter_password": "أدخل كلمة المرور",
|
||||||
"enter_password_for": "Enter password for {{username}}",
|
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
|
||||||
"invalid_password": "Invalid password"
|
"invalid_password": "كلمة المرور غير صحيحة"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"checking_server_connection": "التحقق من اتصال الخادم...",
|
"checking_server_connection": "التحقق من اتصال الخادم...",
|
||||||
@@ -86,8 +95,9 @@
|
|||||||
"oops": "عفوًا!",
|
"oops": "عفوًا!",
|
||||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "تابع و التالي",
|
||||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||||
"suggested_movies": "أفلام مقترحة",
|
"suggested_movies": "أفلام مقترحة",
|
||||||
"suggested_episodes": "حلقات مقترحة",
|
"suggested_episodes": "حلقات مقترحة",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "الإعدادات",
|
"settings_title": "الإعدادات",
|
||||||
"log_out_button": "تسجيل الخروج",
|
"log_out_button": "تسجيل الخروج",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "الأقسام"
|
"title": "الأقسام"
|
||||||
},
|
},
|
||||||
@@ -120,36 +136,45 @@
|
|||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "المظهر",
|
"title": "المظهر",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "الشبكة",
|
||||||
"local_network": "Local Network",
|
"local_network": "الشبكة المحلية",
|
||||||
"auto_switch_enabled": "Auto-switch when at home",
|
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
|
||||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
|
||||||
"local_url": "Local URL",
|
"local_url": "رابط محلي",
|
||||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
"home_wifi_networks": "Home WiFi Networks",
|
"home_wifi_networks": "شبكات WiFi المنزل",
|
||||||
"add_current_network": "Add \"{{ssid}}\"",
|
"add_current_network": "إضافة \"{{ssid}}\"",
|
||||||
"not_connected_to_wifi": "Not connected to WiFi",
|
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
|
||||||
"no_networks_configured": "No networks configured",
|
"no_networks_configured": "لا توجد شبكات مكونة",
|
||||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
|
||||||
"current_wifi": "Current WiFi",
|
"current_wifi": "شبكة WiFi الحالية",
|
||||||
"using_url": "Using",
|
"using_url": "استخدام",
|
||||||
"local": "Local URL",
|
"local": "رابط محلي",
|
||||||
"remote": "Remote URL",
|
"remote": "الـ URL الخارجي",
|
||||||
"not_connected": "Not connected",
|
"not_connected": "غير متصل",
|
||||||
"current_server": "Current Server",
|
"current_server": "الخادم الحالي",
|
||||||
"remote_url": "Remote URL",
|
"remote_url": "الـ URL الخارجي",
|
||||||
"active_url": "Active URL",
|
"active_url": "الرابط النشط",
|
||||||
"not_configured": "Not configured",
|
"not_configured": "لم يتم تكوينه",
|
||||||
"network_added": "Network added",
|
"network_added": "تمت إضافة الشبكة",
|
||||||
"network_already_added": "Network already added",
|
"network_already_added": "الشبكة مضافة مسبقاً",
|
||||||
"no_wifi_connected": "Not connected to WiFi",
|
"no_wifi_connected": "غير متصل بشبكة WiFi",
|
||||||
"permission_denied": "Location permission denied",
|
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
|
||||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
|
||||||
},
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "معلومات المستخدم",
|
"user_info_title": "معلومات المستخدم",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "مدة الترجيع",
|
"rewind_length": "مدة الترجيع",
|
||||||
"seconds_unit": "ث"
|
"seconds_unit": "ث"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "التحكم بالإيماءات",
|
"gesture_controls_title": "التحكم بالإيماءات",
|
||||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
||||||
@@ -182,10 +223,10 @@
|
|||||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
||||||
"hide_volume_slider": "Hide Volume Slider",
|
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
|
||||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
|
||||||
"hide_brightness_slider": "Hide Brightness Slider",
|
"hide_brightness_slider": "إخفاء شريط السطوع",
|
||||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "الصوت",
|
"audio_title": "الصوت",
|
||||||
@@ -195,12 +236,12 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"language": "اللغة",
|
"language": "اللغة",
|
||||||
"transcode_mode": {
|
"transcode_mode": {
|
||||||
"title": "Audio Transcoding",
|
"title": "تحويل ترميز الصوت",
|
||||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
|
||||||
"auto": "Auto",
|
"auto": "تلقائي",
|
||||||
"stereo": "Force Stereo",
|
"stereo": "إجبار تشغيل ستيريو",
|
||||||
"5_1": "Allow 5.1",
|
"5_1": "السماح بـ 5.1",
|
||||||
"passthrough": "Passthrough"
|
"passthrough": "تمرير الصوت"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
@@ -251,29 +292,45 @@
|
|||||||
"Normal": "عادي",
|
"Normal": "عادي",
|
||||||
"Thick": "سميك"
|
"Thick": "سميك"
|
||||||
},
|
},
|
||||||
"subtitle_color": "Subtitle Color",
|
"subtitle_color": "لون الترجمة",
|
||||||
"subtitle_background_color": "Background Color",
|
"subtitle_background_color": "لون الخلفية",
|
||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "خط الترجمة",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "إعدادات KSPlayer",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "إعدادات ترجمة VLC",
|
||||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
||||||
"text_color": "Text Color",
|
"text_color": "لون النص",
|
||||||
"background_color": "Background Color",
|
"background_color": "لون الخلفية",
|
||||||
"background_opacity": "Background Opacity",
|
"background_opacity": "شفافية الخلفية",
|
||||||
"outline_color": "Outline Color",
|
"outline_color": "لون إطار الخط",
|
||||||
"outline_opacity": "Outline Opacity",
|
"outline_opacity": "شفافية إطار الخط",
|
||||||
"outline_thickness": "Outline Thickness",
|
"outline_thickness": "سمك إطار الخط",
|
||||||
"bold": "Bold Text",
|
"bold": "خط عريض",
|
||||||
"margin": "Bottom Margin"
|
"margin": "الهامش السفلي"
|
||||||
},
|
},
|
||||||
"video_player": {
|
"video_player": {
|
||||||
"title": "Video Player",
|
"title": "مشغل الفيديو",
|
||||||
"video_player": "Video Player",
|
"video_player": "مشغل الفيديو",
|
||||||
"video_player_description": "Choose which video player to use on iOS.",
|
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
||||||
"ksplayer": "KSPlayer",
|
"ksplayer": "KSPlayer",
|
||||||
"vlc": "VLC"
|
"vlc": "VLC"
|
||||||
},
|
},
|
||||||
@@ -305,8 +362,8 @@
|
|||||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||||
"default_quality": "الجودة الافتراضية",
|
"default_quality": "الجودة الافتراضية",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
|
||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||||
"disabled": "معطل"
|
"disabled": "معطل"
|
||||||
},
|
},
|
||||||
@@ -314,15 +371,15 @@
|
|||||||
"downloads_title": "التنزيلات"
|
"downloads_title": "التنزيلات"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "الموسيقى",
|
||||||
"playback_title": "Playback",
|
"playback_title": "التشغيل",
|
||||||
"playback_description": "Configure how music is played.",
|
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
|
||||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
|
||||||
"caching_title": "Caching",
|
"caching_title": "التخزين المؤقت",
|
||||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
|
||||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
|
||||||
"lookahead_count": "Tracks to Pre-cache",
|
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
|
||||||
"max_cache_size": "Max Cache Size"
|
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "الإضافات",
|
"plugins_title": "الإضافات",
|
||||||
@@ -357,39 +414,39 @@
|
|||||||
"save_button": "حفظ",
|
"save_button": "حفظ",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"refreshed": "Settings refreshed from server"
|
"refreshed": "تم تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
"enable_streamystats": "تفعيل Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "تعطيل Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "استخدم للبحث",
|
||||||
"url": "URL",
|
"url": "الرابط",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
||||||
"save_button": "Save",
|
"save_button": "حفظ",
|
||||||
"save": "Save",
|
"save": "حفظ",
|
||||||
"features_title": "Features",
|
"features_title": "المميزات",
|
||||||
"home_sections_title": "Home Sections",
|
"home_sections_title": "أقسام الرئيسية",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "توصيات الأفلام",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "توصيات المسلسلات",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
||||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
|
||||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
|
||||||
"recommended_movies": "Recommended Movies",
|
"recommended_movies": "أفلام موصى بها",
|
||||||
"recommended_series": "Recommended Series",
|
"recommended_series": "مسلسلات موصى بها",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved",
|
"saved": "تم الحفظ",
|
||||||
"refreshed": "Settings refreshed from server",
|
"refreshed": "تم تحديث الإعدادات من الخادم",
|
||||||
"disabled": "Streamystats disabled"
|
"disabled": "تم تعطيل Streamystats"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -398,15 +455,21 @@
|
|||||||
"device_usage": "الجهاز {{availableSpace}}%",
|
"device_usage": "الجهاز {{availableSpace}}%",
|
||||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||||
"enable_music_cache": "Enable Music Cache",
|
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "تم تنزيل {{size}}",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "المقدمة",
|
"title": "المقدمة",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "خطأ في حذف الملفات",
|
"error_deleting_files": "خطأ في حذف الملفات",
|
||||||
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
||||||
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
||||||
"back": "رجوع",
|
"back": "رجوع",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "حدث خطأ ما",
|
"something_went_wrong": "حدث خطأ ما",
|
||||||
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
||||||
"eta": "الوقت المتبقي {{eta}}",
|
"eta": "الوقت المتبقي {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "اختر",
|
"select": "اختر",
|
||||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
"subtitle": "الترجمة",
|
"subtitle": "الترجمة",
|
||||||
"play": "تشغيل",
|
"play": "تشغيل",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"track": "Track",
|
"track": "أغنية",
|
||||||
"cancel": "Cancel",
|
"cancel": "إلغاء",
|
||||||
"delete": "Delete",
|
"stop": "Stop",
|
||||||
"ok": "OK",
|
"delete": "حذف",
|
||||||
"remove": "Remove",
|
"ok": "حسناً",
|
||||||
"next": "Next",
|
"remove": "إزالة",
|
||||||
"back": "Back",
|
"next": "التالي",
|
||||||
"continue": "Continue",
|
"back": "رجوع",
|
||||||
"verifying": "Verifying..."
|
"continue": "متابعة",
|
||||||
|
"verifying": "جارٍ التحقق...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "بحث...",
|
"search": "بحث...",
|
||||||
@@ -521,10 +606,10 @@
|
|||||||
"episodes": "حلقات",
|
"episodes": "حلقات",
|
||||||
"collections": "مجموعات",
|
"collections": "مجموعات",
|
||||||
"actors": "ممثلون",
|
"actors": "ممثلون",
|
||||||
"artists": "Artists",
|
"artists": "الفنانون",
|
||||||
"albums": "Albums",
|
"albums": "الألبومات",
|
||||||
"songs": "Songs",
|
"songs": "الأغاني",
|
||||||
"playlists": "Playlists",
|
"playlists": "قوائم التشغيل",
|
||||||
"request_movies": "طلب أفلام",
|
"request_movies": "طلب أفلام",
|
||||||
"request_series": "طلب مسلسلات",
|
"request_series": "طلب مسلسلات",
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"series": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"boxsets": "مجموعات",
|
"boxsets": "مجموعات",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "عناصر"
|
"items": "عناصر"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,15 +652,20 @@
|
|||||||
"poster": "ملصق",
|
"poster": "ملصق",
|
||||||
"cover": "غلاف",
|
"cover": "غلاف",
|
||||||
"show_titles": "إظهار العناوين",
|
"show_titles": "إظهار العناوين",
|
||||||
"show_stats": "إظهار الإحصائيات"
|
"show_stats": "إظهار الإحصائيات",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "الأنواع",
|
"genres": "الأنواع",
|
||||||
"years": "السنوات",
|
"years": "السنوات",
|
||||||
"sort_by": "ترتيب حسب",
|
"sort_by": "ترتيب حسب",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "تصفية حسب",
|
||||||
"sort_order": "اتجاه الترتيب",
|
"sort_order": "اتجاه الترتيب",
|
||||||
"tags": "الوسوم"
|
"tags": "الوسوم",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "لا توجد روابط"
|
"no_links": "لا توجد روابط"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "خطأ",
|
"error": "خطأ",
|
||||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||||
@@ -604,11 +697,39 @@
|
|||||||
"index": "الفِهْرِس:",
|
"index": "الفِهْرِس:",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"go_back": "رجوع",
|
"go_back": "رجوع",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||||
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
||||||
"downloaded_file_yes": "نعم",
|
"downloaded_file_yes": "نعم",
|
||||||
"downloaded_file_no": "لا",
|
"downloaded_file_no": "لا",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "إلغاء",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"seasons": "مواسم",
|
"seasons": "مواسم",
|
||||||
"season": "موسم",
|
"season": "موسم",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
||||||
"overview": "نظرة عامة",
|
"overview": "نظرة عامة",
|
||||||
"more_with": "المزيد مع {{name}}",
|
"more_with": "المزيد مع {{name}}",
|
||||||
@@ -624,13 +750,24 @@
|
|||||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"more_details": "المزيد من التفاصيل",
|
"more_details": "المزيد من التفاصيل",
|
||||||
"media_options": "Media Options",
|
"media_options": "خيارات الوسائط",
|
||||||
"quality": "الجودة",
|
"quality": "الجودة",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
"subtitles": "الترجمة",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "عرض المزيد",
|
"show_more": "عرض المزيد",
|
||||||
"show_less": "عرض أقل",
|
"show_less": "عرض أقل",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "ظهر في",
|
"appeared_in": "ظهر في",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "تعذر تحميل العنصر",
|
"could_not_load_item": "تعذر تحميل العنصر",
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "تنزيل {{item_count}} عناصر",
|
"download_x_item": "تنزيل {{item_count}} عناصر",
|
||||||
"download_unwatched_only": "غير المشاهدة فقط",
|
"download_unwatched_only": "غير المشاهدة فقط",
|
||||||
"download_button": "تنزيل"
|
"download_button": "تنزيل"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"sports": "رياضة",
|
"sports": "رياضة",
|
||||||
"for_kids": "للأطفال",
|
"for_kids": "للأطفال",
|
||||||
"news": "أخبار"
|
"news": "أخبار",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "تأكيد",
|
"confirm": "تأكيد",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "رفض",
|
"decline": "رفض",
|
||||||
"requested_by": "مطلوب من {{user}}",
|
"requested_by": "مطلوب من {{user}}",
|
||||||
"unknown_user": "مستخدم غير معروف",
|
"unknown_user": "مستخدم غير معروف",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
|
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
|
||||||
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
|
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
|
||||||
@@ -716,130 +876,162 @@
|
|||||||
"search": "بحث",
|
"search": "بحث",
|
||||||
"library": "المكتبة",
|
"library": "المكتبة",
|
||||||
"custom_links": "روابط مخصصة",
|
"custom_links": "روابط مخصصة",
|
||||||
"favorites": "المفضلة"
|
"favorites": "المفضلة",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "الموسيقى",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "الإقتراحات",
|
||||||
"albums": "Albums",
|
"albums": "الألبومات",
|
||||||
"artists": "Artists",
|
"artists": "الفنانون",
|
||||||
"playlists": "Playlists",
|
"playlists": "قوائم التشغيل",
|
||||||
"tracks": "tracks"
|
"tracks": "الأغاني"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All"
|
"all": "الكل"
|
||||||
},
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "تم تشغيله مؤخرًا",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "الأكثر تشغيلاً",
|
||||||
"explore": "Explore",
|
"explore": "اكتشف",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "أفضل الأغاني",
|
||||||
"play": "Play",
|
"play": "تشغيل",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "ترتيب عشوائي",
|
||||||
"play_top_tracks": "Play Top Tracks",
|
"play_top_tracks": "تشغيل أفضل الأغاني",
|
||||||
"no_suggestions": "No suggestions available",
|
"no_suggestions": "لا توجد مقترحات متاحة",
|
||||||
"no_albums": "No albums found",
|
"no_albums": "لا توجد ألبومات",
|
||||||
"no_artists": "No artists found",
|
"no_artists": "لا يوجد فنانون",
|
||||||
"no_playlists": "No playlists found",
|
"no_playlists": "لا توجد قوائم تشغيل",
|
||||||
"album_not_found": "Album not found",
|
"album_not_found": "الألبوم غير موجود",
|
||||||
"artist_not_found": "Artist not found",
|
"artist_not_found": "الفنان غير موجود",
|
||||||
"playlist_not_found": "Playlist not found",
|
"playlist_not_found": "قائمة التشغيل غير موجودة",
|
||||||
"track_options": {
|
"track_options": {
|
||||||
"play_next": "Play Next",
|
"play_next": "تشغيل التالي",
|
||||||
"add_to_queue": "Add to Queue",
|
"add_to_queue": "إضافة إلى قائمة الانتظار",
|
||||||
"add_to_playlist": "Add to Playlist",
|
"add_to_playlist": "أضف إلى قائمة التشغيل",
|
||||||
"download": "Download",
|
"download": "تنزيل",
|
||||||
"downloaded": "Downloaded",
|
"downloaded": "تم التنزيل",
|
||||||
"downloading": "Downloading...",
|
"downloading": "جارٍ التنزيل...",
|
||||||
"cached": "Cached",
|
"cached": "تم التخزين مؤقتاً",
|
||||||
"delete_download": "Delete Download",
|
"delete_download": "حذف ملف التنزيل",
|
||||||
"delete_cache": "Remove from Cache",
|
"delete_cache": "إزالة من التخزين المؤقت",
|
||||||
"go_to_artist": "Go to Artist",
|
"go_to_artist": "انتقال إلى الفنان",
|
||||||
"go_to_album": "Go to Album",
|
"go_to_album": "انتقال إلى الألبوم",
|
||||||
"add_to_favorites": "Add to Favorites",
|
"add_to_favorites": "إضافة إلى المفضلة",
|
||||||
"remove_from_favorites": "Remove from Favorites",
|
"remove_from_favorites": "إزالة من المفضلة",
|
||||||
"remove_from_playlist": "Remove from Playlist"
|
"remove_from_playlist": "إزالة من قائمة التشغيل"
|
||||||
},
|
},
|
||||||
"playlists": {
|
"playlists": {
|
||||||
"create_playlist": "Create Playlist",
|
"create_playlist": "إنشاء قائمة التشغيل",
|
||||||
"playlist_name": "Playlist Name",
|
"playlist_name": "اسم قائمة التشغيل",
|
||||||
"enter_name": "Enter playlist name",
|
"enter_name": "أدخل اسم قائمة التشغيل",
|
||||||
"create": "Create",
|
"create": "إنشاء",
|
||||||
"search_playlists": "Search playlists...",
|
"search_playlists": "البحث عن قوائم التشغيل...",
|
||||||
"added_to": "Added to {{name}}",
|
"added_to": "تمت الإضافة إلى {{name}}",
|
||||||
"added": "Added to playlist",
|
"added": "تمت الإضافة إلى قائمة التشغيل",
|
||||||
"removed_from": "Removed from {{name}}",
|
"removed_from": "تمت الإزالة من {{name}}",
|
||||||
"removed": "Removed from playlist",
|
"removed": "تمت الازالة من قائمة التشغيل",
|
||||||
"created": "Playlist created",
|
"created": "تم إنشاء قائمة التشغيل",
|
||||||
"create_new": "Create New Playlist",
|
"create_new": "إنشاء قائمة تشغيل جديدة",
|
||||||
"failed_to_add": "Failed to add to playlist",
|
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
|
||||||
"failed_to_remove": "Failed to remove from playlist",
|
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
|
||||||
"failed_to_create": "Failed to create playlist",
|
"failed_to_create": "فشل إنشاء قائمة التشغيل",
|
||||||
"delete_playlist": "Delete Playlist",
|
"delete_playlist": "حذف قائمة التشغيل",
|
||||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"deleted": "Playlist deleted",
|
"deleted": "تم حذف قائمة التشغيل",
|
||||||
"failed_to_delete": "Failed to delete playlist"
|
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"title": "Sort By",
|
"title": "ترتيب حسب",
|
||||||
"alphabetical": "Alphabetical",
|
"alphabetical": "أبجدي",
|
||||||
"date_created": "Date Created"
|
"date_created": "تاريخ الإنشاء"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Watchlists",
|
"title": "قوائم المشاهدة",
|
||||||
"my_watchlists": "My Watchlists",
|
"my_watchlists": "قوائم المشاهدة الخاصة بي",
|
||||||
"public_watchlists": "Public Watchlists",
|
"public_watchlists": "قوائم مشاهدة عامة",
|
||||||
"create_title": "Create Watchlist",
|
"create_title": "إنشاء قائمة مشاهدة",
|
||||||
"edit_title": "Edit Watchlist",
|
"edit_title": "تعديل قائمة المشاهدة",
|
||||||
"create_button": "Create Watchlist",
|
"create_button": "إنشاء قائمة مشاهدة",
|
||||||
"save_button": "Save Changes",
|
"save_button": "حفظ التغييرات",
|
||||||
"delete_button": "Delete",
|
"delete_button": "حذف",
|
||||||
"remove_button": "Remove",
|
"remove_button": "إزالة",
|
||||||
"cancel_button": "Cancel",
|
"cancel_button": "إلغاء",
|
||||||
"name_label": "Name",
|
"name_label": "الاسم",
|
||||||
"name_placeholder": "Enter watchlist name",
|
"name_placeholder": "أدخل اسم قائمة المشاهدة",
|
||||||
"description_label": "Description",
|
"description_label": "الوصف",
|
||||||
"description_placeholder": "Enter description (optional)",
|
"description_placeholder": "أدخل الوصف (اختياري)",
|
||||||
"is_public_label": "Public Watchlist",
|
"is_public_label": "قائمة مشاهدة عامة",
|
||||||
"is_public_description": "Allow others to view this watchlist",
|
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
|
||||||
"allowed_type_label": "Content Type",
|
"allowed_type_label": "نوع المحتوى",
|
||||||
"sort_order_label": "Default Sort Order",
|
"sort_order_label": "الترتيب الافتراضي",
|
||||||
"empty_title": "No Watchlists",
|
"empty_title": "لا توجد قوائم مشاهدة",
|
||||||
"empty_description": "Create your first watchlist to start organizing your media",
|
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
|
||||||
"empty_watchlist": "This watchlist is empty",
|
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
|
||||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
|
||||||
"not_configured_title": "Streamystats Not Configured",
|
"not_configured_title": "لم يتم ضبط Streamystats",
|
||||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
|
||||||
"go_to_settings": "Go to Settings",
|
"go_to_settings": "الذهاب إلى الإعدادات",
|
||||||
"add_to_watchlist": "Add to Watchlist",
|
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
|
||||||
"remove_from_watchlist": "Remove from Watchlist",
|
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
|
||||||
"select_watchlist": "Select Watchlist",
|
"select_watchlist": "تحديد قائمة المشاهدة",
|
||||||
"create_new": "Create New Watchlist",
|
"create_new": "إنشاء قائمة مشاهدة جديدة",
|
||||||
"item": "item",
|
"item": "عنصر",
|
||||||
"items": "items",
|
"items": "عناصر",
|
||||||
"public": "Public",
|
"public": "عامة",
|
||||||
"private": "Private",
|
"private": "خاصة",
|
||||||
"you": "You",
|
"you": "أنت",
|
||||||
"by_owner": "By another user",
|
"by_owner": "بواسطة مستخدم آخر",
|
||||||
"not_found": "Watchlist not found",
|
"not_found": "قائمة المشاهدة غير موجودة",
|
||||||
"delete_confirm_title": "Delete Watchlist",
|
"delete_confirm_title": "حذف قائمة المشاهدة",
|
||||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"remove_item_title": "Remove from Watchlist",
|
"remove_item_title": "إزالة من قائمة المشاهدة",
|
||||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
|
||||||
"loading": "Loading watchlists...",
|
"loading": "تحميل قوائم المشاهدة...",
|
||||||
"no_compatible_watchlists": "No compatible watchlists",
|
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
|
||||||
"create_one_first": "Create a watchlist that accepts this content type"
|
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
|
||||||
},
|
},
|
||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "Playback Speed",
|
"title": "سرعة التشغيل",
|
||||||
"apply_to": "Apply To",
|
"apply_to": "تطبيق على",
|
||||||
"speed": "Speed",
|
"speed": "السرعة",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "This media only",
|
"media": "الوسائط هذه فقط",
|
||||||
"show": "This show",
|
"show": "هذا المسلسل",
|
||||||
"all": "All media (default)"
|
"all": "جميع الوسائط (الافتراضي)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
"login_title": "Inicia sessió",
|
"login_title": "Inicia sessió",
|
||||||
"login_to_title": "Inicia sessió a",
|
"login_to_title": "Inicia sessió a",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nom d'usuari",
|
"username_placeholder": "Nom d'usuari",
|
||||||
"password_placeholder": "Contrasenya",
|
"password_placeholder": "Contrasenya",
|
||||||
"login_button": "Inicia sessió",
|
"login_button": "Inicia sessió",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Oops!",
|
"oops": "Oops!",
|
||||||
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
|
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
|
||||||
"continue_watching": "Continua veient",
|
"continue_watching": "Continua veient",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "A continuació",
|
"next_up": "A continuació",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Configuració",
|
"settings_title": "Configuració",
|
||||||
"log_out_button": "Tanca sessió",
|
"log_out_button": "Tanca sessió",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Durada del rebobinat",
|
"rewind_length": "Durada del rebobinat",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Gesture Controls",
|
"gesture_controls_title": "Gesture Controls",
|
||||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Error en suprimir fitxers",
|
"error_deleting_files": "Error en suprimir fitxers",
|
||||||
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
||||||
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
|
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
|
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
|
||||||
"back": "Enrere",
|
"back": "Enrere",
|
||||||
"delete": "Suprimeix",
|
"delete": "Suprimeix",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Alguna cosa ha anat malament",
|
"something_went_wrong": "Alguna cosa ha anat malament",
|
||||||
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Vídeo",
|
"video": "Vídeo",
|
||||||
"audio": "Àudio",
|
"audio": "Àudio",
|
||||||
"subtitle": "Subtítols",
|
"subtitle": "Subtítols",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "pel·lícules",
|
"movies": "pel·lícules",
|
||||||
"series": "sèries",
|
"series": "sèries",
|
||||||
"boxsets": "col·leccions",
|
"boxsets": "col·leccions",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "elements"
|
"items": "elements"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Cartell",
|
"poster": "Cartell",
|
||||||
"cover": "Coberta",
|
"cover": "Coberta",
|
||||||
"show_titles": "Mostrar títols",
|
"show_titles": "Mostrar títols",
|
||||||
"show_stats": "Mostrar estadístiques"
|
"show_stats": "Mostrar estadístiques",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Gèneres",
|
"genres": "Gèneres",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Ordenar per",
|
"sort_by": "Ordenar per",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Ordre",
|
"sort_order": "Ordre",
|
||||||
"tags": "Etiquetes"
|
"tags": "Etiquetes",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "No hi ha enllaços"
|
"no_links": "No hi ha enllaços"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
||||||
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "A continuació",
|
"next_up": "A continuació",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Sèries",
|
"series": "Sèries",
|
||||||
"seasons": "Temporades",
|
"seasons": "Temporades",
|
||||||
"season": "Temporada",
|
"season": "Temporada",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
|
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
|
||||||
"overview": "Descripció general",
|
"overview": "Descripció general",
|
||||||
"more_with": "Més amb {{name}}",
|
"more_with": "Més amb {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "Qualitat",
|
"quality": "Qualitat",
|
||||||
"audio": "Àudio",
|
"audio": "Àudio",
|
||||||
"subtitles": "Subtítols",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Mostra més",
|
"show_more": "Mostra més",
|
||||||
"show_less": "Mostra menys",
|
"show_less": "Mostra menys",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Va aparèixer a",
|
"appeared_in": "Va aparèixer a",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "No s'ha pogut carregar l'element",
|
"could_not_load_item": "No s'ha pogut carregar l'element",
|
||||||
"none": "Cap",
|
"none": "Cap",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Descarrega {{item_count}} elements",
|
"download_x_item": "Descarrega {{item_count}} elements",
|
||||||
"download_unwatched_only": "Unwatched Only",
|
"download_unwatched_only": "Unwatched Only",
|
||||||
"download_button": "Descarrega"
|
"download_button": "Descarrega"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Següent",
|
"next": "Següent",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Pel·lícules",
|
"movies": "Pel·lícules",
|
||||||
"sports": "Esports",
|
"sports": "Esports",
|
||||||
"for_kids": "Infantil",
|
"for_kids": "Infantil",
|
||||||
"news": "Notícies"
|
"news": "Notícies",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Confirma",
|
"confirm": "Confirma",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
|
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
|
||||||
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
|
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Cercar",
|
"search": "Cercar",
|
||||||
"library": "Biblioteca",
|
"library": "Biblioteca",
|
||||||
"custom_links": "Enllaços personalitzats",
|
"custom_links": "Enllaços personalitzats",
|
||||||
"favorites": "Preferits"
|
"favorites": "Preferits",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Chyba",
|
"error_title": "Chyba",
|
||||||
"login_title": "Přihlásit se",
|
"login_title": "Přihlásit se",
|
||||||
"login_to_title": "Přihlásit se do",
|
"login_to_title": "Přihlásit se do",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Uživatelské jméno",
|
"username_placeholder": "Uživatelské jméno",
|
||||||
"password_placeholder": "Heslo",
|
"password_placeholder": "Heslo",
|
||||||
"login_button": "Přihlásit se",
|
"login_button": "Přihlásit se",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Jejda!",
|
"oops": "Jejda!",
|
||||||
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
|
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
|
||||||
"continue_watching": "Pokračovat ve sledování",
|
"continue_watching": "Pokračovat ve sledování",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Další nahoru",
|
"next_up": "Další nahoru",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Nedávno přidané v {{libraryName}}",
|
"recently_added_in": "Nedávno přidané v {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Nastavení",
|
"settings_title": "Nastavení",
|
||||||
"log_out_button": "Odhlásit se",
|
"log_out_button": "Odhlásit se",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Délka zpětného větru",
|
"rewind_length": "Délka zpětného větru",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Ovládání gest",
|
"gesture_controls_title": "Ovládání gest",
|
||||||
"horizontal_swipe_skip": "Horizontální přejetím přeskočit",
|
"horizontal_swipe_skip": "Horizontální přejetím přeskočit",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Chyba při mazání souborů",
|
"error_deleting_files": "Chyba při mazání souborů",
|
||||||
"background_downloads_enabled": "Stahování na pozadí povoleno",
|
"background_downloads_enabled": "Stahování na pozadí povoleno",
|
||||||
"background_downloads_disabled": "Stahování na pozadí zakázáno"
|
"background_downloads_disabled": "Stahování na pozadí zakázáno"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Nová aktualizace vyžaduje opětovné stažení obsahu. Odstraňte prosím veškerý stažený obsah a zkuste to znovu.",
|
"new_app_version_requires_re_download_description": "Nová aktualizace vyžaduje opětovné stažení obsahu. Odstraňte prosím veškerý stažený obsah a zkuste to znovu.",
|
||||||
"back": "Zpět",
|
"back": "Zpět",
|
||||||
"delete": "Vymazat",
|
"delete": "Vymazat",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Něco se pokazilo",
|
"something_went_wrong": "Něco se pokazilo",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Vybrat",
|
"select": "Vybrat",
|
||||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Zvuk",
|
"audio": "Zvuk",
|
||||||
"subtitle": "Podtitulek",
|
"subtitle": "Podtitulek",
|
||||||
"play": "Hrát",
|
"play": "Hrát",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Hledat...",
|
"search": "Hledat...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"series": "Série",
|
"series": "Série",
|
||||||
"boxsets": "Sada boxů",
|
"boxsets": "Sada boxů",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Položky"
|
"items": "Položky"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Plakát",
|
"poster": "Plakát",
|
||||||
"cover": "Kryt",
|
"cover": "Kryt",
|
||||||
"show_titles": "Zobrazit názvy",
|
"show_titles": "Zobrazit názvy",
|
||||||
"show_stats": "Zobrazit statistiky"
|
"show_stats": "Zobrazit statistiky",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Seřadit podle",
|
"sort_by": "Seřadit podle",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Řazení",
|
"sort_order": "Řazení",
|
||||||
"tags": "Štítky"
|
"tags": "Štítky",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Žádné odkazy"
|
"no_links": "Žádné odkazy"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Chyba",
|
"error": "Chyba",
|
||||||
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
|
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
|
||||||
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Další nahoru",
|
"next_up": "Další nahoru",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Série",
|
"series": "Série",
|
||||||
"seasons": "Série",
|
"seasons": "Série",
|
||||||
"season": "Sezóna",
|
"season": "Sezóna",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Žádné epizody pro tuto sezónu",
|
"no_episodes_for_this_season": "Žádné epizody pro tuto sezónu",
|
||||||
"overview": "Přehled",
|
"overview": "Přehled",
|
||||||
"more_with": "Více s {{name}}",
|
"more_with": "Více s {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "Kvalita",
|
"quality": "Kvalita",
|
||||||
"audio": "Zvuk",
|
"audio": "Zvuk",
|
||||||
"subtitles": "Podtitulek",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Zobrazit více",
|
"show_more": "Zobrazit více",
|
||||||
"show_less": "Zobrazit méně",
|
"show_less": "Zobrazit méně",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Zobrazeno v",
|
"appeared_in": "Zobrazeno v",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Nelze načíst položku",
|
"could_not_load_item": "Nelze načíst položku",
|
||||||
"none": "Nic",
|
"none": "Nic",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Stáhnout položky {{item_count}}",
|
"download_x_item": "Stáhnout položky {{item_count}}",
|
||||||
"download_unwatched_only": "Pouze nezhlédnuté",
|
"download_unwatched_only": "Pouze nezhlédnuté",
|
||||||
"download_button": "Stáhnout"
|
"download_button": "Stáhnout"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Další",
|
"next": "Další",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"sports": "Sporty",
|
"sports": "Sporty",
|
||||||
"for_kids": "Pro děti",
|
"for_kids": "Pro děti",
|
||||||
"news": "Novinky"
|
"news": "Novinky",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Potvrdit",
|
"confirm": "Potvrdit",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.",
|
"jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Hledat",
|
"search": "Hledat",
|
||||||
"library": "Knihovna",
|
"library": "Knihovna",
|
||||||
"custom_links": "Vlastní odkazy",
|
"custom_links": "Vlastní odkazy",
|
||||||
"favorites": "Oblíbené"
|
"favorites": "Oblíbené",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Fejl",
|
"error_title": "Fejl",
|
||||||
"login_title": "Log ind",
|
"login_title": "Log ind",
|
||||||
"login_to_title": "Log ind på",
|
"login_to_title": "Log ind på",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Brugernavn",
|
"username_placeholder": "Brugernavn",
|
||||||
"password_placeholder": "Adgangskode",
|
"password_placeholder": "Adgangskode",
|
||||||
"login_button": "Log ind",
|
"login_button": "Log ind",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Ups!",
|
"oops": "Ups!",
|
||||||
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
|
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
|
||||||
"continue_watching": "Fortsæt med at se",
|
"continue_watching": "Fortsæt med at se",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Næste",
|
"next_up": "Næste",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Senest tilføjet i {{libraryName}}",
|
"recently_added_in": "Senest tilføjet i {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Indstillinger",
|
"settings_title": "Indstillinger",
|
||||||
"log_out_button": "Log ud",
|
"log_out_button": "Log ud",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Spol tilbage længde",
|
"rewind_length": "Spol tilbage længde",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Bevægelsesstyring",
|
"gesture_controls_title": "Bevægelsesstyring",
|
||||||
"horizontal_swipe_skip": "Vandret Stryg for at springe over",
|
"horizontal_swipe_skip": "Vandret Stryg for at springe over",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Fejl ved sletning af filer",
|
"error_deleting_files": "Fejl ved sletning af filer",
|
||||||
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
||||||
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Den nye opdatering kræver, at indhold downloades igen. Fjern venligst alt downloadet indhold og prøv igen.",
|
"new_app_version_requires_re_download_description": "Den nye opdatering kræver, at indhold downloades igen. Fjern venligst alt downloadet indhold og prøv igen.",
|
||||||
"back": "Tilbage",
|
"back": "Tilbage",
|
||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Noget gik galt",
|
"something_went_wrong": "Noget gik galt",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Vælg",
|
"select": "Vælg",
|
||||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Lyd",
|
"audio": "Lyd",
|
||||||
"subtitle": "Undertekster",
|
"subtitle": "Undertekster",
|
||||||
"play": "Afspil",
|
"play": "Afspil",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Søg...",
|
"search": "Søg...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "film",
|
"movies": "film",
|
||||||
"series": "serier",
|
"series": "serier",
|
||||||
"boxsets": "box sæt",
|
"boxsets": "box sæt",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "elementer"
|
"items": "elementer"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Plakat",
|
"poster": "Plakat",
|
||||||
"cover": "Omslag",
|
"cover": "Omslag",
|
||||||
"show_titles": "Vis titler",
|
"show_titles": "Vis titler",
|
||||||
"show_stats": "Vis statistik"
|
"show_stats": "Vis statistik",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genrer",
|
"genres": "Genrer",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Sortér efter",
|
"sort_by": "Sortér efter",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Sorteringsrækkefølge",
|
"sort_order": "Sorteringsrækkefølge",
|
||||||
"tags": "Mærker"
|
"tags": "Mærker",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Ingen links"
|
"no_links": "Ingen links"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fejl",
|
"error": "Fejl",
|
||||||
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
|
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
|
||||||
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Næste",
|
"next_up": "Næste",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Serier",
|
"series": "Serier",
|
||||||
"seasons": "Sæsoner",
|
"seasons": "Sæsoner",
|
||||||
"season": "Sæson",
|
"season": "Sæson",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Ingen episoder for denne sæson",
|
"no_episodes_for_this_season": "Ingen episoder for denne sæson",
|
||||||
"overview": "Oversigt",
|
"overview": "Oversigt",
|
||||||
"more_with": "Mere med {{name}}",
|
"more_with": "Mere med {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "Kvalitet",
|
"quality": "Kvalitet",
|
||||||
"audio": "Lyd",
|
"audio": "Lyd",
|
||||||
"subtitles": "Undertekster",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Vis mere",
|
"show_more": "Vis mere",
|
||||||
"show_less": "Vis mindre",
|
"show_less": "Vis mindre",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Medvirket i",
|
"appeared_in": "Medvirket i",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Kunne ikke indlæse elementet",
|
"could_not_load_item": "Kunne ikke indlæse elementet",
|
||||||
"none": "Ingen",
|
"none": "Ingen",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Download {{item_count}} elementer",
|
"download_x_item": "Download {{item_count}} elementer",
|
||||||
"download_unwatched_only": "Kun Usete",
|
"download_unwatched_only": "Kun Usete",
|
||||||
"download_button": "Hent"
|
"download_button": "Hent"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Næste",
|
"next": "Næste",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "For børn",
|
"for_kids": "For børn",
|
||||||
"news": "Nyheder"
|
"news": "Nyheder",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Bekræft",
|
"confirm": "Bekræft",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0",
|
||||||
"jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.",
|
"jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Søg",
|
"search": "Søg",
|
||||||
"library": "Bibliotek",
|
"library": "Bibliotek",
|
||||||
"custom_links": "Tilpassede links",
|
"custom_links": "Tilpassede links",
|
||||||
"favorites": "Favoritter"
|
"favorites": "Favoritter",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Fehler",
|
"error_title": "Fehler",
|
||||||
"login_title": "Anmelden",
|
"login_title": "Anmelden",
|
||||||
"login_to_title": "Anmelden bei",
|
"login_to_title": "Anmelden bei",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Benutzername",
|
"username_placeholder": "Benutzername",
|
||||||
"password_placeholder": "Passwort",
|
"password_placeholder": "Passwort",
|
||||||
"login_button": "Anmelden",
|
"login_button": "Anmelden",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} Konten",
|
"accounts_count": "{{count}} Konten",
|
||||||
"select_account": "Konto auswählen",
|
"select_account": "Konto auswählen",
|
||||||
"add_account": "Konto hinzufügen",
|
"add_account": "Konto hinzufügen",
|
||||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt."
|
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Konto speichern",
|
"title": "Konto speichern",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Ups!",
|
"oops": "Ups!",
|
||||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||||
"continue_watching": "Weiterschauen",
|
"continue_watching": "Weiterschauen",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Als nächstes",
|
"next_up": "Als nächstes",
|
||||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Einstellungen",
|
"settings_title": "Einstellungen",
|
||||||
"log_out_button": "Abmelden",
|
"log_out_button": "Abmelden",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategorien"
|
"title": "Kategorien"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Aussehen",
|
"title": "Aussehen",
|
||||||
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
||||||
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
|
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Netzwerk",
|
"title": "Netzwerk",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Rückspullänge",
|
"rewind_length": "Rückspullänge",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Gestensteuerung",
|
"gesture_controls_title": "Gestensteuerung",
|
||||||
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
|
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Untertitel-Schriftart",
|
"subtitle_font": "Untertitel-Schriftart",
|
||||||
"ksplayer_title": "KSPlayer Einstellungen",
|
"ksplayer_title": "KSPlayer Einstellungen",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
|
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Untertitel-Einstellungen",
|
"title": "VLC Untertitel-Einstellungen",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Musik-Cache geleert",
|
"music_cache_cleared": "Musik-Cache geleert",
|
||||||
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
|
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
|
||||||
"downloaded_songs_size": "{{size}} heruntergeladen",
|
"downloaded_songs_size": "{{size}} heruntergeladen",
|
||||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht"
|
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Einführung",
|
"title": "Einführung",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||||
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"delete_download": "Download löschen",
|
||||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"no_trailer_available": "Kein Trailer verfügbar",
|
"no_trailer_available": "Kein Trailer verfügbar",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Untertitel",
|
"subtitle": "Untertitel",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
|
"mark_as_played": "Als gesehen markieren",
|
||||||
|
"mark_as_not_played": "Als ungesehen markieren",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"track": "Spur",
|
"track": "Spur",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"continue": "Fortsetzen",
|
"continue": "Fortsetzen",
|
||||||
"verifying": "Verifiziere..."
|
"verifying": "Verifiziere...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Suchen...",
|
"search": "Suchen...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"boxsets": "Boxsets",
|
"boxsets": "Boxsets",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Elemente"
|
"items": "Elemente"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Poster",
|
"poster": "Poster",
|
||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Titel anzeigen",
|
"show_titles": "Titel anzeigen",
|
||||||
"show_stats": "Statistiken anzeigen"
|
"show_stats": "Statistiken anzeigen",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Sortieren nach",
|
"sort_by": "Sortieren nach",
|
||||||
"filter_by": "Filtern nach",
|
"filter_by": "Filtern nach",
|
||||||
"sort_order": "Sortierreihenfolge",
|
"sort_order": "Sortierreihenfolge",
|
||||||
"tags": "Tags"
|
"tags": "Tags",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Keine Links"
|
"no_links": "Keine Links"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||||
@@ -609,7 +702,34 @@
|
|||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen",
|
"downloaded_file_cancel": "Abbrechen",
|
||||||
"ends_at": "Endet um {{time}}"
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Endet um {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
@@ -618,6 +738,11 @@
|
|||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"seasons": "Staffeln",
|
"seasons": "Staffeln",
|
||||||
"season": "Staffel",
|
"season": "Staffel",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
|
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
|
||||||
"overview": "Überblick",
|
"overview": "Überblick",
|
||||||
"more_with": "Mehr mit {{name}}",
|
"more_with": "Mehr mit {{name}}",
|
||||||
@@ -628,10 +753,21 @@
|
|||||||
"media_options": "Medienoptionen",
|
"media_options": "Medienoptionen",
|
||||||
"quality": "Qualität",
|
"quality": "Qualität",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Untertitel",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Mehr anzeigen",
|
"show_more": "Mehr anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Erschien in",
|
"appeared_in": "Erschien in",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Konnte Element nicht laden",
|
"could_not_load_item": "Konnte Element nicht laden",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -642,7 +778,13 @@
|
|||||||
"download_x_item": "{{item_count}} Elemente herunterladen",
|
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||||
"download_unwatched_only": "Nur Ungesehene",
|
"download_unwatched_only": "Nur Ungesehene",
|
||||||
"download_button": "Herunterladen"
|
"download_button": "Herunterladen"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Nächste",
|
"next": "Nächste",
|
||||||
@@ -653,7 +795,18 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Für Kinder",
|
"for_kids": "Für Kinder",
|
||||||
"news": "Nachrichten"
|
"news": "Nachrichten",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
@@ -698,6 +851,12 @@
|
|||||||
"decline": "Ablehnen",
|
"decline": "Ablehnen",
|
||||||
"requested_by": "Angefragt von {{user}}",
|
"requested_by": "Angefragt von {{user}}",
|
||||||
"unknown_user": "Unbekannter Nutzer",
|
"unknown_user": "Unbekannter Nutzer",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
@@ -717,7 +876,8 @@
|
|||||||
"search": "Suche",
|
"search": "Suche",
|
||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
"custom_links": "Links",
|
"custom_links": "Links",
|
||||||
"favorites": "Favoriten"
|
"favorites": "Favoriten",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
@@ -842,5 +1002,36 @@
|
|||||||
"show": "Nur diese Serie",
|
"show": "Nur diese Serie",
|
||||||
"all": "Alle (Standard)"
|
"all": "Alle (Standard)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Σφάλμα",
|
"error_title": "Σφάλμα",
|
||||||
"login_title": "Σύνδεση",
|
"login_title": "Σύνδεση",
|
||||||
"login_to_title": "Συνδεθείτε στο",
|
"login_to_title": "Συνδεθείτε στο",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Όνομα Χρήστη",
|
"username_placeholder": "Όνομα Χρήστη",
|
||||||
"password_placeholder": "Κωδικός",
|
"password_placeholder": "Κωδικός",
|
||||||
"login_button": "Σύνδεση",
|
"login_button": "Σύνδεση",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Ωχ!",
|
"oops": "Ωχ!",
|
||||||
"error_message": "Something went wrong.\nPlease log out and in again.",
|
"error_message": "Something went wrong.\nPlease log out and in again.",
|
||||||
"continue_watching": "Συνέχεια Παρακολούθησης",
|
"continue_watching": "Συνέχεια Παρακολούθησης",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Επόμενο Επάνω",
|
"next_up": "Επόμενο Επάνω",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
|
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Ρυθμίσεις",
|
"settings_title": "Ρυθμίσεις",
|
||||||
"log_out_button": "Αποσύνδεση",
|
"log_out_button": "Αποσύνδεση",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Επαναφορά Μήκους",
|
"rewind_length": "Επαναφορά Μήκους",
|
||||||
"seconds_unit": "ίνα"
|
"seconds_unit": "ίνα"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Έλεγχοι Χειρονομιών",
|
"gesture_controls_title": "Έλεγχοι Χειρονομιών",
|
||||||
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
|
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
|
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
|
||||||
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
|
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
|
||||||
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
|
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.",
|
"new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.",
|
||||||
"back": "Πίσω",
|
"back": "Πίσω",
|
||||||
"delete": "Διαγραφή",
|
"delete": "Διαγραφή",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Κάτι Πήγε Λάθος",
|
"something_went_wrong": "Κάτι Πήγε Λάθος",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Επιλογή",
|
"select": "Επιλογή",
|
||||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||||
"video": "Βίντεο",
|
"video": "Βίντεο",
|
||||||
"audio": "Ήχος",
|
"audio": "Ήχος",
|
||||||
"subtitle": "Υπότιτλος",
|
"subtitle": "Υπότιτλος",
|
||||||
"play": "Αναπαραγωγή",
|
"play": "Αναπαραγωγή",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Αναζήτηση...",
|
"search": "Αναζήτηση...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
"series": "Σειρά",
|
"series": "Σειρά",
|
||||||
"boxsets": "Σύνολα Πλαισίων",
|
"boxsets": "Σύνολα Πλαισίων",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Στοιχεία"
|
"items": "Στοιχεία"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Αφίσα",
|
"poster": "Αφίσα",
|
||||||
"cover": "Εξώφυλλο",
|
"cover": "Εξώφυλλο",
|
||||||
"show_titles": "Εμφάνιση Τίτλων",
|
"show_titles": "Εμφάνιση Τίτλων",
|
||||||
"show_stats": "Εμφάνιση Στατιστικών"
|
"show_stats": "Εμφάνιση Στατιστικών",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Ταξινόμηση Κατά",
|
"sort_by": "Ταξινόμηση Κατά",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Σειρά Ταξινόμησης",
|
"sort_order": "Σειρά Ταξινόμησης",
|
||||||
"tags": "Ετικέτες"
|
"tags": "Ετικέτες",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Δεν Υπάρχουν Σύνδεσμοι"
|
"no_links": "Δεν Υπάρχουν Σύνδεσμοι"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Σφάλμα",
|
"error": "Σφάλμα",
|
||||||
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
|
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
|
||||||
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
|
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Επόμενο Επάνω",
|
"next_up": "Επόμενο Επάνω",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Σειρά",
|
"series": "Σειρά",
|
||||||
"seasons": "Περίοδοι",
|
"seasons": "Περίοδοι",
|
||||||
"season": "Σεζόν",
|
"season": "Σεζόν",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
|
"no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
|
||||||
"overview": "Επισκόπηση",
|
"overview": "Επισκόπηση",
|
||||||
"more_with": "More with {{name}}",
|
"more_with": "More with {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "Ποιότητα",
|
"quality": "Ποιότητα",
|
||||||
"audio": "Ήχος",
|
"audio": "Ήχος",
|
||||||
"subtitles": "Υπότιτλος",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Εμφάνιση Περισσότερων",
|
"show_more": "Εμφάνιση Περισσότερων",
|
||||||
"show_less": "Εμφάνιση Λιγότερων",
|
"show_less": "Εμφάνιση Λιγότερων",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Εμφανίστηκε Σε",
|
"appeared_in": "Εμφανίστηκε Σε",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
|
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
|
||||||
"none": "Κανένα",
|
"none": "Κανένα",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Λήψη Αντικειμένων {{item_count}}",
|
"download_x_item": "Λήψη Αντικειμένων {{item_count}}",
|
||||||
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
|
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
|
||||||
"download_button": "Λήψη"
|
"download_button": "Λήψη"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Επόμενο",
|
"next": "Επόμενο",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
"sports": "Αθλητισμός",
|
"sports": "Αθλητισμός",
|
||||||
"for_kids": "Για Παιδιά",
|
"for_kids": "Για Παιδιά",
|
||||||
"news": "Νέα"
|
"news": "Νέα",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Επιβεβαίωση",
|
"confirm": "Επιβεβαίωση",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
|
||||||
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
|
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Αναζήτηση",
|
"search": "Αναζήτηση",
|
||||||
"library": "Βιβλιοθήκη",
|
"library": "Βιβλιοθήκη",
|
||||||
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
|
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
|
||||||
"favorites": "Αγαπημένα"
|
"favorites": "Αγαπημένα",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"log_out_button": "Log Out",
|
"log_out_button": "Log Out",
|
||||||
|
"search_placeholder": "Search settings",
|
||||||
|
"search_results": "Results",
|
||||||
|
"search_no_results": "No matching settings",
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -126,7 +129,25 @@
|
|||||||
"current": "current"
|
"current": "current"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories",
|
||||||
|
"playback": "Playback",
|
||||||
|
"personalization": "Personalization",
|
||||||
|
"advanced": "Advanced"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"disabled_title": "Notifications are off",
|
||||||
|
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
|
||||||
|
"enable_button": "Enable notifications",
|
||||||
|
"events_title": "Notify me about",
|
||||||
|
"master": "Enable notifications",
|
||||||
|
"downloads": "Download completed / failed"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Account",
|
||||||
|
"copy_token": "Copy token",
|
||||||
|
"copied": "Copied to clipboard",
|
||||||
|
"copy_failed": "Couldn't copy to clipboard"
|
||||||
},
|
},
|
||||||
"playback_controls": {
|
"playback_controls": {
|
||||||
"title": "Playback & Controls"
|
"title": "Playback & Controls"
|
||||||
@@ -199,6 +220,10 @@
|
|||||||
"rewind_length": "Rewind Length",
|
"rewind_length": "Rewind Length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"chromecast": {
|
||||||
|
"title": "Chromecast",
|
||||||
|
"enable_h265": "Enable H265 for Chromecast"
|
||||||
|
},
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
@@ -534,6 +559,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Something Went Wrong",
|
"something_went_wrong": "Something Went Wrong",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -577,6 +603,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -649,7 +677,8 @@
|
|||||||
"poster": "Poster",
|
"poster": "Poster",
|
||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Show Titles",
|
"show_titles": "Show Titles",
|
||||||
"show_stats": "Show Stats"
|
"show_stats": "Show Stats",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -679,6 +708,7 @@
|
|||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "LIVE",
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
@@ -998,30 +1028,6 @@
|
|||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"syncplay": {
|
|
||||||
"title": "SyncPlay",
|
|
||||||
"my_group": "My Group",
|
|
||||||
"join_group": "Join Group",
|
|
||||||
"leave_group": "Leave Group",
|
|
||||||
"create_group": "Create Group",
|
|
||||||
"create_new_group": "Create New Group",
|
|
||||||
"available_groups": "Available Groups",
|
|
||||||
"group_id": "Group ID",
|
|
||||||
"leader": "Leader",
|
|
||||||
"members": "members",
|
|
||||||
"enabled": "SyncPlay enabled",
|
|
||||||
"disabled": "SyncPlay disabled",
|
|
||||||
"user_joined": "{{username}} joined the group",
|
|
||||||
"user_left": "{{username}} left the group",
|
|
||||||
"permission_required": "Permission required to use SyncPlay",
|
|
||||||
"group_does_not_exist": "Group does not exist",
|
|
||||||
"create_denied": "Permission denied to create group",
|
|
||||||
"join_denied": "Permission denied to join group",
|
|
||||||
"library_access_denied": "Library access denied",
|
|
||||||
"waiting_for_group": "Waiting for group...",
|
|
||||||
"joining_playback": "Joining group playback...",
|
|
||||||
"failed_to_start": "Failed to start SyncPlay group playback"
|
|
||||||
},
|
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Pair with TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Align the QR code within the frame",
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
"login_title": "Iniciar sesión",
|
"login_title": "Iniciar sesión",
|
||||||
"login_to_title": "Iniciar sesión en",
|
"login_to_title": "Iniciar sesión en",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nombre de usuario",
|
"username_placeholder": "Nombre de usuario",
|
||||||
"password_placeholder": "Contraseña",
|
"password_placeholder": "Contraseña",
|
||||||
"login_button": "Iniciar sesión",
|
"login_button": "Iniciar sesión",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} cuentas",
|
"accounts_count": "{{count}} cuentas",
|
||||||
"select_account": "Seleccione una cuenta",
|
"select_account": "Seleccione una cuenta",
|
||||||
"add_account": "Añadir cuenta",
|
"add_account": "Añadir cuenta",
|
||||||
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}."
|
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Guardar Cuenta",
|
"title": "Guardar Cuenta",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "¡Vaya!",
|
"oops": "¡Vaya!",
|
||||||
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
|
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
|
||||||
"continue_watching": "Seguir viendo",
|
"continue_watching": "Seguir viendo",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "A continuación",
|
"next_up": "A continuación",
|
||||||
"continue_and_next_up": "Continuar y siguiente",
|
"continue_and_next_up": "Continuar y siguiente",
|
||||||
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Configuración",
|
"settings_title": "Configuración",
|
||||||
"log_out_button": "Cerrar sesión",
|
"log_out_button": "Cerrar sesión",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categorías"
|
"title": "Categorías"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Apariencia",
|
"title": "Apariencia",
|
||||||
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
|
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
|
||||||
"hide_remote_session_button": "Ocultar botón de sesión remota"
|
"hide_remote_session_button": "Ocultar botón de sesión remota",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Cadena",
|
"title": "Cadena",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Longitud de retroceso",
|
"rewind_length": "Longitud de retroceso",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Controles de gestos",
|
"gesture_controls_title": "Controles de gestos",
|
||||||
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
|
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Fuente de los subtítulos",
|
"subtitle_font": "Fuente de los subtítulos",
|
||||||
"ksplayer_title": "Ajustes de KSPlayer",
|
"ksplayer_title": "Ajustes de KSPlayer",
|
||||||
"hardware_decode": "Decodificación de hardware",
|
"hardware_decode": "Decodificación de hardware",
|
||||||
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
|
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "Configuración de subtítulos VLC",
|
"title": "Configuración de subtítulos VLC",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Caché de música eliminado",
|
"music_cache_cleared": "Caché de música eliminado",
|
||||||
"delete_all_downloaded_songs": "Eliminar todas las descargas",
|
"delete_all_downloaded_songs": "Eliminar todas las descargas",
|
||||||
"downloaded_songs_size": "{{tamaño}} descargado",
|
"downloaded_songs_size": "{{tamaño}} descargado",
|
||||||
"downloaded_songs_deleted": "Canciones descargadas eliminadas"
|
"downloaded_songs_deleted": "Canciones descargadas eliminadas",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Error al eliminar archivos",
|
"error_deleting_files": "Error al eliminar archivos",
|
||||||
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
||||||
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.",
|
"new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.",
|
||||||
"back": "Atrás",
|
"back": "Atrás",
|
||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Algo ha salido mal",
|
"something_went_wrong": "Algo ha salido mal",
|
||||||
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
|
||||||
"eta": "{{eta}} restante",
|
"eta": "{{eta}} restante",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Seleccionar",
|
"select": "Seleccionar",
|
||||||
"no_trailer_available": "No hay tráiler disponible",
|
"no_trailer_available": "No hay tráiler disponible",
|
||||||
"video": "Vídeo",
|
"video": "Vídeo",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtítulos",
|
"subtitle": "Subtítulos",
|
||||||
"play": "Jugar",
|
"play": "Jugar",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Nada",
|
"none": "Nada",
|
||||||
"track": "Pista",
|
"track": "Pista",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
"ok": "Aceptar",
|
"ok": "Aceptar",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"back": "Atrás",
|
"back": "Atrás",
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"verifying": "Verificando..."
|
"verifying": "Verificando...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Buscar...",
|
"search": "Buscar...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"boxsets": "Colecciones",
|
"boxsets": "Colecciones",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Elementos"
|
"items": "Elementos"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Póster",
|
"poster": "Póster",
|
||||||
"cover": "Portada",
|
"cover": "Portada",
|
||||||
"show_titles": "Mostrar títulos",
|
"show_titles": "Mostrar títulos",
|
||||||
"show_stats": "Mostrar estadísticas"
|
"show_stats": "Mostrar estadísticas",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Géneros",
|
"genres": "Géneros",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Ordenar por",
|
"sort_by": "Ordenar por",
|
||||||
"filter_by": "Filtrar por",
|
"filter_by": "Filtrar por",
|
||||||
"sort_order": "Ordenar",
|
"sort_order": "Ordenar",
|
||||||
"tags": "Etiquetas"
|
"tags": "Etiquetas",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Sin enlaces"
|
"no_links": "Sin enlaces"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
|
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
|
||||||
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
|
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
|
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
|
||||||
"downloaded_file_yes": "Sí",
|
"downloaded_file_yes": "Sí",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancelar"
|
"downloaded_file_cancel": "Cancelar",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "A continuación",
|
"next_up": "A continuación",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Series",
|
"series": "Series",
|
||||||
"seasons": "Temporadas",
|
"seasons": "Temporadas",
|
||||||
"season": "Temporada",
|
"season": "Temporada",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "No hay episodios para esta temporada",
|
"no_episodes_for_this_season": "No hay episodios para esta temporada",
|
||||||
"overview": "Resumen",
|
"overview": "Resumen",
|
||||||
"more_with": "Más con {{name}}",
|
"more_with": "Más con {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Opciones de medios",
|
"media_options": "Opciones de medios",
|
||||||
"quality": "Calidad",
|
"quality": "Calidad",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtítulos",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Mostrar más",
|
"show_more": "Mostrar más",
|
||||||
"show_less": "Mostrar menos",
|
"show_less": "Mostrar menos",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apareció en",
|
"appeared_in": "Apareció en",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "No se pudo cargar el ítem",
|
"could_not_load_item": "No se pudo cargar el ítem",
|
||||||
"none": "Ninguno",
|
"none": "Ninguno",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Descargar {{item_count}} ítems",
|
"download_x_item": "Descargar {{item_count}} ítems",
|
||||||
"download_unwatched_only": "No visto",
|
"download_unwatched_only": "No visto",
|
||||||
"download_button": "Descargar"
|
"download_button": "Descargar"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
"sports": "Deportes",
|
"sports": "Deportes",
|
||||||
"for_kids": "Para niños",
|
"for_kids": "Para niños",
|
||||||
"news": "Noticias"
|
"news": "Noticias",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Rechazar",
|
"decline": "Rechazar",
|
||||||
"requested_by": "Solicitado por {{user}}",
|
"requested_by": "Solicitado por {{user}}",
|
||||||
"unknown_user": "Usuario desconocido",
|
"unknown_user": "Usuario desconocido",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
|
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
|
||||||
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
|
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
"library": "Bibliotecas",
|
"library": "Bibliotecas",
|
||||||
"custom_links": "Enlaces personalizados",
|
"custom_links": "Enlaces personalizados",
|
||||||
"favorites": "Favoritos"
|
"favorites": "Favoritos",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Música",
|
"title": "Música",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "Este programa",
|
"show": "Este programa",
|
||||||
"all": "Todos los medios (por defecto)"
|
"all": "Todos los medios (por defecto)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Virhe",
|
"error_title": "Virhe",
|
||||||
"login_title": "Kirjaudu sisään",
|
"login_title": "Kirjaudu sisään",
|
||||||
"login_to_title": "Kirjaudu sisään palveluun",
|
"login_to_title": "Kirjaudu sisään palveluun",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Käyttäjätunnus",
|
"username_placeholder": "Käyttäjätunnus",
|
||||||
"password_placeholder": "Salasana",
|
"password_placeholder": "Salasana",
|
||||||
"login_button": "Kirjaudu sisään",
|
"login_button": "Kirjaudu sisään",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Ups!",
|
"oops": "Ups!",
|
||||||
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
|
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
|
||||||
"continue_watching": "Jatka katsomista",
|
"continue_watching": "Jatka katsomista",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Seuraavaksi",
|
"next_up": "Seuraavaksi",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
|
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Asetukset",
|
"settings_title": "Asetukset",
|
||||||
"log_out_button": "Kirjaudu ulos",
|
"log_out_button": "Kirjaudu ulos",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategoriat"
|
"title": "Kategoriat"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Ulkoasu",
|
"title": "Ulkoasu",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Taaksepäin hyppäämisen pituus",
|
"rewind_length": "Taaksepäin hyppäämisen pituus",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Ele Ohjaus",
|
"gesture_controls_title": "Ele Ohjaus",
|
||||||
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
|
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Esittely",
|
"title": "Esittely",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Virhe tiedostojen poistamisessa",
|
"error_deleting_files": "Virhe tiedostojen poistamisessa",
|
||||||
"background_downloads_enabled": "Taustalataukset käytössä",
|
"background_downloads_enabled": "Taustalataukset käytössä",
|
||||||
"background_downloads_disabled": "Taustalataukset pois käytöstä"
|
"background_downloads_disabled": "Taustalataukset pois käytöstä"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Uusi päivitys vaatii sisällön lataamista uudelleen. Poista kaikki ladattu sisältö ja yritä uudelleen.",
|
"new_app_version_requires_re_download_description": "Uusi päivitys vaatii sisällön lataamista uudelleen. Poista kaikki ladattu sisältö ja yritä uudelleen.",
|
||||||
"back": "Takaisin",
|
"back": "Takaisin",
|
||||||
"delete": "Poista",
|
"delete": "Poista",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Jotain meni pieleen",
|
"something_went_wrong": "Jotain meni pieleen",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta",
|
"could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta",
|
||||||
"eta": "Arvio {{eta}}",
|
"eta": "Arvio {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Valitse",
|
"select": "Valitse",
|
||||||
"no_trailer_available": "Perävaunua ei saatavilla",
|
"no_trailer_available": "Perävaunua ei saatavilla",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Ääni",
|
"audio": "Ääni",
|
||||||
"subtitle": "Tekstitys",
|
"subtitle": "Tekstitys",
|
||||||
"play": "Toista",
|
"play": "Toista",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Ei mitään",
|
"none": "Ei mitään",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Haku...",
|
"search": "Haku...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "elokuvat",
|
"movies": "elokuvat",
|
||||||
"series": "sarjat",
|
"series": "sarjat",
|
||||||
"boxsets": "bokset",
|
"boxsets": "bokset",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "kohteet"
|
"items": "kohteet"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Juliste",
|
"poster": "Juliste",
|
||||||
"cover": "Kansi",
|
"cover": "Kansi",
|
||||||
"show_titles": "Näytä otsikot",
|
"show_titles": "Näytä otsikot",
|
||||||
"show_stats": "Näytä tilastot"
|
"show_stats": "Näytä tilastot",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genret",
|
"genres": "Genret",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Lajittele",
|
"sort_by": "Lajittele",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Lajittelujärjestys",
|
"sort_order": "Lajittelujärjestys",
|
||||||
"tags": "Tunnisteet"
|
"tags": "Tunnisteet",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Ei Linkkejä"
|
"no_links": "Ei Linkkejä"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Virhe",
|
"error": "Virhe",
|
||||||
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
|
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
|
||||||
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
|
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
|
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
|
||||||
"downloaded_file_yes": "Kyllä",
|
"downloaded_file_yes": "Kyllä",
|
||||||
"downloaded_file_no": "Ei",
|
"downloaded_file_no": "Ei",
|
||||||
"downloaded_file_cancel": "Peruuta"
|
"downloaded_file_cancel": "Peruuta",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Seuraavaksi",
|
"next_up": "Seuraavaksi",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Sarjat",
|
"series": "Sarjat",
|
||||||
"seasons": "Kaudet",
|
"seasons": "Kaudet",
|
||||||
"season": "Kausi",
|
"season": "Kausi",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Ei jaksoja tälle kaudelle",
|
"no_episodes_for_this_season": "Ei jaksoja tälle kaudelle",
|
||||||
"overview": "Yleiskatsaus",
|
"overview": "Yleiskatsaus",
|
||||||
"more_with": "Enemmän {{name}} kanssa",
|
"more_with": "Enemmän {{name}} kanssa",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media-asetukset",
|
"media_options": "Media-asetukset",
|
||||||
"quality": "Laatu",
|
"quality": "Laatu",
|
||||||
"audio": "Ääni",
|
"audio": "Ääni",
|
||||||
"subtitles": "Tekstitys",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Näytä Lisää",
|
"show_more": "Näytä Lisää",
|
||||||
"show_less": "Näytä Vähemmän",
|
"show_less": "Näytä Vähemmän",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Esiintyy Sisään",
|
"appeared_in": "Esiintyy Sisään",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Kohdetta Ei Voitu Ladata",
|
"could_not_load_item": "Kohdetta Ei Voitu Ladata",
|
||||||
"none": "Ei mitään",
|
"none": "Ei mitään",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Lataa {{item_count}} Kohteita",
|
"download_x_item": "Lataa {{item_count}} Kohteita",
|
||||||
"download_unwatched_only": "Vain Katsomattomat",
|
"download_unwatched_only": "Vain Katsomattomat",
|
||||||
"download_button": "Lataa"
|
"download_button": "Lataa"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Seuraava",
|
"next": "Seuraava",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Elokuvat",
|
"movies": "Elokuvat",
|
||||||
"sports": "Urheilu",
|
"sports": "Urheilu",
|
||||||
"for_kids": "Lapsille",
|
"for_kids": "Lapsille",
|
||||||
"news": "Uutiset"
|
"news": "Uutiset",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Vahvista",
|
"confirm": "Vahvista",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Hylkää",
|
"decline": "Hylkää",
|
||||||
"requested_by": "Käyttäjän {{user}} pyynnöstä",
|
"requested_by": "Käyttäjän {{user}} pyynnöstä",
|
||||||
"unknown_user": "Tuntematon käyttäjä",
|
"unknown_user": "Tuntematon käyttäjä",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0",
|
||||||
"jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.",
|
"jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Haku",
|
"search": "Haku",
|
||||||
"library": "Kirjasto",
|
"library": "Kirjasto",
|
||||||
"custom_links": "Mukautetut linkit",
|
"custom_links": "Mukautetut linkit",
|
||||||
"favorites": "Suosikit"
|
"favorites": "Suosikit",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Erreur",
|
"error_title": "Erreur",
|
||||||
"login_title": "Se connecter",
|
"login_title": "Se connecter",
|
||||||
"login_to_title": "Se connecter à",
|
"login_to_title": "Se connecter à",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nom d'utilisateur",
|
"username_placeholder": "Nom d'utilisateur",
|
||||||
"password_placeholder": "Mot de passe",
|
"password_placeholder": "Mot de passe",
|
||||||
"login_button": "Se connecter",
|
"login_button": "Se connecter",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "Comptes {{count}}",
|
"accounts_count": "Comptes {{count}}",
|
||||||
"select_account": "Sélectionnez un compte",
|
"select_account": "Sélectionnez un compte",
|
||||||
"add_account": "Ajouter un compte",
|
"add_account": "Ajouter un compte",
|
||||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}."
|
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Sauvegarder le compte",
|
"title": "Sauvegarder le compte",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Oups !",
|
"oops": "Oups !",
|
||||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||||
"continue_watching": "Continuer à regarder",
|
"continue_watching": "Continuer à regarder",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"continue_and_next_up": "Continuer de regarder et à suivre",
|
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Paramètres",
|
"settings_title": "Paramètres",
|
||||||
"log_out_button": "Déconnexion",
|
"log_out_button": "Déconnexion",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Catégories"
|
"title": "Catégories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Apparence",
|
"title": "Apparence",
|
||||||
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||||
"hide_remote_session_button": "Masquer le bouton de session distante"
|
"hide_remote_session_button": "Masquer le bouton de session distante",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Réseau",
|
"title": "Réseau",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Durée de retour en arrière",
|
"rewind_length": "Durée de retour en arrière",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Commandes gestuelles",
|
"gesture_controls_title": "Commandes gestuelles",
|
||||||
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
|
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Police des sous-titres",
|
"subtitle_font": "Police des sous-titres",
|
||||||
"ksplayer_title": "Paramètres de KSPlayer",
|
"ksplayer_title": "Paramètres de KSPlayer",
|
||||||
"hardware_decode": "Décodage matériel",
|
"hardware_decode": "Décodage matériel",
|
||||||
"hardware_decode_description": "Utilisez l’accélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture."
|
"hardware_decode_description": "Utilisez l’accélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "Paramètres des sous-titres VLC",
|
"title": "Paramètres des sous-titres VLC",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Cache de musique effacé",
|
"music_cache_cleared": "Cache de musique effacé",
|
||||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||||
"downloaded_songs_size": "{{size}} téléchargé",
|
"downloaded_songs_size": "{{size}} téléchargé",
|
||||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées"
|
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Introduction",
|
"title": "Introduction",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Erreur lors de la suppression des fichiers",
|
"error_deleting_files": "Erreur lors de la suppression des fichiers",
|
||||||
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
|
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
|
||||||
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
|
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||||
"back": "Retour",
|
"back": "Retour",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Sélectionner",
|
"select": "Sélectionner",
|
||||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||||
"video": "Vidéo",
|
"video": "Vidéo",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Sous-titres",
|
"subtitle": "Sous-titres",
|
||||||
"play": "Lecture",
|
"play": "Lecture",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"track": "Suivre",
|
"track": "Suivre",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
"remove": "Retirer",
|
"remove": "Retirer",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"verifying": "Vérification..."
|
"verifying": "Vérification...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"boxsets": "Coffrets ",
|
"boxsets": "Coffrets ",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Médias"
|
"items": "Médias"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Affiche",
|
"poster": "Affiche",
|
||||||
"cover": "Couverture",
|
"cover": "Couverture",
|
||||||
"show_titles": "Afficher les titres",
|
"show_titles": "Afficher les titres",
|
||||||
"show_stats": "Afficher les statistiques"
|
"show_stats": "Afficher les statistiques",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Trier par",
|
"sort_by": "Trier par",
|
||||||
"filter_by": "Filtrer par",
|
"filter_by": "Filtrer par",
|
||||||
"sort_order": "Ordre de tri",
|
"sort_order": "Ordre de tri",
|
||||||
"tags": "Tags"
|
"tags": "Tags",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Aucuns liens"
|
"no_links": "Aucuns liens"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
|
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
|
||||||
"downloaded_file_yes": "Oui",
|
"downloaded_file_yes": "Oui",
|
||||||
"downloaded_file_no": "Non",
|
"downloaded_file_no": "Non",
|
||||||
"downloaded_file_cancel": "Annuler"
|
"downloaded_file_cancel": "Annuler",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"seasons": "Saisons",
|
"seasons": "Saisons",
|
||||||
"season": "Saison",
|
"season": "Saison",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
||||||
"overview": "Aperçu",
|
"overview": "Aperçu",
|
||||||
"more_with": "Plus avec {{name}}",
|
"more_with": "Plus avec {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Options média",
|
"media_options": "Options média",
|
||||||
"quality": "Qualité",
|
"quality": "Qualité",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Sous-titres",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apparu dans",
|
"appeared_in": "Apparu dans",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Impossible de charger le média",
|
"could_not_load_item": "Impossible de charger le média",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "Télécharger {{item_count}} médias",
|
"download_x_item": "Télécharger {{item_count}} médias",
|
||||||
"download_unwatched_only": "Non visionné uniquement",
|
"download_unwatched_only": "Non visionné uniquement",
|
||||||
"download_button": "Télécharger"
|
"download_button": "Télécharger"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
"for_kids": "Pour enfants",
|
"for_kids": "Pour enfants",
|
||||||
"news": "Actualités"
|
"news": "Actualités",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Refuser",
|
"decline": "Refuser",
|
||||||
"requested_by": "Demandé par {{user}}",
|
"requested_by": "Demandé par {{user}}",
|
||||||
"unknown_user": "Utilisateur inconnu",
|
"unknown_user": "Utilisateur inconnu",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Recherche",
|
"search": "Recherche",
|
||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
"custom_links": "Liens personnalisés",
|
"custom_links": "Liens personnalisés",
|
||||||
"favorites": "Favoris"
|
"favorites": "Favoris",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musique",
|
"title": "Musique",
|
||||||
@@ -788,8 +949,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Watchlists",
|
"title": "Listes de lecture",
|
||||||
"my_watchlists": "My Watchlists",
|
"my_watchlists": "Mes listes de lecture",
|
||||||
"public_watchlists": "Watchlist publique",
|
"public_watchlists": "Watchlist publique",
|
||||||
"create_title": "Créer une Watchlist",
|
"create_title": "Créer une Watchlist",
|
||||||
"edit_title": "Modifier la Watchlist",
|
"edit_title": "Modifier la Watchlist",
|
||||||
@@ -802,7 +963,7 @@
|
|||||||
"name_placeholder": "Entrer le nom de la playlist",
|
"name_placeholder": "Entrer le nom de la playlist",
|
||||||
"description_label": "Description",
|
"description_label": "Description",
|
||||||
"description_placeholder": "Entrez la description (facultatif)",
|
"description_placeholder": "Entrez la description (facultatif)",
|
||||||
"is_public_label": "Public Watchlist",
|
"is_public_label": "Liste de lecture Publique",
|
||||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||||
"allowed_type_label": "Type de contenu",
|
"allowed_type_label": "Type de contenu",
|
||||||
"sort_order_label": "Ordre de tri par défaut",
|
"sort_order_label": "Ordre de tri par défaut",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "Cette série",
|
"show": "Cette série",
|
||||||
"all": "Tous les médias (par défaut)"
|
"all": "Tous les médias (par défaut)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "שגיאה",
|
"error_title": "שגיאה",
|
||||||
"login_title": "התחבר",
|
"login_title": "התחבר",
|
||||||
"login_to_title": "התחבר אל",
|
"login_to_title": "התחבר אל",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "שם משתמש",
|
"username_placeholder": "שם משתמש",
|
||||||
"password_placeholder": "סיסמה",
|
"password_placeholder": "סיסמה",
|
||||||
"login_button": "התחבר",
|
"login_button": "התחבר",
|
||||||
@@ -39,10 +42,16 @@
|
|||||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||||
"remove_saved_login": "Remove Saved Login",
|
"remove_saved_login": "Remove Saved Login",
|
||||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} חשבונות",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "אופס!",
|
"oops": "אופס!",
|
||||||
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
|
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
|
||||||
"continue_watching": "המשך לצפות",
|
"continue_watching": "המשך לצפות",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "הבא בתור",
|
"next_up": "הבא בתור",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
|
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
|
||||||
@@ -109,19 +119,34 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "הגדרות",
|
"settings_title": "הגדרות",
|
||||||
"log_out_button": "התנתק",
|
"log_out_button": "התנתק",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "קטגוריות"
|
||||||
},
|
},
|
||||||
"playback_controls": {
|
"playback_controls": {
|
||||||
"title": "Playback & Controls"
|
"title": "Playback & Controls"
|
||||||
},
|
},
|
||||||
"audio_subtitles": {
|
"audio_subtitles": {
|
||||||
"title": "Audio & Subtitles"
|
"title": "שמע וכתוביות"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "מראה",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "אורך הזזה אחורה",
|
"rewind_length": "אורך הזזה אחורה",
|
||||||
"seconds_unit": "שנ'"
|
"seconds_unit": "שנ'"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "פקדי מחוות",
|
"gesture_controls_title": "פקדי מחוות",
|
||||||
"horizontal_swipe_skip": "החלקה אופקית לדילוג",
|
"horizontal_swipe_skip": "החלקה אופקית לדילוג",
|
||||||
@@ -188,7 +229,7 @@
|
|||||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "אודיו",
|
"audio_title": "שמע",
|
||||||
"set_audio_track": "בחר רצועת שמע מהפריט הקודם",
|
"set_audio_track": "בחר רצועת שמע מהפריט הקודם",
|
||||||
"audio_language": "שפת שמע",
|
"audio_language": "שפת שמע",
|
||||||
"audio_hint": "בחר שפת שמע אוטומטית.",
|
"audio_hint": "בחר שפת שמע אוטומטית.",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -271,8 +328,8 @@
|
|||||||
"margin": "Bottom Margin"
|
"margin": "Bottom Margin"
|
||||||
},
|
},
|
||||||
"video_player": {
|
"video_player": {
|
||||||
"title": "Video Player",
|
"title": "נגן וידאו",
|
||||||
"video_player": "Video Player",
|
"video_player": "נגן וידאו",
|
||||||
"video_player_description": "Choose which video player to use on iOS.",
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
"ksplayer": "KSPlayer",
|
"ksplayer": "KSPlayer",
|
||||||
"vlc": "VLC"
|
"vlc": "VLC"
|
||||||
@@ -314,7 +371,7 @@
|
|||||||
"downloads_title": "הורדות"
|
"downloads_title": "הורדות"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "מוזיקה",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
"playback_description": "Configure how music is played.",
|
"playback_description": "Configure how music is played.",
|
||||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||||
@@ -406,10 +463,16 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "הקדמה",
|
||||||
"show_intro": "הצג פתיח",
|
"show_intro": "הצג פתיח",
|
||||||
"reset_intro": "אפס פתיח"
|
"reset_intro": "אפס פתיח"
|
||||||
},
|
},
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "שגיאה במחיקת קבצים",
|
"error_deleting_files": "שגיאה במחיקת קבצים",
|
||||||
"background_downloads_enabled": "הורדה ברקע מופעלת",
|
"background_downloads_enabled": "הורדה ברקע מופעלת",
|
||||||
"background_downloads_disabled": "הורדה ברקע כבויה"
|
"background_downloads_disabled": "הורדה ברקע כבויה"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.",
|
"new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.",
|
||||||
"back": "חזרה",
|
"back": "חזרה",
|
||||||
"delete": "מחק",
|
"delete": "מחק",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "משהו השתבש",
|
"something_went_wrong": "משהו השתבש",
|
||||||
"could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin",
|
||||||
"eta": "זמן משוער {{eta}}",
|
"eta": "זמן משוער {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "בחר",
|
"select": "בחר",
|
||||||
"no_trailer_available": "אין טריילר זמין",
|
"no_trailer_available": "אין טריילר זמין",
|
||||||
"video": "וידאו",
|
"video": "וידאו",
|
||||||
"audio": "אודיו",
|
"audio": "שמע",
|
||||||
"subtitle": "כתובית",
|
"subtitle": "כתובית",
|
||||||
"play": "נגן",
|
"play": "נגן",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "ללא",
|
"none": "ללא",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "חפש...",
|
"search": "חפש...",
|
||||||
@@ -521,9 +606,9 @@
|
|||||||
"episodes": "פרקים",
|
"episodes": "פרקים",
|
||||||
"collections": "אוספים",
|
"collections": "אוספים",
|
||||||
"actors": "שחקנים",
|
"actors": "שחקנים",
|
||||||
"artists": "Artists",
|
"artists": "אומנים",
|
||||||
"albums": "Albums",
|
"albums": "אלבומים",
|
||||||
"songs": "Songs",
|
"songs": "שירים",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"request_movies": "סרטים מבוקשים",
|
"request_movies": "סרטים מבוקשים",
|
||||||
"request_series": "סדרות מבוקשים",
|
"request_series": "סדרות מבוקשים",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "סרטים",
|
"movies": "סרטים",
|
||||||
"series": "סדרות",
|
"series": "סדרות",
|
||||||
"boxsets": "אוסף",
|
"boxsets": "אוסף",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "פריטים"
|
"items": "פריטים"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "פוסטר",
|
"poster": "פוסטר",
|
||||||
"cover": "עטיפה",
|
"cover": "עטיפה",
|
||||||
"show_titles": "הצג כותרות",
|
"show_titles": "הצג כותרות",
|
||||||
"show_stats": "הצג סטטיסטיקה"
|
"show_stats": "הצג סטטיסטיקה",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "סגנונות",
|
"genres": "סגנונות",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "מיין לפי",
|
"sort_by": "מיין לפי",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "סדר מיון",
|
"sort_order": "סדר מיון",
|
||||||
"tags": "תגים"
|
"tags": "תגים",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "אין קישורים"
|
"no_links": "אין קישורים"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "שגיאה",
|
"error": "שגיאה",
|
||||||
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
|
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
|
||||||
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
|
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
|
||||||
@@ -606,9 +699,37 @@
|
|||||||
"go_back": "חזור",
|
"go_back": "חזור",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "כן",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "לא",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "הבא בתור",
|
"next_up": "הבא בתור",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "סדרות",
|
"series": "סדרות",
|
||||||
"seasons": "עונות",
|
"seasons": "עונות",
|
||||||
"season": "עונה",
|
"season": "עונה",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "אין פרקים בעונה זו",
|
"no_episodes_for_this_season": "אין פרקים בעונה זו",
|
||||||
"overview": "סקירה",
|
"overview": "סקירה",
|
||||||
"more_with": "עוד עם {{name}}",
|
"more_with": "עוד עם {{name}}",
|
||||||
@@ -626,11 +752,22 @@
|
|||||||
"more_details": "פרטים נוספים",
|
"more_details": "פרטים נוספים",
|
||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "איכות",
|
"quality": "איכות",
|
||||||
"audio": "אודיו",
|
"audio": "שמע",
|
||||||
"subtitles": "כתובית",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "הצג עוד",
|
"show_more": "הצג עוד",
|
||||||
"show_less": "הצג פחות",
|
"show_less": "הצג פחות",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "הופיע ב-",
|
"appeared_in": "הופיע ב-",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "נכשל בטעינת פריט",
|
"could_not_load_item": "נכשל בטעינת פריט",
|
||||||
"none": "ללא",
|
"none": "ללא",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "הורד {{item_count}} פריטים",
|
"download_x_item": "הורד {{item_count}} פריטים",
|
||||||
"download_unwatched_only": "רק שלא נצפו",
|
"download_unwatched_only": "רק שלא נצפו",
|
||||||
"download_button": "הורד"
|
"download_button": "הורד"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "הבא",
|
"next": "הבא",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "סרטים",
|
"movies": "סרטים",
|
||||||
"sports": "ספורט",
|
"sports": "ספורט",
|
||||||
"for_kids": "לילדים",
|
"for_kids": "לילדים",
|
||||||
"news": "חדשות"
|
"news": "חדשות",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "אשר",
|
"confirm": "אשר",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
|
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
|
||||||
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
|
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
|
||||||
@@ -716,13 +876,14 @@
|
|||||||
"search": "חיפוש",
|
"search": "חיפוש",
|
||||||
"library": "ספריה",
|
"library": "ספריה",
|
||||||
"custom_links": "קישורים מותאמים אישית",
|
"custom_links": "קישורים מותאמים אישית",
|
||||||
"favorites": "מועדפים"
|
"favorites": "מועדפים",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "מוזיקה",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Albums",
|
"albums": "אלבומים",
|
||||||
"artists": "Artists",
|
"artists": "Artists",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
@@ -798,9 +959,9 @@
|
|||||||
"delete_button": "Delete",
|
"delete_button": "Delete",
|
||||||
"remove_button": "Remove",
|
"remove_button": "Remove",
|
||||||
"cancel_button": "Cancel",
|
"cancel_button": "Cancel",
|
||||||
"name_label": "Name",
|
"name_label": "שם",
|
||||||
"name_placeholder": "Enter watchlist name",
|
"name_placeholder": "Enter watchlist name",
|
||||||
"description_label": "Description",
|
"description_label": "תיאור",
|
||||||
"description_placeholder": "Enter description (optional)",
|
"description_placeholder": "Enter description (optional)",
|
||||||
"is_public_label": "Public Watchlist",
|
"is_public_label": "Public Watchlist",
|
||||||
"is_public_description": "Allow others to view this watchlist",
|
"is_public_description": "Allow others to view this watchlist",
|
||||||
@@ -817,10 +978,10 @@
|
|||||||
"remove_from_watchlist": "Remove from Watchlist",
|
"remove_from_watchlist": "Remove from Watchlist",
|
||||||
"select_watchlist": "Select Watchlist",
|
"select_watchlist": "Select Watchlist",
|
||||||
"create_new": "Create New Watchlist",
|
"create_new": "Create New Watchlist",
|
||||||
"item": "item",
|
"item": "פריט",
|
||||||
"items": "items",
|
"items": "פריטים",
|
||||||
"public": "Public",
|
"public": "ציבורי",
|
||||||
"private": "Private",
|
"private": "פרטי",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
"by_owner": "By another user",
|
"by_owner": "By another user",
|
||||||
"not_found": "Watchlist not found",
|
"not_found": "Watchlist not found",
|
||||||
@@ -835,11 +996,42 @@
|
|||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "Playback Speed",
|
"title": "Playback Speed",
|
||||||
"apply_to": "Apply To",
|
"apply_to": "Apply To",
|
||||||
"speed": "Speed",
|
"speed": "מהירות",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "This media only",
|
"media": "This media only",
|
||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"error_title": "Hiba",
|
"error_title": "Hiba",
|
||||||
"login_title": "Bejelentkezés",
|
"login_title": "Bejelentkezés",
|
||||||
"login_to_title": "Bejelentkezés ide",
|
"login_to_title": "Bejelentkezés ide",
|
||||||
|
"select_user": "Select a user to log in",
|
||||||
|
"add_user_to_login": "Add a user to log in",
|
||||||
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Felhasználónév",
|
"username_placeholder": "Felhasználónév",
|
||||||
"password_placeholder": "Jelszó",
|
"password_placeholder": "Jelszó",
|
||||||
"login_button": "Bejelentkezés",
|
"login_button": "Bejelentkezés",
|
||||||
@@ -42,7 +45,13 @@
|
|||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
||||||
|
"remove_server": "Remove Server",
|
||||||
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
|
"select_your_server": "Select Your Server",
|
||||||
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"oops": "Hoppá!",
|
"oops": "Hoppá!",
|
||||||
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
|
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
|
||||||
"continue_watching": "Nézd Tovább",
|
"continue_watching": "Nézd Tovább",
|
||||||
|
"continue": "Continue",
|
||||||
"next_up": "Következő",
|
"next_up": "Következő",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
|
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
|
||||||
@@ -109,6 +119,12 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Beállítások",
|
"settings_title": "Beállítások",
|
||||||
"log_out_button": "Kijelentkezés",
|
"log_out_button": "Kijelentkezés",
|
||||||
|
"switch_user": {
|
||||||
|
"title": "Switch User",
|
||||||
|
"account": "Account",
|
||||||
|
"switch_user": "Switch User on This Server",
|
||||||
|
"current": "current"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
},
|
},
|
||||||
@@ -121,7 +137,16 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
|
"show_hero_carousel": "Hero Carousel",
|
||||||
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
|
"theme_music": "Theme Music",
|
||||||
|
"display_size": "Display Size",
|
||||||
|
"display_size_small": "Small",
|
||||||
|
"display_size_default": "Default",
|
||||||
|
"display_size_large": "Large",
|
||||||
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
@@ -174,6 +199,22 @@
|
|||||||
"rewind_length": "Visszatekerés Hossza",
|
"rewind_length": "Visszatekerés Hossza",
|
||||||
"seconds_unit": "mp"
|
"seconds_unit": "mp"
|
||||||
},
|
},
|
||||||
|
"buffer": {
|
||||||
|
"title": "Buffer Settings",
|
||||||
|
"cache_mode": "Cache Mode",
|
||||||
|
"cache_auto": "Auto",
|
||||||
|
"cache_yes": "Enabled",
|
||||||
|
"cache_no": "Disabled",
|
||||||
|
"buffer_duration": "Buffer Duration",
|
||||||
|
"max_cache_size": "Max Cache Size",
|
||||||
|
"max_backward_cache": "Max Backward Cache"
|
||||||
|
},
|
||||||
|
"vo_driver": {
|
||||||
|
"title": "Video Output",
|
||||||
|
"vo_mode": "VO Driver",
|
||||||
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
|
"gpu": "gpu"
|
||||||
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Gesztusvezérlés",
|
"gesture_controls_title": "Gesztusvezérlés",
|
||||||
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
|
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
|
||||||
@@ -256,7 +297,23 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
|
"opensubtitles_api_key": "API Key",
|
||||||
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
|
"align": {
|
||||||
|
"left": "Left",
|
||||||
|
"center": "Center",
|
||||||
|
"right": "Right",
|
||||||
|
"top": "Top",
|
||||||
|
"bottom": "Bottom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -406,7 +463,13 @@
|
|||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
|
"clear_all_cache": "Clear All Cache",
|
||||||
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -430,6 +493,21 @@
|
|||||||
"error_deleting_files": "Hiba a Fájlok Törlésekor",
|
"error_deleting_files": "Hiba a Fájlok Törlésekor",
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
"background_downloads_enabled": "Background downloads enabled",
|
||||||
"background_downloads_disabled": "Background downloads disabled"
|
"background_downloads_disabled": "Background downloads disabled"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Security",
|
||||||
|
"inactivity_timeout": {
|
||||||
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"1_minute": "1 minute",
|
||||||
|
"5_minutes": "5 minutes",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"4_hours": "4 hours",
|
||||||
|
"24_hours": "24 hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -456,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.",
|
"new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Hiba Történt",
|
"something_went_wrong": "Hiba Történt",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből",
|
"could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből",
|
||||||
"eta": "Várható Idő: {{eta}}",
|
"eta": "Várható Idő: {{eta}}",
|
||||||
@@ -492,22 +571,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"no_results": "No Results",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Videó",
|
"video": "Videó",
|
||||||
"audio": "Hang",
|
"audio": "Hang",
|
||||||
"subtitle": "Felirat",
|
"subtitle": "Felirat",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying..."
|
"verifying": "Verifying...",
|
||||||
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Keresés...",
|
"search": "Keresés...",
|
||||||
@@ -556,6 +641,7 @@
|
|||||||
"movies": "Filmek",
|
"movies": "Filmek",
|
||||||
"series": "Sorozatok",
|
"series": "Sorozatok",
|
||||||
"boxsets": "Gyűjtemények",
|
"boxsets": "Gyűjtemények",
|
||||||
|
"playlists": "Playlists",
|
||||||
"items": "Elemek"
|
"items": "Elemek"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -566,7 +652,8 @@
|
|||||||
"poster": "Poszter",
|
"poster": "Poszter",
|
||||||
"cover": "Borító",
|
"cover": "Borító",
|
||||||
"show_titles": "Címek Megjelenítése",
|
"show_titles": "Címek Megjelenítése",
|
||||||
"show_stats": "Statisztikák Megjelenítése"
|
"show_stats": "Statisztikák Megjelenítése",
|
||||||
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Műfajok",
|
"genres": "Műfajok",
|
||||||
@@ -574,7 +661,11 @@
|
|||||||
"sort_by": "Rendezés",
|
"sort_by": "Rendezés",
|
||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Rendezés Iránya",
|
"sort_order": "Rendezés Iránya",
|
||||||
"tags": "Címkék"
|
"tags": "Címkék",
|
||||||
|
"all": "All",
|
||||||
|
"reset": "Reset",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,6 +682,8 @@
|
|||||||
"no_links": "Nincsenek Linkek"
|
"no_links": "Nincsenek Linkek"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Hiba",
|
"error": "Hiba",
|
||||||
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
|
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
|
||||||
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
|
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Yes",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Ends at {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Következő",
|
"next_up": "Következő",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Sorozat",
|
"series": "Sorozat",
|
||||||
"seasons": "Évadok",
|
"seasons": "Évadok",
|
||||||
"season": "Évad",
|
"season": "Évad",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
|
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
|
||||||
"overview": "Áttekintés",
|
"overview": "Áttekintés",
|
||||||
"more_with": "További {{name}} Alkotások",
|
"more_with": "További {{name}} Alkotások",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Media Options",
|
"media_options": "Media Options",
|
||||||
"quality": "Minőség",
|
"quality": "Minőség",
|
||||||
"audio": "Hang",
|
"audio": "Hang",
|
||||||
"subtitles": "Felirat",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Több Megjelenítése",
|
"show_more": "Több Megjelenítése",
|
||||||
"show_less": "Kevesebb Megjelenítése",
|
"show_less": "Kevesebb Megjelenítése",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Megjelent:",
|
"appeared_in": "Megjelent:",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
|
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
|
||||||
"none": "Nincs",
|
"none": "Nincs",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "{{item_count}} Elem Letöltése",
|
"download_x_item": "{{item_count}} Elem Letöltése",
|
||||||
"download_unwatched_only": "Csak Nem Megtekintett",
|
"download_unwatched_only": "Csak Nem Megtekintett",
|
||||||
"download_button": "Letöltés"
|
"download_button": "Letöltés"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Filmek",
|
"movies": "Filmek",
|
||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Gyerekeknek",
|
"for_kids": "Gyerekeknek",
|
||||||
"news": "Hírek"
|
"news": "Hírek",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Megerősítés",
|
"confirm": "Megerősítés",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"requested_by": "Requested by {{user}}",
|
"requested_by": "Requested by {{user}}",
|
||||||
"unknown_user": "Unknown User",
|
"unknown_user": "Unknown User",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
|
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
|
||||||
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
|
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Keresés",
|
"search": "Keresés",
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
"custom_links": "Egyéni Linkek",
|
"custom_links": "Egyéni Linkek",
|
||||||
"favorites": "Kedvencek"
|
"favorites": "Kedvencek",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "This show",
|
"show": "This show",
|
||||||
"all": "All media (default)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user