mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Compare commits
161 Commits
renovate/r
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e35785a5c | ||
|
|
c7c3aa8a34 | ||
|
|
211923b2ab | ||
|
|
f4a68bca10 | ||
|
|
985cb0f337 | ||
|
|
46bd2a784e | ||
|
|
0a36fdfbec | ||
|
|
45d1f752d6 | ||
|
|
54ee507209 | ||
|
|
338fb9713b | ||
|
|
939fd2512d | ||
|
|
32c99de874 | ||
|
|
c232e433bf | ||
|
|
07e2faff07 | ||
|
|
8507699cdd | ||
|
|
21fb056586 | ||
|
|
1d79b513f3 | ||
|
|
863dffd944 | ||
|
|
6aa0868bfd | ||
|
|
6b7ee0514f | ||
|
|
c663bd0413 | ||
|
|
52e6f56220 | ||
|
|
c981f59a50 | ||
|
|
62fc6f9a70 | ||
|
|
eb8dd51b4e | ||
|
|
ea5a999f21 | ||
|
|
dffcdef945 | ||
|
|
fa1c3f3947 | ||
|
|
2761de5a74 | ||
|
|
feca1d7e9c | ||
|
|
6b6bfd1a89 | ||
|
|
d585b20f49 | ||
|
|
692ccfdb2c | ||
|
|
86e39c444c | ||
|
|
ed7928b4d3 | ||
|
|
27dc7b5664 | ||
|
|
a205c75895 | ||
|
|
58f0877cfe | ||
|
|
2c2a7137d3 | ||
|
|
56e350891d | ||
|
|
d9e25135c4 | ||
|
|
84246e9dde | ||
|
|
57cfa5ce78 | ||
|
|
0ba3d19550 | ||
|
|
58e2418120 | ||
|
|
6c00a0348a | ||
|
|
276ba1e4c5 | ||
|
|
41ab4de833 | ||
|
|
abe4981126 | ||
|
|
a9d8f753d4 | ||
|
|
ee5c9ae19f | ||
|
|
d661a9ff7a | ||
|
|
4939d05e69 | ||
|
|
7201002dd5 | ||
|
|
03d2917ca0 | ||
|
|
74315a8b94 | ||
|
|
53c4f317cc | ||
|
|
335a373034 | ||
|
|
55595bea9b | ||
|
|
0cf6630af9 | ||
|
|
41f6116ba8 | ||
|
|
1e3311fea9 | ||
|
|
e400378684 | ||
|
|
21c0fb4b6c | ||
|
|
b9e87e51cc | ||
|
|
c3a9b451b6 | ||
|
|
418bd506c0 | ||
|
|
b0e92d8689 | ||
|
|
4ae656818c | ||
|
|
99527e1fae | ||
|
|
1ca6e0853b | ||
|
|
f99ce8210c | ||
|
|
674e252641 | ||
|
|
119b7ad937 | ||
|
|
788a3b7cfd | ||
|
|
8b94f491e4 | ||
|
|
e9f61a2f7c | ||
|
|
6ca1f63877 | ||
|
|
0cc3a8469d | ||
|
|
b38064e2da | ||
|
|
5b823a8efd | ||
|
|
750caba038 | ||
|
|
d3ee6c8239 | ||
|
|
7e2ef0f2da | ||
|
|
ca2e657eac | ||
|
|
288b390e5b | ||
|
|
c04924fe9e | ||
|
|
525a6b39fa | ||
|
|
1ea7f0f491 | ||
|
|
79c2829444 | ||
|
|
87e0b0006b | ||
|
|
3c71c08591 | ||
|
|
9f4f0fa7d1 | ||
|
|
0d922b75d6 | ||
|
|
0ee1d43d16 | ||
|
|
ec49d03cf1 | ||
|
|
02df2477d8 | ||
|
|
8c9506c7b5 | ||
|
|
b225286f57 | ||
|
|
23b4f20d18 | ||
|
|
88d96603e4 | ||
|
|
6e513b8f9e | ||
|
|
4f50ec6665 | ||
|
|
0e25a5936c | ||
|
|
e9fee79130 | ||
|
|
3d65c3bb7a | ||
|
|
e5d61bf3ea | ||
|
|
5eac91190e | ||
|
|
95d63e3c8a | ||
|
|
6d0ca44308 | ||
|
|
73214f5d45 | ||
|
|
5cfd110ad5 | ||
|
|
6e63afc61a | ||
|
|
bcf6b705e1 | ||
|
|
fb8c649f6f | ||
|
|
6ecadecb87 | ||
|
|
e3f105691b | ||
|
|
bcfa8c6d63 | ||
|
|
17450e3811 | ||
|
|
9759d84aa2 | ||
|
|
28d8b28c73 | ||
|
|
a4e47e5cb7 | ||
|
|
fcd7e46599 | ||
|
|
a841619d78 | ||
|
|
6c72a2803f | ||
|
|
6bf00abb9b | ||
|
|
ac405af3b2 | ||
|
|
9ec81cfa1d | ||
|
|
28bf1489c1 | ||
|
|
68d64fec9c | ||
|
|
9dcbcdc41d | ||
|
|
99775b353f | ||
|
|
7589ccd284 | ||
|
|
d4f730fc54 | ||
|
|
e002381706 | ||
|
|
838a248d28 | ||
|
|
a5c72011a8 | ||
|
|
51bd8a92da | ||
|
|
515e05015f | ||
|
|
7126564f72 | ||
|
|
6894decdba | ||
|
|
72c050b9a5 | ||
|
|
1da3d7cfc6 | ||
|
|
594a1d04aa | ||
|
|
9efe12637b | ||
|
|
9398f5f104 | ||
|
|
18600a4956 | ||
|
|
e54bdd048b | ||
|
|
e35623c46c | ||
|
|
25730a24d6 | ||
|
|
5a1fe51ad7 | ||
|
|
8dc0421e22 | ||
|
|
4125924aa6 | ||
|
|
eeb27cbaf6 | ||
|
|
88b79920bf | ||
|
|
203c6d59b0 | ||
|
|
bb491d4e86 | ||
|
|
0b6639fc4e | ||
|
|
71d922beeb | ||
|
|
5e60b6c2f8 | ||
|
|
da70541c8e |
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
|
||||||
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||||
|
|
||||||
## Main Technologies
|
## Main Technologies
|
||||||
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||||
- `plugins/` – Expo/Metro plugins
|
- `plugins/` – Expo/Metro plugins
|
||||||
|
|
||||||
## Coding Standards
|
## Code Quality Standards
|
||||||
|
|
||||||
|
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
|
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||||
|
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||||
|
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||||
|
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||||
|
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||||
|
- Enable and respect strict TypeScript compiler options
|
||||||
|
- Define explicit return types for functions
|
||||||
|
- Use discriminated unions for complex state
|
||||||
|
|
||||||
|
### Code Reliability
|
||||||
|
- Implement comprehensive error handling with try-catch blocks
|
||||||
|
- Validate all external inputs (API responses, user input, query params)
|
||||||
|
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||||
|
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||||
|
- Add runtime checks for critical operations
|
||||||
|
- Implement proper loading and error states in components
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- Follow BiomeJS formatting and linting rules
|
- Follow BiomeJS formatting and linting rules
|
||||||
- Use `const` over `let`, avoid `var` entirely
|
- Use `const` over `let`, avoid `var` entirely
|
||||||
- Implement proper error boundaries
|
- Implement proper error boundaries
|
||||||
- Use React.memo() for performance optimization
|
- Use React.memo() for performance optimization when needed
|
||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Write self-documenting code with clear intent
|
||||||
|
- Add comments only when code complexity requires explanation
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -85,6 +108,18 @@ Exemples:
|
|||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
- `chore(deps): update Jellyfin SDK`
|
- `chore(deps): update Jellyfin SDK`
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||||
|
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||||
|
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||||
|
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||||
|
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||||
|
- **Crowdin workflow**:
|
||||||
|
- New keys added to `en.json` sync to Crowdin automatically
|
||||||
|
- Approved translations sync back to language files via GitHub integration
|
||||||
|
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||||
|
|
||||||
## Special Instructions
|
## Special Instructions
|
||||||
|
|
||||||
- Prioritize cross-platform compatibility (mobile + TV)
|
- Prioritize cross-platform compatibility (mobile + TV)
|
||||||
|
|||||||
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
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.0",
|
"version": "0.54.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"versionCode": 93,
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -144,8 +143,8 @@
|
|||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit-GPL",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,17 +59,19 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<View className='mb-4'>
|
{Platform.OS !== "ios" && (
|
||||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
<View className='mb-4'>
|
||||||
<ListItem
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
onPress={() =>
|
<ListItem
|
||||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
onPress={() =>
|
||||||
}
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
title={t("pairing.pair_with_phone")}
|
}
|
||||||
textColor='blue'
|
title={t("pairing.pair_with_phone")}
|
||||||
/>
|
textColor='blue'
|
||||||
</ListGroup>
|
/>
|
||||||
</View>
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create skip options for a specific segment type
|
||||||
|
* Reduces code duplication across all 5 segment types
|
||||||
|
*/
|
||||||
|
const useSkipOptions = (
|
||||||
|
settingKey:
|
||||||
|
| "skipIntro"
|
||||||
|
| "skipOutro"
|
||||||
|
| "skipRecap"
|
||||||
|
| "skipCommercial"
|
||||||
|
| "skipPreview",
|
||||||
|
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||||
|
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||||
|
t: TFunction<"translation", undefined>,
|
||||||
|
) => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
selected: option.value === settings?.[settingKey],
|
||||||
|
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SegmentSkipPage() {
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: t("home.settings.other.segment_skip_settings"),
|
||||||
|
});
|
||||||
|
}, [navigation, t]);
|
||||||
|
|
||||||
|
const skipIntroOptions = useSkipOptions(
|
||||||
|
"skipIntro",
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const skipOutroOptions = useSkipOptions(
|
||||||
|
"skipOutro",
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const skipRecapOptions = useSkipOptions(
|
||||||
|
"skipRecap",
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const skipCommercialOptions = useSkipOptions(
|
||||||
|
"skipCommercial",
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const skipPreviewOptions = useSkipOptions(
|
||||||
|
"skipPreview",
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting disabled={false} className='px-4'>
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.skip_intro")}
|
||||||
|
subtitle={t("home.settings.other.skip_intro_description")}
|
||||||
|
disabled={pluginSettings?.skipIntro?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={skipIntroOptions}
|
||||||
|
disabled={pluginSettings?.skipIntro?.locked}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.skip_intro")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.skip_outro")}
|
||||||
|
subtitle={t("home.settings.other.skip_outro_description")}
|
||||||
|
disabled={pluginSettings?.skipOutro?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={skipOutroOptions}
|
||||||
|
disabled={pluginSettings?.skipOutro?.locked}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.skip_outro")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.skip_recap")}
|
||||||
|
subtitle={t("home.settings.other.skip_recap_description")}
|
||||||
|
disabled={pluginSettings?.skipRecap?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={skipRecapOptions}
|
||||||
|
disabled={pluginSettings?.skipRecap?.locked}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.skip_recap")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.skip_commercial")}
|
||||||
|
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||||
|
disabled={pluginSettings?.skipCommercial?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={skipCommercialOptions}
|
||||||
|
disabled={pluginSettings?.skipCommercial?.locked}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(
|
||||||
|
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.skip_commercial")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.skip_preview")}
|
||||||
|
subtitle={t("home.settings.other.skip_preview_description")}
|
||||||
|
disabled={pluginSettings?.skipPreview?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={skipPreviewOptions}
|
||||||
|
disabled={pluginSettings?.skipPreview?.locked}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{t(
|
||||||
|
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.skip_preview")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENT_SKIP_OPTIONS = (
|
||||||
|
t: TFunction<"translation", undefined>,
|
||||||
|
): Array<{
|
||||||
|
label: string;
|
||||||
|
value: "none" | "ask" | "auto";
|
||||||
|
}> => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.other.segment_skip_auto"),
|
||||||
|
value: "auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.other.segment_skip_ask"),
|
||||||
|
value: "ask",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.other.segment_skip_none"),
|
||||||
|
value: "none",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
||||||
|
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -139,6 +141,8 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
<CastingMiniPlayer />
|
||||||
|
<CastAutoplayWatcher />
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
768
app/(auth)/casting-player.tsx
Normal file
768
app/(auth)/casting-player.tsx
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Player Modal
|
||||||
|
* Protocol-agnostic full-screen player for all supported casting protocols
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { router, Stack } from "expo-router";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||||
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import GoogleCast, {
|
||||||
|
CastState,
|
||||||
|
MediaPlayerState,
|
||||||
|
useCastDevice,
|
||||||
|
useCastState,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import Animated from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
||||||
|
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
||||||
|
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
||||||
|
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
||||||
|
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
||||||
|
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
||||||
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
|
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
||||||
|
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
||||||
|
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
||||||
|
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||||
|
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
||||||
|
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
||||||
|
import { useCasting } from "@/hooks/useCasting";
|
||||||
|
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||||
|
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
||||||
|
import { useCastSelection } from "@/hooks/useCastSelection";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { detectCapabilities } from "@/utils/casting/capabilities";
|
||||||
|
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||||
|
import { getPosterUrl } from "@/utils/casting/helpers";
|
||||||
|
import { resolveSelection } from "@/utils/casting/selection";
|
||||||
|
import type { CastSelection } from "@/utils/casting/types";
|
||||||
|
import { chapterMarkers } from "@/utils/chapters";
|
||||||
|
import {
|
||||||
|
type PlaybackController,
|
||||||
|
useRegisterPlaybackController,
|
||||||
|
} from "@/utils/playback/playbackController";
|
||||||
|
|
||||||
|
export default function CastingPlayerScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
||||||
|
// the overlay here when set, and handle Play-now / Cancel from the user.
|
||||||
|
const castAutoplay = useAtomValue(castAutoplayAtom);
|
||||||
|
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
||||||
|
|
||||||
|
// Get raw Chromecast state directly - same as old implementation
|
||||||
|
const castState = useCastState();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||||
|
useRemoteMediaClient();
|
||||||
|
|
||||||
|
// Fetch full item data from Jellyfin by ID and derive the effective item
|
||||||
|
const { fetchedItem, currentItem } = useCastPlayerItem({
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
mediaStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive state from raw Chromecast hooks
|
||||||
|
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
||||||
|
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||||
|
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||||
|
const currentDevice = castDevice?.friendlyName ?? null;
|
||||||
|
|
||||||
|
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
||||||
|
// live-progress interpolation, resume-position tracking, trickplay preview.
|
||||||
|
const {
|
||||||
|
sliderProgress,
|
||||||
|
sliderMin,
|
||||||
|
sliderMax,
|
||||||
|
isScrubbing,
|
||||||
|
trickplayTime,
|
||||||
|
setTrickplayTime,
|
||||||
|
progress,
|
||||||
|
resumePositionRef,
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
||||||
|
|
||||||
|
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||||
|
const castingControls = useCasting(currentItem);
|
||||||
|
const {
|
||||||
|
togglePlayPause,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
setVolume,
|
||||||
|
volume,
|
||||||
|
remoteMediaClient,
|
||||||
|
} = currentItem
|
||||||
|
? castingControls
|
||||||
|
: {
|
||||||
|
togglePlayPause: async () => {},
|
||||||
|
skipForward: async () => {},
|
||||||
|
skipBackward: async () => {},
|
||||||
|
setVolume: () => {},
|
||||||
|
volume: 1,
|
||||||
|
remoteMediaClient: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
||||||
|
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
|
// Chapter markers (shown for both episodes and movies).
|
||||||
|
const chapters = currentItem?.Chapters;
|
||||||
|
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
||||||
|
|
||||||
|
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
||||||
|
|
||||||
|
// Reload the cast stream with a full selection; resolves true on success.
|
||||||
|
const reloadWithSelection = useCallback(
|
||||||
|
async (selection: CastSelection): Promise<boolean> => {
|
||||||
|
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
||||||
|
console.warn("[Casting Player] Cannot reload - missing required data");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentPosition = resumePositionRef.current;
|
||||||
|
const result = await loadCastMedia({
|
||||||
|
client: remoteMediaClient,
|
||||||
|
device: castDevice,
|
||||||
|
api,
|
||||||
|
item: currentItem,
|
||||||
|
userId: user.Id,
|
||||||
|
profileMode: settings.chromecastProfile,
|
||||||
|
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||||
|
options: {
|
||||||
|
mediaSourceId: selection.mediaSourceId,
|
||||||
|
audioStreamIndex: selection.audioStreamIndex,
|
||||||
|
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||||
|
maxBitrate: selection.maxBitrate,
|
||||||
|
startPositionMs: currentPosition * 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(
|
||||||
|
"[Casting Player] Failed to reload stream:",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
currentItem,
|
||||||
|
remoteMediaClient,
|
||||||
|
castDevice,
|
||||||
|
settings.chromecastProfile,
|
||||||
|
settings.chromecastMaxBitrate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { currentSelection, applySelection } = useCastSelection({
|
||||||
|
currentItem,
|
||||||
|
mediaStatus,
|
||||||
|
reload: reloadWithSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Episode/season cluster: episode list, next episode, season data, loader
|
||||||
|
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
||||||
|
useCastEpisodes({
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
currentItem,
|
||||||
|
remoteMediaClient,
|
||||||
|
castDevice,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
||||||
|
// cast customData) still describes the previous episode. Used to suppress
|
||||||
|
// episode-dependent secondary UI that would otherwise flash stale data.
|
||||||
|
const isEpisodeTransitioning =
|
||||||
|
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
||||||
|
|
||||||
|
// Expose this player to the app-wide remote-control surface while a cast
|
||||||
|
// session is connected. The individual useCasting methods are each
|
||||||
|
// useCallback-wrapped and stable, so depend on them directly rather than on
|
||||||
|
// the whole `castingControls` object literal (rebuilt every render).
|
||||||
|
const {
|
||||||
|
togglePlayPause: castTogglePlayPause,
|
||||||
|
pause: castPause,
|
||||||
|
play: castPlay,
|
||||||
|
stop: castStop,
|
||||||
|
seek: castSeek,
|
||||||
|
setVolume: castSetVolume,
|
||||||
|
} = castingControls;
|
||||||
|
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
||||||
|
const volumeRef = useRef(volume);
|
||||||
|
volumeRef.current = volume;
|
||||||
|
|
||||||
|
const castController = useMemo<PlaybackController>(
|
||||||
|
() => ({
|
||||||
|
playPause: () => {
|
||||||
|
castTogglePlayPause();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
castPause();
|
||||||
|
},
|
||||||
|
unpause: () => {
|
||||||
|
castPlay();
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
castStop();
|
||||||
|
},
|
||||||
|
seek: (positionMs) => {
|
||||||
|
castSeek(positionMs);
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
if (nextEpisode) loadEpisode(nextEpisode);
|
||||||
|
},
|
||||||
|
previous: () => {
|
||||||
|
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
||||||
|
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
||||||
|
},
|
||||||
|
setVolume: (level) => {
|
||||||
|
castSetVolume(level);
|
||||||
|
},
|
||||||
|
toggleMute: () => {
|
||||||
|
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
castTogglePlayPause,
|
||||||
|
castPause,
|
||||||
|
castPlay,
|
||||||
|
castStop,
|
||||||
|
castSeek,
|
||||||
|
castSetVolume,
|
||||||
|
episodes,
|
||||||
|
nextEpisode,
|
||||||
|
loadEpisode,
|
||||||
|
currentItem?.Id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useRegisterPlaybackController(
|
||||||
|
castController,
|
||||||
|
castState === CastState.CONNECTED,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The MediaSource currently selected, for deriving its tracks.
|
||||||
|
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
||||||
|
// MediaStreams, so only the full fetched item yields correct track lists.
|
||||||
|
const selectedSource = useMemo(
|
||||||
|
() =>
|
||||||
|
fetchedItem?.MediaSources?.find(
|
||||||
|
(s) => s.Id === currentSelection?.mediaSourceId,
|
||||||
|
) ??
|
||||||
|
fetchedItem?.MediaSources?.[0] ??
|
||||||
|
null,
|
||||||
|
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Real alternate versions (multi-version items).
|
||||||
|
const availableVersions = useMemo(
|
||||||
|
() =>
|
||||||
|
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
||||||
|
id: s.Id ?? `source-${i}`,
|
||||||
|
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
||||||
|
})),
|
||||||
|
[fetchedItem?.MediaSources, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quality tiers from the shared ladder, capped to BOTH the device's
|
||||||
|
// capability and the media's own bitrate — a tier above either ceiling
|
||||||
|
// would behave identically to "Max", so it is not offered.
|
||||||
|
const availableQualities = useMemo(() => {
|
||||||
|
const caps = detectCapabilities(castDevice, {
|
||||||
|
profileMode: settings.chromecastProfile,
|
||||||
|
maxBitrate: settings.chromecastMaxBitrate,
|
||||||
|
});
|
||||||
|
const mediaBitrate =
|
||||||
|
selectedSource?.Bitrate ??
|
||||||
|
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
||||||
|
Number.POSITIVE_INFINITY;
|
||||||
|
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
||||||
|
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
||||||
|
}, [
|
||||||
|
castDevice,
|
||||||
|
settings.chromecastProfile,
|
||||||
|
settings.chromecastMaxBitrate,
|
||||||
|
selectedSource,
|
||||||
|
fetchedItem?.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const availableAudioTracks = useMemo(() => {
|
||||||
|
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||||
|
if (!streams) return [];
|
||||||
|
return streams
|
||||||
|
.filter((stream) => stream.Type === "Audio")
|
||||||
|
.map((stream) => ({
|
||||||
|
index: stream.Index ?? 0,
|
||||||
|
language: stream.Language || "Unknown",
|
||||||
|
displayTitle:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
||||||
|
codec: stream.Codec || "Unknown",
|
||||||
|
channels: stream.Channels,
|
||||||
|
bitrate: stream.BitRate,
|
||||||
|
}));
|
||||||
|
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||||
|
|
||||||
|
const availableSubtitleTracks = useMemo(() => {
|
||||||
|
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||||
|
if (!streams) return [];
|
||||||
|
return streams
|
||||||
|
.filter((stream) => stream.Type === "Subtitle")
|
||||||
|
.map((stream) => ({
|
||||||
|
index: stream.Index ?? 0,
|
||||||
|
language: stream.Language || "Unknown",
|
||||||
|
displayTitle:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
[
|
||||||
|
stream.Language || "Unknown",
|
||||||
|
stream.IsForced ? " (Forced)" : "",
|
||||||
|
stream.Title ? ` - ${stream.Title}` : "",
|
||||||
|
].join(""),
|
||||||
|
codec: stream.Codec || "Unknown",
|
||||||
|
isForced: stream.IsForced || false,
|
||||||
|
isExternal: stream.IsExternal || false,
|
||||||
|
}));
|
||||||
|
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||||
|
|
||||||
|
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
||||||
|
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
||||||
|
// same start-position derivation) so the cast load is identical regardless
|
||||||
|
// of whether it is triggered by the user or by the countdown timer.
|
||||||
|
const onAutoplayPlayNow = useCallback(async () => {
|
||||||
|
if (!castAutoplay) return;
|
||||||
|
const episode = castAutoplay.nextEpisode;
|
||||||
|
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
||||||
|
setCastAutoplay(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const startPositionMs =
|
||||||
|
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||||
|
const result = await loadCastMedia({
|
||||||
|
client: remoteMediaClient,
|
||||||
|
device: castDevice,
|
||||||
|
api,
|
||||||
|
item: episode,
|
||||||
|
userId: user.Id,
|
||||||
|
profileMode: settings.chromecastProfile,
|
||||||
|
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||||
|
options: { startPositionMs },
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(
|
||||||
|
"[Casting Player] Failed to load next episode (play now):",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset the autoplay counter on explicit user action.
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[Casting Player] Failed to load next episode (play now):",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCastAutoplay(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
castAutoplay,
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
remoteMediaClient,
|
||||||
|
castDevice,
|
||||||
|
settings.chromecastProfile,
|
||||||
|
settings.chromecastMaxBitrate,
|
||||||
|
updateSettings,
|
||||||
|
setCastAutoplay,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
||||||
|
// currently-playing item — same helper, same dimensions).
|
||||||
|
const autoplayPosterUrl = useMemo(() => {
|
||||||
|
if (!castAutoplay || !api?.basePath) return null;
|
||||||
|
const ep = castAutoplay.nextEpisode;
|
||||||
|
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
||||||
|
// call the helper with `undefined`. AutoplayCountdown handles null.
|
||||||
|
if (!ep?.Id) return null;
|
||||||
|
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
||||||
|
}, [castAutoplay, api?.basePath]);
|
||||||
|
|
||||||
|
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
||||||
|
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
||||||
|
// do NOT call router.replace("/casting-player") here because this component
|
||||||
|
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
||||||
|
|
||||||
|
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
||||||
|
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
||||||
|
useChromecastSegments(currentItem, progress * 1000, false);
|
||||||
|
|
||||||
|
// Swipe down to dismiss gesture
|
||||||
|
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize expensive calculations (before early return)
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api?.basePath || !currentItem?.Id) return null;
|
||||||
|
|
||||||
|
// For episodes, use SEASON poster instead of episode poster
|
||||||
|
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
||||||
|
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
||||||
|
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
||||||
|
const seasonImageTag = seasonData.ImageTags?.Primary;
|
||||||
|
return seasonImageTag
|
||||||
|
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||||
|
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to item poster for non-episodes or if season data not loaded
|
||||||
|
return getPosterUrl(
|
||||||
|
api.basePath,
|
||||||
|
currentItem.Id,
|
||||||
|
currentItem.ImageTags?.Primary,
|
||||||
|
260,
|
||||||
|
390,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
api?.basePath,
|
||||||
|
currentItem?.Id,
|
||||||
|
currentItem?.Type,
|
||||||
|
seasonData?.Id,
|
||||||
|
seasonData?.ImageTags?.Primary,
|
||||||
|
currentItem?.ImageTags?.Primary,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||||
|
|
||||||
|
// Redirect if not connected - check CastState like old implementation
|
||||||
|
useEffect(() => {
|
||||||
|
// Redirect immediately when disconnected or no devices
|
||||||
|
if (
|
||||||
|
castState === CastState.NOT_CONNECTED ||
|
||||||
|
castState === CastState.NO_DEVICES_AVAILABLE
|
||||||
|
) {
|
||||||
|
// Use setTimeout to avoid state update during render
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [castState, router]);
|
||||||
|
|
||||||
|
// Also redirect if mediaStatus disappears (media ended or stopped)
|
||||||
|
useEffect(() => {
|
||||||
|
if (castState === CastState.CONNECTED && !mediaStatus) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
}, 500); // Small delay to allow for media transitions
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [castState, mediaStatus, router]);
|
||||||
|
|
||||||
|
// Show loading while connecting
|
||||||
|
if (castState === CastState.CONNECTING) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' color='#fff' />
|
||||||
|
<Text style={{ color: "#fff", marginTop: 16 }}>
|
||||||
|
{t("casting_player.connecting")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if not connected or no media playing
|
||||||
|
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
presentation: "fullScreenModal",
|
||||||
|
animation: "slide_from_bottom",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Header - Fixed at top */}
|
||||||
|
<CastPlayerHeader
|
||||||
|
insetTop={insets.top}
|
||||||
|
protocolColor={protocolColor}
|
||||||
|
currentDevice={currentDevice}
|
||||||
|
t={t}
|
||||||
|
onDismiss={dismissModal}
|
||||||
|
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
||||||
|
onPressSettings={() => setShowSettings(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title Area — hidden during an episode change to avoid flashing
|
||||||
|
the previous episode's title/season-episode numbers. */}
|
||||||
|
{!isEpisodeTransitioning && (
|
||||||
|
<CastPlayerTitle
|
||||||
|
insetTop={insets.top}
|
||||||
|
currentItem={currentItem}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable content area */}
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: insets.top + 160,
|
||||||
|
paddingBottom: insets.bottom + 500,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Poster with buffering overlay — force the overlay during an
|
||||||
|
episode change so the loading state covers the stale poster. */}
|
||||||
|
<CastPlayerPoster
|
||||||
|
posterUrl={posterUrl}
|
||||||
|
isBuffering={isBuffering || isEpisodeTransitioning}
|
||||||
|
currentSegment={currentSegment}
|
||||||
|
skipIntro={skipIntro}
|
||||||
|
skipCredits={skipCredits}
|
||||||
|
skipSegment={skipSegment}
|
||||||
|
remoteMediaClient={remoteMediaClient}
|
||||||
|
mediaStatus={mediaStatus}
|
||||||
|
protocolColor={protocolColor}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Fixed control row - positioned independently. Episode-specific
|
||||||
|
buttons are conditional inside; Stop is always available. */}
|
||||||
|
<CastPlayerEpisodeControls
|
||||||
|
insetBottom={insets.bottom}
|
||||||
|
currentItemId={currentItem.Id}
|
||||||
|
episodes={episodes}
|
||||||
|
nextEpisode={nextEpisode}
|
||||||
|
remoteMediaClient={remoteMediaClient}
|
||||||
|
onPressEpisodes={() => setShowEpisodeList(true)}
|
||||||
|
hasChapters={hasChapters}
|
||||||
|
onPressChapters={() => setChapterListVisible(true)}
|
||||||
|
loadEpisode={loadEpisode}
|
||||||
|
router={router}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fixed bottom controls area */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 10,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
zIndex: 98,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Progress slider with trickplay preview + time display */}
|
||||||
|
<CastPlayerProgressBar
|
||||||
|
sliderProgress={sliderProgress}
|
||||||
|
sliderMin={sliderMin}
|
||||||
|
sliderMax={sliderMax}
|
||||||
|
isScrubbing={isScrubbing}
|
||||||
|
trickplayTime={trickplayTime}
|
||||||
|
setTrickplayTime={setTrickplayTime}
|
||||||
|
trickPlayUrl={trickPlayUrl}
|
||||||
|
calculateTrickplayUrl={calculateTrickplayUrl}
|
||||||
|
trickplayInfo={trickplayInfo}
|
||||||
|
progress={progress}
|
||||||
|
duration={duration}
|
||||||
|
remoteMediaClient={remoteMediaClient}
|
||||||
|
protocolColor={protocolColor}
|
||||||
|
chapters={currentItem?.Chapters}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playback controls */}
|
||||||
|
<CastPlayerTransportControls
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
togglePlayPause={togglePlayPause}
|
||||||
|
skipBackward={skipBackward}
|
||||||
|
skipForward={skipForward}
|
||||||
|
rewindSkipTime={settings?.rewindSkipTime}
|
||||||
|
forwardSkipTime={settings?.forwardSkipTime}
|
||||||
|
protocolColor={protocolColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Autoplay countdown overlay — bottom-centred above the episode
|
||||||
|
control row and main controls. 320 wide card; centred via
|
||||||
|
left/right:0 + alignItems:"center". */}
|
||||||
|
{castAutoplay && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 280,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 99,
|
||||||
|
}}
|
||||||
|
pointerEvents='box-none'
|
||||||
|
>
|
||||||
|
<AutoplayCountdown
|
||||||
|
nextEpisode={castAutoplay.nextEpisode}
|
||||||
|
posterUrl={autoplayPosterUrl}
|
||||||
|
secondsRemaining={castAutoplay.secondsRemaining}
|
||||||
|
onPlayNow={onAutoplayPlayNow}
|
||||||
|
onCancel={() => setCastAutoplay(null)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ChromecastDeviceSheet
|
||||||
|
visible={showDeviceSheet}
|
||||||
|
onClose={() => setShowDeviceSheet(false)}
|
||||||
|
device={
|
||||||
|
currentDevice && castDevice
|
||||||
|
? { friendlyName: currentDevice }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onDisconnect={async () => {
|
||||||
|
try {
|
||||||
|
// End the casting session and disconnect completely
|
||||||
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
|
await sessionManager.endCurrentSession(true);
|
||||||
|
setShowDeviceSheet(false);
|
||||||
|
// Close player immediately after disconnecting
|
||||||
|
setTimeout(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[Casting Player] Error disconnecting from Chromecast:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
volume={volume}
|
||||||
|
onVolumeChange={async (vol) => {
|
||||||
|
try {
|
||||||
|
setVolume(vol);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Casting Player] Failed to set volume:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChromecastEpisodeList
|
||||||
|
visible={showEpisodeList}
|
||||||
|
onClose={() => setShowEpisodeList(false)}
|
||||||
|
currentItem={currentItem}
|
||||||
|
episodes={episodes}
|
||||||
|
api={api}
|
||||||
|
onSelectEpisode={async (episode) => {
|
||||||
|
setShowEpisodeList(false);
|
||||||
|
await loadEpisode(episode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChapterList
|
||||||
|
visible={chapterListVisible}
|
||||||
|
chapters={chapters}
|
||||||
|
currentPositionMs={progress * 1000}
|
||||||
|
onSeek={(ms) => {
|
||||||
|
remoteMediaClient?.seek({ position: ms / 1000 });
|
||||||
|
}}
|
||||||
|
onClose={() => setChapterListVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChromecastSettingsMenu
|
||||||
|
visible={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
versions={availableVersions}
|
||||||
|
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
||||||
|
onVersionChange={(id) => {
|
||||||
|
if (!fetchedItem) return;
|
||||||
|
applySelection({
|
||||||
|
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
||||||
|
maxBitrate: currentSelection?.maxBitrate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
qualities={availableQualities}
|
||||||
|
selectedMaxBitrate={currentSelection?.maxBitrate}
|
||||||
|
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
||||||
|
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
||||||
|
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
||||||
|
onAudioChange={(index) =>
|
||||||
|
applySelection({ audioStreamIndex: index })
|
||||||
|
}
|
||||||
|
subtitleTracks={
|
||||||
|
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
||||||
|
}
|
||||||
|
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
||||||
|
onSubtitleChange={(index) =>
|
||||||
|
applySelection({ subtitleStreamIndex: index })
|
||||||
|
}
|
||||||
|
playbackSpeed={currentPlaybackSpeed}
|
||||||
|
onPlaybackSpeedChange={(speed) => {
|
||||||
|
setCurrentPlaybackSpeed(speed);
|
||||||
|
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,6 @@ 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 { 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";
|
||||||
@@ -60,6 +59,10 @@ import {
|
|||||||
getMpvSubtitleId,
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import {
|
||||||
|
type PlaybackController,
|
||||||
|
useRegisterPlaybackController,
|
||||||
|
} from "@/utils/playback/playbackController";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||||
|
|
||||||
@@ -403,26 +406,6 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (progressInfo) {
|
|
||||||
playbackManager.reportPlaybackProgress(progressInfo);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (!offline && api) {
|
|
||||||
await getPlaystateApi(api).reportPlaybackStart({
|
|
||||||
playbackStartInfo: progressInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
@@ -496,6 +479,35 @@ export default function DirectPlayerPage() {
|
|||||||
isMuted,
|
isMuted,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Declared after currentPlayStateInfo so the dependency array can reference
|
||||||
|
// it without hitting the temporal dead zone.
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
if (isPlaying) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (progressInfo) {
|
||||||
|
playbackManager.reportPlaybackProgress(progressInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoRef.current?.play();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (!offline && api) {
|
||||||
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
|
playbackStartInfo: progressInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
lightHapticFeedback,
|
||||||
|
isPlaying,
|
||||||
|
currentPlayStateInfo,
|
||||||
|
playbackManager,
|
||||||
|
offline,
|
||||||
|
api,
|
||||||
|
]);
|
||||||
|
|
||||||
const lastUrlUpdateTime = useSharedValue(0);
|
const lastUrlUpdateTime = useSharedValue(0);
|
||||||
const wasJustSeeking = useSharedValue(false);
|
const wasJustSeeking = useSharedValue(false);
|
||||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||||
@@ -825,12 +837,10 @@ export default function DirectPlayerPage() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PiP handler for MPV */
|
|
||||||
const _onPictureInPictureChange = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
// Hide controls when entering PiP
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_setShowControls(false);
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
@@ -848,6 +858,9 @@ export default function DirectPlayerPage() {
|
|||||||
|
|
||||||
// Memoize video ref functions to prevent unnecessary re-renders
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
|
// Hide controls BEFORE entering PiP so the window captures a clean view
|
||||||
|
_setShowControls(false);
|
||||||
|
setIsPipMode(true);
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -923,6 +936,47 @@ export default function DirectPlayerPage() {
|
|||||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// App-wide remote control: wrap the player's existing handlers so remote
|
||||||
|
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
||||||
|
const playbackController = useMemo<PlaybackController>(
|
||||||
|
() => ({
|
||||||
|
// togglePlay flips play/pause and reports progress to the server.
|
||||||
|
playPause: () => {
|
||||||
|
void togglePlay();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
pause();
|
||||||
|
},
|
||||||
|
unpause: () => {
|
||||||
|
play();
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
stop();
|
||||||
|
},
|
||||||
|
// PlaybackController seeks in ms; the player's seek already expects ms.
|
||||||
|
seek: (positionMs: number) => {
|
||||||
|
seek(positionMs);
|
||||||
|
},
|
||||||
|
// The player screen has no episode-loading path of its own — episode
|
||||||
|
// navigation is handled inside <Controls> via router replacement — so
|
||||||
|
// next/previous are honest no-ops here.
|
||||||
|
next: () => {},
|
||||||
|
previous: () => {},
|
||||||
|
// Volume is device-level (react-native-volume-manager); the slider sends
|
||||||
|
// 0-1 while setVolumeCb expects 0-100.
|
||||||
|
setVolume: (level: number) => {
|
||||||
|
void setVolumeCb(level * 100);
|
||||||
|
},
|
||||||
|
toggleMute: () => {
|
||||||
|
void toggleMuteCb();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Active for the whole lifetime of the player screen; cleared on unmount.
|
||||||
|
useRegisterPlaybackController(playbackController, true);
|
||||||
|
|
||||||
// Determine play method based on stream URL and media source
|
// Determine play method based on stream URL and media source
|
||||||
const playMethod = useMemo<
|
const playMethod = useMemo<
|
||||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||||
@@ -1253,12 +1307,13 @@ export default function DirectPlayerPage() {
|
|||||||
nowPlayingMetadata={nowPlayingMetadata}
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
|
onPictureInPictureChange={_onPictureInPictureChange}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
t("player.an_error_occurred_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
37
bun.lock
37
bun.lock
@@ -11,9 +11,10 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
|
"react-native-tab-view": "4.3.0",
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -114,11 +116,6 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
|
||||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
|
|
||||||
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
|
||||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
|
||||||
},
|
|
||||||
"packages": {
|
"packages": {
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||||
|
|
||||||
@@ -368,7 +365,7 @@
|
|||||||
|
|
||||||
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
||||||
|
|
||||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
|
||||||
|
|
||||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||||
|
|
||||||
@@ -542,6 +539,10 @@
|
|||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
|
||||||
|
|
||||||
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
||||||
|
|
||||||
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
||||||
@@ -1594,6 +1595,8 @@
|
|||||||
|
|
||||||
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
||||||
|
|
||||||
|
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
|
||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
@@ -2006,6 +2009,10 @@
|
|||||||
|
|
||||||
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||||
|
|
||||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
@@ -2224,6 +2231,14 @@
|
|||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
|
|
||||||
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
@@ -2336,6 +2351,14 @@
|
|||||||
|
|
||||||
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { FilterSheet } from "./filters/FilterSheet";
|
|
||||||
|
|
||||||
export type Bitrate = {
|
|
||||||
key: string;
|
|
||||||
value: number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
|
||||||
{
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "8 Mb/s",
|
|
||||||
value: 8000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "4 Mb/s",
|
|
||||||
value: 4000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "2 Mb/s",
|
|
||||||
value: 2000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "1 Mb/s",
|
|
||||||
value: 1000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "500 Kb/s",
|
|
||||||
value: 500000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "250 Kb/s",
|
|
||||||
value: 250000,
|
|
||||||
},
|
|
||||||
].sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
|
||||||
onChange: (value: Bitrate) => void;
|
|
||||||
selected?: Bitrate | null;
|
|
||||||
inverted?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BitrateSheet: React.FC<Props> = ({
|
|
||||||
onChange,
|
|
||||||
selected,
|
|
||||||
inverted,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
if (inverted)
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
}, [inverted]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex shrink'
|
|
||||||
style={{
|
|
||||||
minWidth: 60,
|
|
||||||
maxWidth: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col' {...props}>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.quality")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Text numberOfLines={1}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilterSheet
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
title={t("item_card.quality")}
|
|
||||||
data={sorted}
|
|
||||||
values={selected ? [selected] : []}
|
|
||||||
multiple={false}
|
|
||||||
searchFilter={(item, query) => {
|
|
||||||
const label = (item as any).key || "";
|
|
||||||
return label.toLowerCase().includes(query.toLowerCase());
|
|
||||||
}}
|
|
||||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as Bitrate | undefined;
|
|
||||||
if (chosen) onChange(chosen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,36 +10,31 @@ export type Bitrate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
{
|
{ key: "Max", value: undefined },
|
||||||
key: "Max",
|
{ key: "200 Mb/s", value: 200000000 },
|
||||||
value: undefined,
|
{ key: "180 Mb/s", value: 180000000 },
|
||||||
},
|
{ key: "140 Mb/s", value: 140000000 },
|
||||||
{
|
{ key: "120 Mb/s", value: 120000000 },
|
||||||
key: "8 Mb/s",
|
{ key: "110 Mb/s", value: 110000000 },
|
||||||
value: 8000000,
|
{ key: "100 Mb/s", value: 100000000 },
|
||||||
height: 1080,
|
{ key: "90 Mb/s", value: 90000000 },
|
||||||
},
|
{ key: "80 Mb/s", value: 80000000 },
|
||||||
{
|
{ key: "70 Mb/s", value: 70000000 },
|
||||||
key: "4 Mb/s",
|
{ key: "60 Mb/s", value: 60000000 },
|
||||||
value: 4000000,
|
{ key: "50 Mb/s", value: 50000000 },
|
||||||
height: 1080,
|
{ key: "40 Mb/s", value: 40000000 },
|
||||||
},
|
{ key: "30 Mb/s", value: 30000000 },
|
||||||
{
|
{ key: "20 Mb/s", value: 20000000 },
|
||||||
key: "2 Mb/s",
|
{ key: "15 Mb/s", value: 15000000 },
|
||||||
value: 2000000,
|
{ key: "10 Mb/s", value: 10000000 },
|
||||||
},
|
{ key: "8 Mb/s", value: 8000000 },
|
||||||
{
|
{ key: "5 Mb/s", value: 5000000 },
|
||||||
key: "1 Mb/s",
|
{ key: "4 Mb/s", value: 4000000 },
|
||||||
value: 1000000,
|
{ key: "3 Mb/s", value: 3000000 },
|
||||||
},
|
{ key: "2 Mb/s", value: 2000000 },
|
||||||
{
|
{ key: "1 Mb/s", value: 1000000 },
|
||||||
key: "500 Kb/s",
|
{ key: "720 Kb/s", value: 720000 },
|
||||||
value: 500000,
|
{ key: "420 Kb/s", value: 420000 },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "250 Kb/s",
|
|
||||||
value: 250000,
|
|
||||||
},
|
|
||||||
].sort(
|
].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
|
CastState,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
|
useCastState,
|
||||||
useDevices,
|
useDevices,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
@@ -18,23 +26,136 @@ export function Chromecast({
|
|||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const client = useRemoteMediaClient();
|
// Hooks called for their side effects (keep Chromecast session active)
|
||||||
const castDevice = useCastDevice();
|
useRemoteMediaClient();
|
||||||
const devices = useDevices();
|
useCastDevice();
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
const castState = useCastState();
|
||||||
|
useDevices();
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// Connection menu state
|
||||||
|
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
||||||
|
const isConnected = castState === CastState.CONNECTED;
|
||||||
|
|
||||||
|
const lastReportedProgressRef = useRef(0);
|
||||||
|
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
||||||
|
const playSessionIdRef = useRef<string | null>(null);
|
||||||
|
const lastContentIdRef = useRef<string | null>(null);
|
||||||
|
const discoveryAttempts = useRef(0);
|
||||||
|
const maxDiscoveryAttempts = 3;
|
||||||
|
|
||||||
|
// Enhanced discovery with retry mechanism - runs once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
let isSubscribed = true;
|
||||||
|
let retryTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const startDiscoveryWithRetry = async () => {
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
console.warn("DiscoveryManager is not initialized");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await discoveryManager.startDiscovery();
|
try {
|
||||||
})();
|
// Stop any existing discovery first
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
try {
|
||||||
|
await discoveryManager.stopDiscovery();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors when stopping
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fresh discovery
|
||||||
|
await discoveryManager.startDiscovery();
|
||||||
|
discoveryAttempts.current = 0; // Reset on success
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Chromecast Discovery] Failed:", error);
|
||||||
|
|
||||||
|
// Retry on error
|
||||||
|
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
||||||
|
discoveryAttempts.current++;
|
||||||
|
retryTimeout = setTimeout(() => {
|
||||||
|
if (isSubscribed) {
|
||||||
|
startDiscoveryWithRetry();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startDiscoveryWithRetry();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isSubscribed = false;
|
||||||
|
if (retryTimeout) {
|
||||||
|
clearTimeout(retryTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
||||||
|
|
||||||
|
// Report video progress to Jellyfin server
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamPosition = mediaStatus.streamPosition || 0;
|
||||||
|
const playerState = mediaStatus.playerState || null;
|
||||||
|
|
||||||
|
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
||||||
|
const positionChanged =
|
||||||
|
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
||||||
|
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
||||||
|
if (!positionChanged && !stateChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentId = mediaStatus.mediaInfo.contentId;
|
||||||
|
|
||||||
|
// Generate a new PlaySessionId when the content changes
|
||||||
|
if (contentId !== lastContentIdRef.current) {
|
||||||
|
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
||||||
|
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||||
|
/[xy]/g,
|
||||||
|
(c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
lastContentIdRef.current = contentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||||
|
const isPaused = mediaStatus.playerState === "paused";
|
||||||
|
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||||
|
const isTranscoding = /m3u8/i.test(streamUrl);
|
||||||
|
|
||||||
|
const progressInfo: PlaybackProgressInfo = {
|
||||||
|
ItemId: contentId,
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPaused: isPaused,
|
||||||
|
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||||
|
PlaySessionId: playSessionIdRef.current || contentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
getPlaystateApi(api)
|
||||||
|
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||||
|
.then(() => {
|
||||||
|
lastReportedProgressRef.current = streamPosition;
|
||||||
|
lastReportedPlayerStateRef.current = playerState;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to report Chromecast progress:", error);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
mediaStatus?.streamPosition,
|
||||||
|
mediaStatus?.mediaInfo?.contentId,
|
||||||
|
mediaStatus?.playerState,
|
||||||
|
mediaStatus?.mediaInfo?.contentUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
@@ -43,50 +164,92 @@ export function Chromecast({
|
|||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle press - show connection menu when connected, otherwise show cast dialog
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isConnected) {
|
||||||
|
if (mediaStatus?.currentItemId) {
|
||||||
|
// Media is playing - navigate to full player
|
||||||
|
router.push("/casting-player");
|
||||||
|
} else {
|
||||||
|
// Connected but no media - show connection menu
|
||||||
|
setShowConnectionMenu(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not connected - show cast dialog
|
||||||
|
CastContext.showCastDialog();
|
||||||
|
}
|
||||||
|
}, [isConnected, mediaStatus?.currentItemId]);
|
||||||
|
|
||||||
|
// Handle disconnect from Chromecast
|
||||||
|
const handleDisconnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
|
await sessionManager.endCurrentSession(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Chromecast] Disconnect error:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<>
|
||||||
className='mr-4'
|
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
||||||
onPress={() => {
|
<AndroidCastButton />
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
<Feather
|
||||||
else CastContext.showCastDialog();
|
name='cast'
|
||||||
}}
|
size={22}
|
||||||
{...props}
|
color={isConnected ? "#a855f7" : "white"}
|
||||||
>
|
/>
|
||||||
<AndroidCastButton />
|
</Pressable>
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
<ChromecastConnectionMenu
|
||||||
</Pressable>
|
visible={showConnectionMenu}
|
||||||
|
onClose={() => setShowConnectionMenu(false)}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<>
|
||||||
size='large'
|
<RoundButton
|
||||||
className='mr-2'
|
size='large'
|
||||||
background={false}
|
className='mr-2'
|
||||||
onPress={() => {
|
background={false}
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
onPress={handlePress}
|
||||||
else CastContext.showCastDialog();
|
{...props}
|
||||||
}}
|
>
|
||||||
{...props}
|
<AndroidCastButton />
|
||||||
>
|
<Feather
|
||||||
<AndroidCastButton />
|
name='cast'
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
size={22}
|
||||||
</RoundButton>
|
color={isConnected ? "#a855f7" : "white"}
|
||||||
|
/>
|
||||||
|
</RoundButton>
|
||||||
|
<ChromecastConnectionMenu
|
||||||
|
visible={showConnectionMenu}
|
||||||
|
onClose={() => setShowConnectionMenu(false)}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<>
|
||||||
size='large'
|
<RoundButton size='large' onPress={handlePress} {...props}>
|
||||||
onPress={() => {
|
<AndroidCastButton />
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
<Feather
|
||||||
else CastContext.showCastDialog();
|
name='cast'
|
||||||
}}
|
size={22}
|
||||||
{...props}
|
color={isConnected ? "#a855f7" : "white"}
|
||||||
>
|
/>
|
||||||
<AndroidCastButton />
|
</RoundButton>
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
<ChromecastConnectionMenu
|
||||||
</RoundButton>
|
visible={showConnectionMenu}
|
||||||
|
onClose={() => setShowConnectionMenu(false)}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type {
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ interface PlatformDropdownProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onOptionSelect?: (value?: any) => void;
|
onOptionSelect?: (value?: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
expoUIConfig?: {
|
expoUIConfig?: {
|
||||||
hostStyle?: any;
|
hostStyle?: any;
|
||||||
};
|
};
|
||||||
@@ -213,6 +214,9 @@ const PlatformDropdownComponent = ({
|
|||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
onOptionSelect,
|
onOptionSelect,
|
||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
|
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
||||||
|
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
||||||
|
disabled: isDisabled,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
@@ -265,6 +269,13 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||||
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||||
@@ -417,8 +428,14 @@ const PlatformDropdownComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
||||||
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
MediaStreamType,
|
MediaPlayerState,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
|
useCastDevice,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
@@ -32,12 +33,8 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { chromecast } from "../utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -59,6 +56,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
@@ -111,7 +109,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
const options = [
|
||||||
|
t("casting_player.chromecast"),
|
||||||
|
t("casting_player.device"),
|
||||||
|
t("casting_player.cancel"),
|
||||||
|
];
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -120,9 +122,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
async (selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
||||||
|
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
||||||
|
const isMediaActive =
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||||
const isOpeningCurrentlyPlayingMedia =
|
const isOpeningCurrentlyPlayingMedia =
|
||||||
currentTitle && currentTitle === item?.Name;
|
isMediaActive && currentContentId && currentContentId === item?.Id;
|
||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -130,30 +137,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (state && state !== PlayServicesState.SUCCESS) {
|
if (state && state !== PlayServicesState.SUCCESS) {
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
} else {
|
} else {
|
||||||
// Check if user wants H265 for Chromecast
|
if (!api || !user?.Id || !item?.Id) {
|
||||||
const enableH265 = settings.enableH265ForChromecast;
|
console.warn("Missing parameters for Chromecast streaming");
|
||||||
|
|
||||||
// Validate required parameters before calling getStreamUrl
|
|
||||||
if (!api) {
|
|
||||||
console.warn("API not available for Chromecast streaming");
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.missing_parameters"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!user?.Id) {
|
|
||||||
console.warn(
|
|
||||||
"User not authenticated for Chromecast streaming",
|
|
||||||
);
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.missing_parameters"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item?.Id) {
|
|
||||||
console.warn("Item not available for Chromecast streaming");
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.missing_parameters"),
|
t("player.missing_parameters"),
|
||||||
@@ -161,110 +146,37 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile
|
const startPositionMs =
|
||||||
try {
|
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||||
const data = await getStreamUrl({
|
|
||||||
api,
|
const result = await loadCastMedia({
|
||||||
item,
|
client,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
device: castDevice,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
api,
|
||||||
userId: user.Id,
|
item,
|
||||||
|
userId: user.Id,
|
||||||
|
profileMode: settings.chromecastProfile,
|
||||||
|
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||||
|
options: {
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
});
|
maxBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||||
|
startPositionMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
console.log("URL: ", data?.url, enableH265);
|
if (!result.ok) {
|
||||||
|
console.error("[PlayButton] cast load failed:", result.error);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.could_not_create_stream_for_chromecast"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data?.url) {
|
if (!isOpeningCurrentlyPlayingMedia) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
router.push("/casting-player");
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate start time in seconds from playback position
|
|
||||||
const startTimeSeconds =
|
|
||||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
|
||||||
|
|
||||||
// Calculate stream duration in seconds from runtime
|
|
||||||
const streamDurationSeconds = item.RunTimeTicks
|
|
||||||
? item.RunTimeTicks / 10000000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
client
|
|
||||||
.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentId: item.Id,
|
|
||||||
contentUrl: data?.url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
streamType: MediaStreamType.BUFFERED,
|
|
||||||
streamDuration: streamDurationSeconds,
|
|
||||||
metadata:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? {
|
|
||||||
type: "tvShow",
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getParentBackdropImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? {
|
|
||||||
type: "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "generic",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: startTimeSeconds,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -280,6 +192,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
client,
|
||||||
|
castDevice,
|
||||||
settings,
|
settings,
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
|
|||||||
12
components/casting/CastAutoplayWatcher.tsx
Normal file
12
components/casting/CastAutoplayWatcher.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
||||||
|
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
||||||
|
* fires regardless of which screen is open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
||||||
|
|
||||||
|
export function CastAutoplayWatcher() {
|
||||||
|
useCastAutoplay();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
358
components/casting/CastingMiniPlayer.tsx
Normal file
358
components/casting/CastingMiniPlayer.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Mini Player
|
||||||
|
* Works with all supported casting protocols
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
MediaPlayerState,
|
||||||
|
useCastDevice,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import Animated, {
|
||||||
|
SlideInDown,
|
||||||
|
SlideOutDown,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
||||||
|
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
||||||
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
|
export const CastingMiniPlayer: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
const remoteMediaClient = useRemoteMediaClient();
|
||||||
|
|
||||||
|
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
||||||
|
|
||||||
|
// Trickplay support - pass currentItem as BaseItemDto or null
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
|
currentItem || null,
|
||||||
|
);
|
||||||
|
const [trickplayTime, setTrickplayTime] = useState({
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
seconds: 0,
|
||||||
|
});
|
||||||
|
const isScrubbing = useRef(false);
|
||||||
|
|
||||||
|
// Slider shared values
|
||||||
|
const sliderProgress = useSharedValue(0);
|
||||||
|
const sliderMin = useSharedValue(0);
|
||||||
|
const sliderMax = useSharedValue(100);
|
||||||
|
|
||||||
|
// Live progress state that updates every second when playing
|
||||||
|
const [liveProgress, setLiveProgress] = useState(
|
||||||
|
mediaStatus?.streamPosition || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track baseline for elapsed-time computation
|
||||||
|
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
||||||
|
const baselineTimestampRef = useRef(Date.now());
|
||||||
|
|
||||||
|
// Sync live progress with mediaStatus and poll every second when playing
|
||||||
|
useEffect(() => {
|
||||||
|
// Resync baseline whenever mediaStatus reports a new position
|
||||||
|
if (mediaStatus?.streamPosition !== undefined) {
|
||||||
|
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||||
|
baselineTimestampRef.current = Date.now();
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update based on elapsed real time when playing
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
||||||
|
const elapsed =
|
||||||
|
((Date.now() - baselineTimestampRef.current) *
|
||||||
|
(mediaStatus.playbackRate || 1)) /
|
||||||
|
1000;
|
||||||
|
setLiveProgress(baselinePositionRef.current + elapsed);
|
||||||
|
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||||
|
// Sync with actual position when paused/buffering
|
||||||
|
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||||
|
baselineTimestampRef.current = Date.now();
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
mediaStatus?.playerState,
|
||||||
|
mediaStatus?.streamPosition,
|
||||||
|
mediaStatus?.playbackRate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const progress = liveProgress * 1000; // Convert to ms
|
||||||
|
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
||||||
|
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||||
|
|
||||||
|
// Update slider max value when duration changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
sliderMax.value = duration;
|
||||||
|
}
|
||||||
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
// Sync slider progress with live progress (when not scrubbing)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isScrubbing.current && progress >= 0) {
|
||||||
|
sliderProgress.value = progress;
|
||||||
|
}
|
||||||
|
}, [progress, sliderProgress]);
|
||||||
|
|
||||||
|
// For episodes, use series poster; for other content, use item poster
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api?.basePath || !currentItem) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentItem.Type === "Episode" &&
|
||||||
|
currentItem.SeriesId &&
|
||||||
|
currentItem.ParentIndexNumber !== undefined &&
|
||||||
|
currentItem.SeasonId
|
||||||
|
) {
|
||||||
|
// Build series poster URL using SeriesId and series-level image tag
|
||||||
|
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
||||||
|
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
||||||
|
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own poster
|
||||||
|
return getPosterUrl(
|
||||||
|
api.basePath,
|
||||||
|
currentItem.Id,
|
||||||
|
currentItem.ImageTags?.Primary,
|
||||||
|
80,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
}, [api?.basePath, currentItem]);
|
||||||
|
|
||||||
|
// Hide mini player when:
|
||||||
|
// - No cast device connected
|
||||||
|
// - No media info (currentItem)
|
||||||
|
// - No media status
|
||||||
|
// - Media is stopped (IDLE state)
|
||||||
|
// - Media is unknown state
|
||||||
|
const playerState = mediaStatus?.playerState;
|
||||||
|
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
||||||
|
|
||||||
|
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||||
|
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push("/casting-player");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePlayPause = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
||||||
|
console.error("[CastingMiniPlayer] Pause error:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
remoteMediaClient?.play()?.catch((error: unknown) => {
|
||||||
|
console.error("[CastingMiniPlayer] Play error:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||||
|
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: "#333",
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Interactive progress slider with trickplay */}
|
||||||
|
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
||||||
|
<Slider
|
||||||
|
style={{ width: "100%", height: 20 }}
|
||||||
|
progress={sliderProgress}
|
||||||
|
minimumValue={sliderMin}
|
||||||
|
maximumValue={sliderMax}
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: protocolColor,
|
||||||
|
bubbleBackgroundColor: protocolColor,
|
||||||
|
bubbleTextColor: "#fff",
|
||||||
|
}}
|
||||||
|
onSlidingStart={() => {
|
||||||
|
isScrubbing.current = true;
|
||||||
|
}}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// Calculate trickplay preview
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
|
||||||
|
// Update time display for trickplay bubble
|
||||||
|
const progressInSeconds = Math.floor(
|
||||||
|
ticksToSeconds(progressInTicks),
|
||||||
|
);
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTrickplayTime({ hours, minutes, seconds });
|
||||||
|
}}
|
||||||
|
onSlidingComplete={(value) => {
|
||||||
|
isScrubbing.current = false;
|
||||||
|
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||||
|
const positionSeconds = value / 1000;
|
||||||
|
if (remoteMediaClient && duration > 0) {
|
||||||
|
remoteMediaClient
|
||||||
|
.seek({ position: positionSeconds })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Mini Player] Seek error:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderBubble={() => (
|
||||||
|
<CastTrickplayBubble
|
||||||
|
trickPlayUrl={trickPlayUrl}
|
||||||
|
trickplayInfo={trickplayInfo}
|
||||||
|
trickplayTime={trickplayTime}
|
||||||
|
tileWidth={190}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
bubbleMaxWidth={190}
|
||||||
|
bubbleWidth={190}
|
||||||
|
bubbleTranslateY={-20}
|
||||||
|
sliderHeight={3}
|
||||||
|
thumbWidth={14}
|
||||||
|
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={handlePress}>
|
||||||
|
{/* Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 12,
|
||||||
|
paddingTop: 6,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Poster */}
|
||||||
|
{posterUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{currentItem.Name}
|
||||||
|
</Text>
|
||||||
|
{currentItem.SeriesName && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{currentItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='tv' size={12} color={protocolColor} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: protocolColor,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{castDevice.friendlyName || "Chromecast"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(progress)} / {formatTime(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stop button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
||||||
|
console.error("[CastingMiniPlayer] Stop error:", error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Play/Pause button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTogglePlayPause();
|
||||||
|
}}
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={28}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
components/casting/player/CastPlayerEpisodeControls.tsx
Normal file
175
components/casting/player/CastPlayerEpisodeControls.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Episode Controls
|
||||||
|
* Fixed control row: episode list, previous, next, stop.
|
||||||
|
* Episode-specific buttons (list / previous / next) are conditional;
|
||||||
|
* Stop is always rendered so movies still get a Stop button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { ImperativeRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface CastPlayerEpisodeControlsProps {
|
||||||
|
/** Bottom safe-area inset, used to offset the fixed control row. */
|
||||||
|
insetBottom: number;
|
||||||
|
/** Id of the currently playing episode. */
|
||||||
|
currentItemId: BaseItemDto["Id"];
|
||||||
|
/** Full episode list for the series. */
|
||||||
|
episodes: BaseItemDto[];
|
||||||
|
/** Next episode in the list, or null if none. */
|
||||||
|
nextEpisode: BaseItemDto | null;
|
||||||
|
/** Remote media client, or null when no session. */
|
||||||
|
remoteMediaClient: RemoteMediaClient | null;
|
||||||
|
/** Open the episode list modal. */
|
||||||
|
onPressEpisodes: () => void;
|
||||||
|
/** Whether the current item exposes chapter markers. */
|
||||||
|
hasChapters: boolean;
|
||||||
|
/** Open the chapter list modal. */
|
||||||
|
onPressChapters: () => void;
|
||||||
|
/** Load a different episode on the Chromecast. */
|
||||||
|
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||||
|
/** Expo Router instance for navigation on stop. */
|
||||||
|
router: ImperativeRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerEpisodeControls({
|
||||||
|
insetBottom,
|
||||||
|
currentItemId,
|
||||||
|
episodes,
|
||||||
|
nextEpisode,
|
||||||
|
remoteMediaClient,
|
||||||
|
onPressEpisodes,
|
||||||
|
hasChapters,
|
||||||
|
onPressChapters,
|
||||||
|
loadEpisode,
|
||||||
|
router,
|
||||||
|
}: CastPlayerEpisodeControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasEpisodeList = episodes.length > 0;
|
||||||
|
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
||||||
|
const hasNext = nextEpisode != null;
|
||||||
|
|
||||||
|
// Count of buttons actually rendered (Stop is always rendered).
|
||||||
|
const buttonCount =
|
||||||
|
1 +
|
||||||
|
(hasEpisodeList ? 1 : 0) +
|
||||||
|
(hasChapters ? 1 : 0) +
|
||||||
|
(hasPrevious ? 1 : 0) +
|
||||||
|
(hasNext ? 1 : 0);
|
||||||
|
|
||||||
|
// When Stop is the only button (movies), render it full-width with a label.
|
||||||
|
const isLoneStop = buttonCount === 1;
|
||||||
|
|
||||||
|
// Each button stretches evenly only when the row holds more than one;
|
||||||
|
// a lone Stop button keeps its intrinsic size and stays centered.
|
||||||
|
const buttonStyle = {
|
||||||
|
...(buttonCount > 1 ? { flex: 1 } : {}),
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
justifyContent: "center" as const,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insetBottom + 200,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
||||||
|
{hasEpisodeList && (
|
||||||
|
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
||||||
|
<Ionicons name='list' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
||||||
|
<Ionicons name='bookmarks' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Previous episode button - only rendered when a previous episode exists */}
|
||||||
|
{hasPrevious && (
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
const currentIndex = episodes.findIndex(
|
||||||
|
(ep) => ep.Id === currentItemId,
|
||||||
|
);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
await loadEpisode(episodes[currentIndex - 1]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={buttonStyle}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-back' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next episode button - only rendered when a next episode exists */}
|
||||||
|
{hasNext && (
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
if (nextEpisode) {
|
||||||
|
await loadEpisode(nextEpisode);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={buttonStyle}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
// Stop the current media playback (don't disconnect from Chromecast)
|
||||||
|
if (remoteMediaClient) {
|
||||||
|
await remoteMediaClient.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate back/close the player (mini player will disappear since no media is playing)
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Casting Player] Error stopping playback:", error);
|
||||||
|
// Navigate anyway
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop-circle' size={22} color='white' />
|
||||||
|
{isLoneStop && (
|
||||||
|
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
||||||
|
{t("casting_player.stop")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
components/casting/player/CastPlayerHeader.tsx
Normal file
94
components/casting/player/CastPlayerHeader.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Header
|
||||||
|
* Fixed top bar: dismiss button, connection indicator, settings button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface CastPlayerHeaderProps {
|
||||||
|
/** Top safe-area inset, used to offset the fixed header. */
|
||||||
|
insetTop: number;
|
||||||
|
/** Streamyfin protocol accent color. */
|
||||||
|
protocolColor: string;
|
||||||
|
/** Friendly name of the connected cast device, or null. */
|
||||||
|
currentDevice: string | null;
|
||||||
|
/** Translation function. */
|
||||||
|
t: TFunction;
|
||||||
|
/** Dismiss the casting player modal. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
/** Open the device sheet (connection indicator press). */
|
||||||
|
onPressConnectionIndicator: () => void;
|
||||||
|
/** Open the settings menu. */
|
||||||
|
onPressSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerHeader({
|
||||||
|
insetTop,
|
||||||
|
protocolColor,
|
||||||
|
currentDevice,
|
||||||
|
t,
|
||||||
|
onDismiss,
|
||||||
|
onPressConnectionIndicator,
|
||||||
|
onPressSettings,
|
||||||
|
}: CastPlayerHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: insetTop + 8,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
||||||
|
<Ionicons name='chevron-down' size={32} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onPressConnectionIndicator}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: protocolColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentDevice || t("casting_player.unknown_device")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onPressSettings}
|
||||||
|
style={{ padding: 8, marginRight: -8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='settings-outline' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
components/casting/player/CastPlayerPoster.tsx
Normal file
176
components/casting/player/CastPlayerPoster.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Poster
|
||||||
|
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||||
|
import {
|
||||||
|
MediaPlayerState,
|
||||||
|
type MediaStatus,
|
||||||
|
type RemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
||||||
|
|
||||||
|
interface CastPlayerPosterProps {
|
||||||
|
/** Poster image URL, or null when unavailable. */
|
||||||
|
posterUrl: string | null;
|
||||||
|
/** Whether the cast media is currently buffering. */
|
||||||
|
isBuffering: boolean;
|
||||||
|
/** The current playback segment (intro/credits/etc.), or null. */
|
||||||
|
currentSegment: ChromecastSegments["currentSegment"];
|
||||||
|
/** Skip the intro segment. */
|
||||||
|
skipIntro: ChromecastSegments["skipIntro"];
|
||||||
|
/** Skip the credits segment. */
|
||||||
|
skipCredits: ChromecastSegments["skipCredits"];
|
||||||
|
/** Skip the current generic segment. */
|
||||||
|
skipSegment: ChromecastSegments["skipSegment"];
|
||||||
|
/** The remote media client, or null when no session. */
|
||||||
|
remoteMediaClient: RemoteMediaClient | null;
|
||||||
|
/** Raw Chromecast media status. */
|
||||||
|
mediaStatus: MediaStatus | null;
|
||||||
|
/** Theme accent color. */
|
||||||
|
protocolColor: string;
|
||||||
|
/** Translation function. */
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerPoster({
|
||||||
|
posterUrl,
|
||||||
|
isBuffering,
|
||||||
|
currentSegment,
|
||||||
|
skipIntro,
|
||||||
|
skipCredits,
|
||||||
|
skipSegment,
|
||||||
|
remoteMediaClient,
|
||||||
|
mediaStatus,
|
||||||
|
protocolColor,
|
||||||
|
t,
|
||||||
|
}: CastPlayerPosterProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
height: 420,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={64} color='#333' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skip intro/credits bar at bottom of poster */}
|
||||||
|
{currentSegment && (
|
||||||
|
<Pressable
|
||||||
|
onPress={async () => {
|
||||||
|
if (!remoteMediaClient) return;
|
||||||
|
try {
|
||||||
|
const seekFn = async (positionMs: number) => {
|
||||||
|
if (
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
||||||
|
) {
|
||||||
|
await remoteMediaClient.seek({
|
||||||
|
position: positionMs / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (currentSegment.type === "intro") {
|
||||||
|
await skipIntro(seekFn);
|
||||||
|
} else if (currentSegment.type === "credits") {
|
||||||
|
await skipCredits(seekFn);
|
||||||
|
} else {
|
||||||
|
await skipSegment(seekFn);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Casting Player] Skip error:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-forward' size={18} color='white' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buffering overlay */}
|
||||||
|
{isBuffering && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' color={protocolColor} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("casting_player.buffering")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/casting/player/CastPlayerProgressBar.tsx
Normal file
163
components/casting/player/CastPlayerProgressBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Progress Bar
|
||||||
|
* Progress slider with trickplay preview bubble and current/end time display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||||
|
import type { SharedValue } from "react-native-reanimated";
|
||||||
|
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||||
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
|
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
||||||
|
import { chapterMarkers } from "@/utils/chapters";
|
||||||
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
|
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||||
|
|
||||||
|
interface CastPlayerProgressBarProps {
|
||||||
|
/** Shared value tracking the slider progress, in milliseconds. */
|
||||||
|
sliderProgress: SharedValue<number>;
|
||||||
|
/** Shared value for the slider minimum, in milliseconds. */
|
||||||
|
sliderMin: SharedValue<number>;
|
||||||
|
/** Shared value for the slider maximum, in milliseconds. */
|
||||||
|
sliderMax: SharedValue<number>;
|
||||||
|
/** Mutable ref flag set true while the user is scrubbing. */
|
||||||
|
isScrubbing: { current: boolean };
|
||||||
|
/** Trickplay time display state for the bubble. */
|
||||||
|
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||||
|
/** Updates the trickplay time display state. */
|
||||||
|
setTrickplayTime: (time: {
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
}) => void;
|
||||||
|
/** Current trickplay image URL/coordinates, or null. */
|
||||||
|
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||||
|
/** Computes the trickplay URL for a given progress in ticks. */
|
||||||
|
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||||
|
/** Parsed trickplay metadata, or null. */
|
||||||
|
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||||
|
/** Current playback progress, in seconds. */
|
||||||
|
progress: number;
|
||||||
|
/** Total media duration, in seconds. */
|
||||||
|
duration: number;
|
||||||
|
/** Remote media client, or null when no session. */
|
||||||
|
remoteMediaClient: RemoteMediaClient | null;
|
||||||
|
/** Theme color used for the slider track and bubbles. */
|
||||||
|
protocolColor: string;
|
||||||
|
/** Chapter markers for the current item, or null/undefined if none. */
|
||||||
|
chapters?: ChapterInfo[] | null;
|
||||||
|
/** Translation function. */
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerProgressBar({
|
||||||
|
sliderProgress,
|
||||||
|
sliderMin,
|
||||||
|
sliderMax,
|
||||||
|
isScrubbing,
|
||||||
|
trickplayTime,
|
||||||
|
setTrickplayTime,
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
remoteMediaClient,
|
||||||
|
protocolColor,
|
||||||
|
chapters,
|
||||||
|
t,
|
||||||
|
}: CastPlayerProgressBarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Progress slider with trickplay preview */}
|
||||||
|
<View style={{ marginTop: 8, height: 40 }}>
|
||||||
|
<Slider
|
||||||
|
style={{ width: "100%", height: 40 }}
|
||||||
|
progress={sliderProgress}
|
||||||
|
minimumValue={sliderMin}
|
||||||
|
maximumValue={sliderMax}
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: protocolColor,
|
||||||
|
bubbleBackgroundColor: protocolColor,
|
||||||
|
bubbleTextColor: "#fff",
|
||||||
|
}}
|
||||||
|
onSlidingStart={() => {
|
||||||
|
isScrubbing.current = true;
|
||||||
|
}}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// Calculate trickplay preview
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
|
||||||
|
// Update time display for trickplay bubble
|
||||||
|
const progressInSeconds = Math.floor(
|
||||||
|
ticksToSeconds(progressInTicks),
|
||||||
|
);
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTrickplayTime({ hours, minutes, seconds });
|
||||||
|
}}
|
||||||
|
onSlidingComplete={(value) => {
|
||||||
|
isScrubbing.current = false;
|
||||||
|
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||||
|
const positionSeconds = value / 1000;
|
||||||
|
if (remoteMediaClient && duration > 0) {
|
||||||
|
remoteMediaClient
|
||||||
|
.seek({ position: positionSeconds })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Casting Player] Seek error:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderBubble={() => (
|
||||||
|
<CastTrickplayBubble
|
||||||
|
trickPlayUrl={trickPlayUrl}
|
||||||
|
trickplayInfo={trickplayInfo}
|
||||||
|
trickplayTime={trickplayTime}
|
||||||
|
tileWidth={220}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
bubbleMaxWidth={220}
|
||||||
|
bubbleWidth={220}
|
||||||
|
bubbleTranslateY={-20}
|
||||||
|
sliderHeight={6}
|
||||||
|
thumbWidth={16}
|
||||||
|
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
||||||
|
/>
|
||||||
|
<ChapterTicks
|
||||||
|
markers={chapterMarkers(chapters, duration * 1000)}
|
||||||
|
height={4}
|
||||||
|
color='#cccccc'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Time display */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
{formatTime(progress * 1000)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
{t("casting_player.ending_at", {
|
||||||
|
time: calculateEndingTime(progress * 1000, duration * 1000),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
{formatTime(duration * 1000)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
components/casting/player/CastPlayerTitle.tsx
Normal file
72
components/casting/player/CastPlayerTitle.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Title Area
|
||||||
|
* Fixed title bar: item title and optional grey episode/season info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { truncateTitle } from "@/utils/casting/helpers";
|
||||||
|
|
||||||
|
interface CastPlayerTitleProps {
|
||||||
|
/** Top safe-area inset, used to offset the fixed title area. */
|
||||||
|
insetTop: number;
|
||||||
|
/** The currently playing item. */
|
||||||
|
currentItem: BaseItemDto;
|
||||||
|
/** Translation function. */
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerTitle({
|
||||||
|
insetTop,
|
||||||
|
currentItem,
|
||||||
|
t,
|
||||||
|
}: CastPlayerTitleProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: insetTop + 50,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 95,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Grey episode/season info */}
|
||||||
|
{currentItem.Type === "Episode" &&
|
||||||
|
currentItem.ParentIndexNumber !== undefined &&
|
||||||
|
currentItem.IndexNumber !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 15,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("casting_player.season_episode_format", {
|
||||||
|
season: currentItem.ParentIndexNumber,
|
||||||
|
episode: currentItem.IndexNumber,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/casting/player/CastPlayerTransportControls.tsx
Normal file
122
components/casting/player/CastPlayerTransportControls.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Casting Player Transport Controls
|
||||||
|
* Playback transport row: rewind, play/pause, forward.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface CastPlayerTransportControlsProps {
|
||||||
|
/** Whether playback is currently playing. */
|
||||||
|
isPlaying: boolean;
|
||||||
|
/** Toggle play/pause on the Chromecast. */
|
||||||
|
togglePlayPause: () => Promise<void>;
|
||||||
|
/** Skip backward by the given number of seconds. */
|
||||||
|
skipBackward: (seconds: number) => Promise<void>;
|
||||||
|
/** Skip forward by the given number of seconds. */
|
||||||
|
skipForward: (seconds: number) => Promise<void>;
|
||||||
|
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
||||||
|
rewindSkipTime: number | null | undefined;
|
||||||
|
/** Configured forward skip time in seconds, shown on the forward button. */
|
||||||
|
forwardSkipTime: number | null | undefined;
|
||||||
|
/** Accent color used for the play/pause button background. */
|
||||||
|
protocolColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastPlayerTransportControls({
|
||||||
|
isPlaying,
|
||||||
|
togglePlayPause,
|
||||||
|
skipBackward,
|
||||||
|
skipForward,
|
||||||
|
rewindSkipTime,
|
||||||
|
forwardSkipTime,
|
||||||
|
protocolColor,
|
||||||
|
}: CastPlayerTransportControlsProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 32,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Rewind (use settings) */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='refresh-outline'
|
||||||
|
size={48}
|
||||||
|
color='white'
|
||||||
|
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
||||||
|
/>
|
||||||
|
{rewindSkipTime != null && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<Pressable
|
||||||
|
onPress={togglePlayPause}
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={36}
|
||||||
|
color='white'
|
||||||
|
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Forward (use settings) */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='refresh-outline' size={48} color='white' />
|
||||||
|
{forwardSkipTime != null && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
components/casting/player/CastTrickplayBubble.tsx
Normal file
110
components/casting/player/CastTrickplayBubble.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Shared scrub-preview bubble for the casting progress bars.
|
||||||
|
*
|
||||||
|
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
||||||
|
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
||||||
|
* component therefore does NO horizontal positioning — it only anchors itself
|
||||||
|
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
||||||
|
|
||||||
|
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||||
|
|
||||||
|
interface CastTrickplayBubbleProps {
|
||||||
|
/** Current trickplay image URL/coordinates, or null. */
|
||||||
|
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||||
|
/** Parsed trickplay metadata, or null. */
|
||||||
|
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||||
|
/** Scrub time to display. */
|
||||||
|
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||||
|
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
||||||
|
tileWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastTrickplayBubble({
|
||||||
|
trickPlayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
trickplayTime,
|
||||||
|
tileWidth,
|
||||||
|
}: CastTrickplayBubbleProps) {
|
||||||
|
const timeText = (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTrickplayTime(trickplayTime)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anchored to the bottom of the slider-positioned container, growing upward,
|
||||||
|
// and filling the container width (left/right: 0) so it stays centred on the
|
||||||
|
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeText}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeText}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
style={{
|
||||||
|
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||||
|
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,9 @@ 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}>
|
||||||
|
|||||||
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Chromecast Connection Menu
|
||||||
|
* Shows device info, volume control, and disconnect option
|
||||||
|
* Simple menu for when connected but not actively controlling playback
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Modal, Pressable, View } from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface ChromecastConnectionMenuProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDisconnect?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChromecastConnectionMenu: React.FC<
|
||||||
|
ChromecastConnectionMenuProps
|
||||||
|
> = ({ visible, onClose, onDisconnect }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const castSession = useCastSession();
|
||||||
|
|
||||||
|
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||||
|
const [displayVolume, setDisplayVolume] = useState(50);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const isMutedRef = useRef(false);
|
||||||
|
const volumeValue = useSharedValue(50);
|
||||||
|
const minimumValue = useSharedValue(0);
|
||||||
|
const maximumValue = useSharedValue(100);
|
||||||
|
const isSliding = useRef(false);
|
||||||
|
const lastSetVolume = useRef(50);
|
||||||
|
|
||||||
|
const protocolColor = "#a855f7";
|
||||||
|
|
||||||
|
// Get initial volume and mute state when menu opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !castSession) return;
|
||||||
|
|
||||||
|
// Get initial states
|
||||||
|
const fetchInitialState = async () => {
|
||||||
|
try {
|
||||||
|
const vol = await castSession.getVolume();
|
||||||
|
if (vol !== undefined) {
|
||||||
|
const percent = Math.round(vol * 100);
|
||||||
|
setDisplayVolume(percent);
|
||||||
|
volumeValue.value = percent;
|
||||||
|
lastSetVolume.current = percent;
|
||||||
|
}
|
||||||
|
const muted = await castSession.isMute();
|
||||||
|
isMutedRef.current = muted;
|
||||||
|
setIsMuted(muted);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInitialState();
|
||||||
|
|
||||||
|
// Poll for external volume changes (physical buttons) - only when not sliding
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (isSliding.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vol = await castSession.getVolume();
|
||||||
|
if (vol !== undefined) {
|
||||||
|
const percent = Math.round(vol * 100);
|
||||||
|
// Only update if external change detected (not our own change)
|
||||||
|
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
||||||
|
setDisplayVolume(percent);
|
||||||
|
volumeValue.value = percent;
|
||||||
|
lastSetVolume.current = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const muted = await castSession.isMute();
|
||||||
|
if (muted !== isMutedRef.current) {
|
||||||
|
isMutedRef.current = muted;
|
||||||
|
setIsMuted(muted);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}, 1000); // Poll less frequently
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [visible, castSession, volumeValue]);
|
||||||
|
|
||||||
|
// Volume change during sliding - update display only, don't call API
|
||||||
|
const handleVolumeChange = useCallback((value: number) => {
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
setDisplayVolume(rounded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Volume change complete - call API
|
||||||
|
const handleVolumeComplete = useCallback(
|
||||||
|
async (value: number) => {
|
||||||
|
isSliding.current = false;
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
setDisplayVolume(rounded);
|
||||||
|
lastSetVolume.current = rounded;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (castSession) {
|
||||||
|
await castSession.setVolume(rounded / 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Connection Menu] Volume error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[castSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
const handleToggleMute = useCallback(async () => {
|
||||||
|
if (!castSession) return;
|
||||||
|
try {
|
||||||
|
const newMute = !isMuted;
|
||||||
|
await castSession.setMute(newMute);
|
||||||
|
isMutedRef.current = newMute;
|
||||||
|
setIsMuted(newMute);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Connection Menu] Mute error:", error);
|
||||||
|
}
|
||||||
|
}, [castSession, isMuted]);
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
const handleDisconnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (onDisconnect) {
|
||||||
|
await onDisconnect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Connection Menu] Disconnect error:", error);
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onDisconnect, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingBottom: insets.bottom + 16,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header with device name */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='tv' size={20} color='white' />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
||||||
|
{t("casting_player.connected")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Volume Control */}
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||||
|
{t("casting_player.volume")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "white", fontSize: 14 }}>
|
||||||
|
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: isMuted ? protocolColor : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isMuted ? "volume-mute" : "volume-low"}
|
||||||
|
size={20}
|
||||||
|
color={isMuted ? "white" : "#999"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Slider
|
||||||
|
style={{ width: "100%", height: 40 }}
|
||||||
|
progress={volumeValue}
|
||||||
|
minimumValue={minimumValue}
|
||||||
|
maximumValue={maximumValue}
|
||||||
|
theme={{
|
||||||
|
disableMinTrackTintColor: "#333",
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
||||||
|
bubbleBackgroundColor: protocolColor,
|
||||||
|
}}
|
||||||
|
onSlidingStart={() => {
|
||||||
|
isSliding.current = true;
|
||||||
|
}}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
volumeValue.value = value;
|
||||||
|
handleVolumeChange(value);
|
||||||
|
// Unmute when adjusting volume - use ref to avoid
|
||||||
|
// stale closure and prevent repeated async calls
|
||||||
|
if (isMutedRef.current) {
|
||||||
|
isMutedRef.current = false;
|
||||||
|
setIsMuted(false);
|
||||||
|
try {
|
||||||
|
await castSession?.setMute(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(
|
||||||
|
"[ChromecastConnectionMenu] Failed to unmute:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
isMutedRef.current = true;
|
||||||
|
setIsMuted(true); // Rollback on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSlidingComplete={handleVolumeComplete}
|
||||||
|
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name='volume-high'
|
||||||
|
size={20}
|
||||||
|
color={isMuted ? "#666" : "#999"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Disconnect button */}
|
||||||
|
<View style={{ paddingHorizontal: 16 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDisconnect}
|
||||||
|
style={{
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='power' size={20} color='white' />
|
||||||
|
<Text
|
||||||
|
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
||||||
|
>
|
||||||
|
{t("casting_player.disconnect")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Chromecast Device Info Sheet
|
||||||
|
* Shows device details, volume control, and disconnect option
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Modal, Pressable, View } from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { useCastSession } from "react-native-google-cast";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface ChromecastDeviceSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
device: { friendlyName?: string } | null;
|
||||||
|
onDisconnect: () => Promise<void>;
|
||||||
|
volume?: number;
|
||||||
|
onVolumeChange?: (volume: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
device,
|
||||||
|
onDisconnect,
|
||||||
|
volume = 0.5,
|
||||||
|
onVolumeChange,
|
||||||
|
}) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||||
|
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
||||||
|
const volumeValue = useSharedValue(volume * 100);
|
||||||
|
const minimumValue = useSharedValue(0);
|
||||||
|
const maximumValue = useSharedValue(100);
|
||||||
|
const castSession = useCastSession();
|
||||||
|
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const isSliding = useRef(false);
|
||||||
|
const lastSetVolume = useRef(Math.round(volume * 100));
|
||||||
|
|
||||||
|
// Sync volume slider with prop changes (updates from physical buttons)
|
||||||
|
// Skip updates while user is actively sliding to avoid overwriting drag
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSliding.current) return;
|
||||||
|
volumeValue.value = volume * 100;
|
||||||
|
setDisplayVolume(Math.round(volume * 100));
|
||||||
|
}, [volume, volumeValue]);
|
||||||
|
|
||||||
|
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !castSession) return;
|
||||||
|
|
||||||
|
// Get initial mute state
|
||||||
|
castSession
|
||||||
|
.isMute()
|
||||||
|
.then(setIsMuted)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Poll CastSession for device volume and mute state (only when not sliding)
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (isSliding.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deviceVolume = await castSession.getVolume();
|
||||||
|
if (deviceVolume !== undefined) {
|
||||||
|
const volumePercent = Math.round(deviceVolume * 100);
|
||||||
|
// Only update if external change (physical buttons)
|
||||||
|
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
||||||
|
setDisplayVolume(volumePercent);
|
||||||
|
volumeValue.value = volumePercent;
|
||||||
|
lastSetVolume.current = volumePercent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mute state
|
||||||
|
const muteState = await castSession.isMute();
|
||||||
|
setIsMuted(muteState);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors - device might be disconnected
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [visible, castSession, volumeValue]);
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
setIsDisconnecting(true);
|
||||||
|
try {
|
||||||
|
await onDisconnect();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to disconnect:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDisconnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeComplete = async (value: number) => {
|
||||||
|
const newVolume = value / 100;
|
||||||
|
setDisplayVolume(Math.round(value));
|
||||||
|
try {
|
||||||
|
// Use CastSession.setVolume for DEVICE volume control
|
||||||
|
// This works even when no media is playing, unlike setStreamVolume
|
||||||
|
if (castSession) {
|
||||||
|
await castSession.setVolume(newVolume);
|
||||||
|
} else if (onVolumeChange) {
|
||||||
|
// Fallback to prop method if session not available
|
||||||
|
await onVolumeChange(newVolume);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Volume] Error setting volume:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced volume update during sliding for smooth live feedback
|
||||||
|
const handleVolumeChange = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
setDisplayVolume(Math.round(value));
|
||||||
|
|
||||||
|
// Debounce the API call to avoid too many requests
|
||||||
|
if (volumeDebounceRef.current) {
|
||||||
|
clearTimeout(volumeDebounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeDebounceRef.current = setTimeout(async () => {
|
||||||
|
const newVolume = value / 100;
|
||||||
|
try {
|
||||||
|
if (castSession) {
|
||||||
|
await castSession.setVolume(newVolume);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during sliding
|
||||||
|
}
|
||||||
|
}, 150); // 150ms debounce
|
||||||
|
},
|
||||||
|
[castSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle mute state
|
||||||
|
const handleToggleMute = useCallback(async () => {
|
||||||
|
if (!castSession) return;
|
||||||
|
try {
|
||||||
|
const newMuteState = !isMuted;
|
||||||
|
await castSession.setMute(newMuteState);
|
||||||
|
setIsMuted(newMuteState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Volume] Error toggling mute:", error);
|
||||||
|
}
|
||||||
|
}, [castSession, isMuted]);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (volumeDebounceRef.current) {
|
||||||
|
clearTimeout(volumeDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
paddingBottom: insets.bottom + 16,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||||
|
<Text
|
||||||
|
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
{t("casting_player.chromecast")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Device info */}
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||||
|
{t("casting_player.device_name")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||||
|
>
|
||||||
|
{device?.friendlyName || t("casting_player.unknown_device")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Volume control */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||||
|
{t("casting_player.volume")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "white", fontSize: 14 }}>
|
||||||
|
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mute button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isMuted ? "volume-mute" : "volume-low"}
|
||||||
|
size={20}
|
||||||
|
color={isMuted ? "white" : "#999"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Slider
|
||||||
|
style={{ width: "100%", height: 40 }}
|
||||||
|
progress={volumeValue}
|
||||||
|
minimumValue={minimumValue}
|
||||||
|
maximumValue={maximumValue}
|
||||||
|
theme={{
|
||||||
|
disableMinTrackTintColor: "#333",
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
||||||
|
bubbleBackgroundColor: "#a855f7",
|
||||||
|
}}
|
||||||
|
onSlidingStart={async () => {
|
||||||
|
isSliding.current = true;
|
||||||
|
// Auto-unmute when user starts adjusting volume
|
||||||
|
if (isMuted && castSession) {
|
||||||
|
setIsMuted(false);
|
||||||
|
try {
|
||||||
|
await castSession.setMute(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Volume] Failed to unmute:", error);
|
||||||
|
setIsMuted(true); // Rollback on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
volumeValue.value = value;
|
||||||
|
handleVolumeChange(value);
|
||||||
|
}}
|
||||||
|
onSlidingComplete={(value) => {
|
||||||
|
isSliding.current = false;
|
||||||
|
lastSetVolume.current = Math.round(value);
|
||||||
|
handleVolumeComplete(value);
|
||||||
|
}}
|
||||||
|
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name='volume-high'
|
||||||
|
size={20}
|
||||||
|
color={isMuted ? "#666" : "#999"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Disconnect button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDisconnect}
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#a855f7",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
opacity: isDisconnecting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='power'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
{isDisconnecting
|
||||||
|
? t("casting_player.disconnecting")
|
||||||
|
: t("casting_player.stop_casting")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
356
components/chromecast/ChromecastEpisodeList.tsx
Normal file
356
components/chromecast/ChromecastEpisodeList.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
/**
|
||||||
|
* Episode List for Chromecast Player
|
||||||
|
* Displays list of episodes for TV shows with thumbnails
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { truncateTitle } from "@/utils/casting/helpers";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
interface ChromecastEpisodeListProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentItem: BaseItemDto | null;
|
||||||
|
episodes: BaseItemDto[];
|
||||||
|
onSelectEpisode: (episode: BaseItemDto) => void;
|
||||||
|
api: Api | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
currentItem,
|
||||||
|
episodes,
|
||||||
|
onSelectEpisode,
|
||||||
|
api,
|
||||||
|
}) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||||
|
const scrollRetryCountRef = useRef(0);
|
||||||
|
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const MAX_SCROLL_RETRIES = 3;
|
||||||
|
|
||||||
|
// Cleanup pending retry timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scrollRetryTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollRetryTimeoutRef.current);
|
||||||
|
scrollRetryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
scrollRetryCountRef.current = 0;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get unique seasons from episodes
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
const seasonSet = new Set<number>();
|
||||||
|
for (const ep of episodes) {
|
||||||
|
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
||||||
|
seasonSet.add(ep.ParentIndexNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seasonSet).sort((a, b) => a - b);
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
// Filter episodes by selected season and exclude virtual episodes
|
||||||
|
const filteredEpisodes = useMemo(() => {
|
||||||
|
let eps = episodes;
|
||||||
|
|
||||||
|
// Filter by season if selected
|
||||||
|
if (selectedSeason !== null) {
|
||||||
|
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out virtual episodes (episodes without actual video files)
|
||||||
|
// LocationType === "Virtual" means the episode doesn't have a media file
|
||||||
|
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
||||||
|
|
||||||
|
return eps;
|
||||||
|
}, [episodes, selectedSeason]);
|
||||||
|
|
||||||
|
// Set initial season to current episode's season
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentItem?.ParentIndexNumber !== undefined) {
|
||||||
|
setSelectedSeason(currentItem.ParentIndexNumber);
|
||||||
|
}
|
||||||
|
}, [currentItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset retry counter when visibility or data changes
|
||||||
|
scrollRetryCountRef.current = 0;
|
||||||
|
if (scrollRetryTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollRetryTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible && currentItem && filteredEpisodes.length > 0) {
|
||||||
|
const currentIndex = filteredEpisodes.findIndex(
|
||||||
|
(ep) => ep.Id === currentItem.Id,
|
||||||
|
);
|
||||||
|
if (currentIndex !== -1 && flatListRef.current) {
|
||||||
|
// Delay to ensure FlatList is rendered
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index: currentIndex,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5, // Center the item
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (scrollRetryTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollRetryTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, currentItem, filteredEpisodes]);
|
||||||
|
|
||||||
|
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||||
|
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onSelectEpisode(item);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
padding: 12,
|
||||||
|
// Translucent (not solid) purple so the dark base shows through and
|
||||||
|
// the row's text — incl. the purple S:E label — stays readable. The
|
||||||
|
// play-circle icon also marks the current episode.
|
||||||
|
backgroundColor: isCurrentEpisode
|
||||||
|
? "rgba(168, 85, 247, 0.25)"
|
||||||
|
: "transparent",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const imageUrl =
|
||||||
|
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
||||||
|
if (imageUrl) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={32} color='#333' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episode info */}
|
||||||
|
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
||||||
|
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
||||||
|
</Text>
|
||||||
|
{item.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
||||||
|
{item.ParentIndexNumber !== undefined &&
|
||||||
|
item.IndexNumber !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
||||||
|
{String(item.IndexNumber).padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.ProductionYear && (
|
||||||
|
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.RunTimeTicks && (
|
||||||
|
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||||
|
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
||||||
|
{t("casting_player.minutes_short")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isCurrentEpisode && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-circle' size={24} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||||
|
}}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: seasons.length > 1 ? 12 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||||
|
{t("casting_player.episodes")}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Season selector */}
|
||||||
|
{seasons.length > 1 && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 8 }}
|
||||||
|
>
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<Pressable
|
||||||
|
key={season}
|
||||||
|
onPress={() => setSelectedSeason(season)}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor:
|
||||||
|
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: selectedSeason === season ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("casting_player.season", { number: season })}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episode list */}
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={filteredEpisodes}
|
||||||
|
renderItem={renderEpisode}
|
||||||
|
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: insets.bottom + 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScrollToIndexFailed={(info) => {
|
||||||
|
// Bounded retry for scroll failures
|
||||||
|
if (
|
||||||
|
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
||||||
|
info.index >= filteredEpisodes.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollRetryCountRef.current += 1;
|
||||||
|
if (scrollRetryTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollRetryTimeoutRef.current);
|
||||||
|
}
|
||||||
|
scrollRetryTimeoutRef.current = setTimeout(() => {
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index: info.index,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
304
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
304
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Chromecast Settings Menu
|
||||||
|
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
||||||
|
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
export interface VersionOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityOption {
|
||||||
|
key: string;
|
||||||
|
value: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChromecastSettingsMenuProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
versions: VersionOption[];
|
||||||
|
selectedVersionId: string;
|
||||||
|
onVersionChange: (id: string) => void;
|
||||||
|
qualities: QualityOption[];
|
||||||
|
selectedMaxBitrate: number | undefined;
|
||||||
|
onQualityChange: (value: number | undefined) => void;
|
||||||
|
audioTracks: AudioTrack[];
|
||||||
|
selectedAudioIndex: number;
|
||||||
|
onAudioChange: (index: number) => void;
|
||||||
|
subtitleTracks: SubtitleTrack[];
|
||||||
|
/** -1 = subtitles off. */
|
||||||
|
selectedSubtitleIndex: number;
|
||||||
|
onSubtitleChange: (index: number) => void;
|
||||||
|
playbackSpeed: number;
|
||||||
|
onPlaybackSpeedChange: (speed: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||||
|
const ACCENT = "#a855f7";
|
||||||
|
|
||||||
|
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
versions,
|
||||||
|
selectedVersionId,
|
||||||
|
onVersionChange,
|
||||||
|
qualities,
|
||||||
|
selectedMaxBitrate,
|
||||||
|
onQualityChange,
|
||||||
|
audioTracks,
|
||||||
|
selectedAudioIndex,
|
||||||
|
onAudioChange,
|
||||||
|
subtitleTracks,
|
||||||
|
selectedSubtitleIndex,
|
||||||
|
onSubtitleChange,
|
||||||
|
playbackSpeed,
|
||||||
|
onPlaybackSpeedChange,
|
||||||
|
}) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSection(expandedSection === section ? null : section);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSectionHeader = (
|
||||||
|
title: string,
|
||||||
|
icon: keyof typeof Ionicons.glyphMap,
|
||||||
|
sectionKey: string,
|
||||||
|
) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => toggleSection(sectionKey)}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||||
|
<Ionicons name={icon} size={20} color='white' />
|
||||||
|
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
||||||
|
size={20}
|
||||||
|
color='#999'
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (
|
||||||
|
key: string | number,
|
||||||
|
label: string,
|
||||||
|
sublabel: string | null,
|
||||||
|
selected: boolean,
|
||||||
|
onPress: () => void,
|
||||||
|
) => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => {
|
||||||
|
onPress();
|
||||||
|
setExpandedSection(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
||||||
|
{sublabel ? (
|
||||||
|
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
||||||
|
{sublabel}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "80%",
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||||
|
{t("casting_player.playback_settings")}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView>
|
||||||
|
{/* Version — only when the item has more than one MediaSource */}
|
||||||
|
{versions.length > 1 &&
|
||||||
|
renderSectionHeader(
|
||||||
|
t("casting_player.version"),
|
||||||
|
"albums-outline",
|
||||||
|
"version",
|
||||||
|
)}
|
||||||
|
{versions.length > 1 && expandedSection === "version" && (
|
||||||
|
<View style={{ paddingVertical: 8 }}>
|
||||||
|
{versions.map((v) =>
|
||||||
|
renderRow(
|
||||||
|
v.id,
|
||||||
|
v.name,
|
||||||
|
null,
|
||||||
|
v.id === selectedVersionId,
|
||||||
|
() => onVersionChange(v.id),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quality (bitrate cap) */}
|
||||||
|
{renderSectionHeader(
|
||||||
|
t("casting_player.quality"),
|
||||||
|
"film-outline",
|
||||||
|
"quality",
|
||||||
|
)}
|
||||||
|
{expandedSection === "quality" && (
|
||||||
|
<View style={{ paddingVertical: 8 }}>
|
||||||
|
{qualities.map((q) =>
|
||||||
|
renderRow(
|
||||||
|
q.key,
|
||||||
|
q.key,
|
||||||
|
null,
|
||||||
|
q.value === selectedMaxBitrate,
|
||||||
|
() => onQualityChange(q.value),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio — only when more than one track */}
|
||||||
|
{audioTracks.length > 1 &&
|
||||||
|
renderSectionHeader(
|
||||||
|
t("casting_player.audio"),
|
||||||
|
"musical-notes",
|
||||||
|
"audio",
|
||||||
|
)}
|
||||||
|
{audioTracks.length > 1 && expandedSection === "audio" && (
|
||||||
|
<View style={{ paddingVertical: 8 }}>
|
||||||
|
{audioTracks.map((track) =>
|
||||||
|
renderRow(
|
||||||
|
track.index,
|
||||||
|
track.displayTitle ||
|
||||||
|
track.language ||
|
||||||
|
t("casting_player.unknown"),
|
||||||
|
track.codec ? track.codec.toUpperCase() : null,
|
||||||
|
track.index === selectedAudioIndex,
|
||||||
|
() => onAudioChange(track.index),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtitles */}
|
||||||
|
{subtitleTracks.length > 0 &&
|
||||||
|
renderSectionHeader(
|
||||||
|
t("casting_player.subtitles"),
|
||||||
|
"text",
|
||||||
|
"subtitles",
|
||||||
|
)}
|
||||||
|
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
||||||
|
<View style={{ paddingVertical: 8 }}>
|
||||||
|
{renderRow(
|
||||||
|
"off",
|
||||||
|
t("casting_player.none"),
|
||||||
|
null,
|
||||||
|
selectedSubtitleIndex < 0,
|
||||||
|
() => onSubtitleChange(-1),
|
||||||
|
)}
|
||||||
|
{subtitleTracks.map((track) =>
|
||||||
|
renderRow(
|
||||||
|
track.index,
|
||||||
|
track.displayTitle ||
|
||||||
|
track.language ||
|
||||||
|
t("casting_player.unknown"),
|
||||||
|
[
|
||||||
|
track.codec ? track.codec.toUpperCase() : "",
|
||||||
|
track.isForced ? t("casting_player.forced") : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" • ") || null,
|
||||||
|
track.index === selectedSubtitleIndex,
|
||||||
|
() => onSubtitleChange(track.index),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playback speed */}
|
||||||
|
{renderSectionHeader(
|
||||||
|
t("casting_player.playback_speed"),
|
||||||
|
"speedometer",
|
||||||
|
"speed",
|
||||||
|
)}
|
||||||
|
{expandedSection === "speed" && (
|
||||||
|
<View style={{ paddingVertical: 8 }}>
|
||||||
|
{PLAYBACK_SPEEDS.map((speed) =>
|
||||||
|
renderRow(
|
||||||
|
speed,
|
||||||
|
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
||||||
|
null,
|
||||||
|
Math.abs(playbackSpeed - speed) < 0.01,
|
||||||
|
() => onPlaybackSpeedChange(speed),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
||||||
|
* Integrates with autoskip API for segment detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { isWithinSegment } from "@/utils/casting/helpers";
|
||||||
|
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
|
||||||
|
export const useChromecastSegments = (
|
||||||
|
item: BaseItemDto | null,
|
||||||
|
currentProgressMs: number,
|
||||||
|
isOffline = false,
|
||||||
|
) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
// Fetch segments from autoskip API
|
||||||
|
const { data: segmentData } = useSegments(
|
||||||
|
item?.Id || "",
|
||||||
|
isOffline,
|
||||||
|
undefined, // downloadedFiles parameter
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse segments into usable format
|
||||||
|
const segments = useMemo<ChromecastSegmentData>(() => {
|
||||||
|
if (!segmentData) {
|
||||||
|
return {
|
||||||
|
intro: null,
|
||||||
|
credits: null,
|
||||||
|
recap: null,
|
||||||
|
commercial: [],
|
||||||
|
preview: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const intro =
|
||||||
|
segmentData.introSegments && segmentData.introSegments.length > 0
|
||||||
|
? {
|
||||||
|
start: segmentData.introSegments[0].startTime,
|
||||||
|
end: segmentData.introSegments[0].endTime,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const credits =
|
||||||
|
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
||||||
|
? {
|
||||||
|
start: segmentData.creditSegments[0].startTime,
|
||||||
|
end: segmentData.creditSegments[0].endTime,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const recap =
|
||||||
|
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
||||||
|
? {
|
||||||
|
start: segmentData.recapSegments[0].startTime,
|
||||||
|
end: segmentData.recapSegments[0].endTime,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
||||||
|
start: seg.startTime,
|
||||||
|
end: seg.endTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
||||||
|
start: seg.startTime,
|
||||||
|
end: seg.endTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { intro, credits, recap, commercial, preview };
|
||||||
|
}, [segmentData]);
|
||||||
|
|
||||||
|
// Check which segment we're currently in
|
||||||
|
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
||||||
|
// before comparing with segment times (which are in seconds from the autoskip API)
|
||||||
|
const currentSegment = useMemo(() => {
|
||||||
|
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
||||||
|
return { type: "intro" as const, segment: segments.intro };
|
||||||
|
}
|
||||||
|
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
||||||
|
return { type: "credits" as const, segment: segments.credits };
|
||||||
|
}
|
||||||
|
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
||||||
|
return { type: "recap" as const, segment: segments.recap };
|
||||||
|
}
|
||||||
|
for (const commercial of segments.commercial) {
|
||||||
|
if (isWithinSegment(currentProgressMs, commercial)) {
|
||||||
|
return { type: "commercial" as const, segment: commercial };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const preview of segments.preview) {
|
||||||
|
if (isWithinSegment(currentProgressMs, preview)) {
|
||||||
|
return { type: "preview" as const, segment: preview };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [currentProgressMs, segments]);
|
||||||
|
|
||||||
|
// Skip functions
|
||||||
|
const skipIntro = useCallback(
|
||||||
|
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||||
|
if (segments.intro) {
|
||||||
|
await seekFn(segments.intro.end * 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[segments.intro],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipCredits = useCallback(
|
||||||
|
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||||
|
if (segments.credits) {
|
||||||
|
await seekFn(segments.credits.end * 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[segments.credits],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipSegment = useCallback(
|
||||||
|
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||||
|
if (currentSegment?.segment) {
|
||||||
|
await seekFn(currentSegment.segment.end * 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentSegment],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-skip logic based on settings
|
||||||
|
const shouldAutoSkip = useMemo(() => {
|
||||||
|
if (!currentSegment) return false;
|
||||||
|
|
||||||
|
switch (currentSegment.type) {
|
||||||
|
case "intro":
|
||||||
|
return settings?.skipIntro === "auto";
|
||||||
|
case "credits":
|
||||||
|
return settings?.skipOutro === "auto";
|
||||||
|
case "recap":
|
||||||
|
return settings?.skipRecap === "auto";
|
||||||
|
case "commercial":
|
||||||
|
return settings?.skipCommercial === "auto";
|
||||||
|
case "preview":
|
||||||
|
return settings?.skipPreview === "auto";
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentSegment,
|
||||||
|
settings?.skipIntro,
|
||||||
|
settings?.skipOutro,
|
||||||
|
settings?.skipRecap,
|
||||||
|
settings?.skipCommercial,
|
||||||
|
settings?.skipPreview,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
currentSegment,
|
||||||
|
skipIntro,
|
||||||
|
skipCredits,
|
||||||
|
skipSegment,
|
||||||
|
shouldAutoSkip,
|
||||||
|
hasIntro: !!segments.intro,
|
||||||
|
hasCredits: !!segments.credits,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
@@ -47,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
key={person.Id}
|
key={`${person.Id}-${idx}`}
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
actorId={person.Id}
|
actorId={person.Id}
|
||||||
actorName={person.Name}
|
actorName={person.Name}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
103
components/player/AutoplayCountdown.tsx
Normal file
103
components/player/AutoplayCountdown.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
||||||
|
* positioning — this component only renders the next episode's poster, title,
|
||||||
|
* the remaining seconds, and the Play-now / Cancel actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface AutoplayCountdownProps {
|
||||||
|
/** The episode that will play next. */
|
||||||
|
nextEpisode: BaseItemDto;
|
||||||
|
/** Poster image URL for the next episode, or null. */
|
||||||
|
posterUrl: string | null;
|
||||||
|
/** Seconds left before the next episode plays. */
|
||||||
|
secondsRemaining: number;
|
||||||
|
/** Play the next episode immediately. */
|
||||||
|
onPlayNow: () => void;
|
||||||
|
/** Cancel autoplay — the next episode will not play. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoplayCountdown({
|
||||||
|
nextEpisode,
|
||||||
|
posterUrl,
|
||||||
|
secondsRemaining,
|
||||||
|
onPlayNow,
|
||||||
|
onCancel,
|
||||||
|
}: AutoplayCountdownProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
width: 320,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: 62, height: 93, borderRadius: 6 }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
||||||
|
<View style={{ gap: 2 }}>
|
||||||
|
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||||
|
{t("player.up_next")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{nextEpisode.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
||||||
|
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPlayNow}
|
||||||
|
accessibilityRole='button'
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#a855f7",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||||
|
{t("player.play_now")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onCancel}
|
||||||
|
accessibilityRole='button'
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#333",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||||
|
{t("player.cancel")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { PlatformDropdown } from "../PlatformDropdown";
|
||||||
|
|
||||||
|
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
||||||
|
auto: "Automatic (recommended)",
|
||||||
|
"force-hevc": "Force HEVC / H265",
|
||||||
|
"force-h264": "Force H264",
|
||||||
|
};
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
|
const profileOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: (
|
||||||
|
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
||||||
|
).map((mode) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: PROFILE_LABELS[mode],
|
||||||
|
value: mode,
|
||||||
|
selected: (settings.chromecastProfile ?? "auto") === mode,
|
||||||
|
onPress: () => updateSettings({ chromecastProfile: mode }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings.chromecastProfile, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem
|
||||||
<Switch
|
title={"Profile"}
|
||||||
value={settings.enableH265ForChromecast}
|
subtitle={
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
"Automatic picks codecs per device. Override only if needed."
|
||||||
updateSettings({ enableH265ForChromecast })
|
}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={profileOptions}
|
||||||
|
title={"Chromecast profile"}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
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 useRouter from "@/hooks/useAppRouter";
|
||||||
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 { Text } from "../common/Text";
|
||||||
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -96,6 +98,48 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
||||||
|
// valid selection even if an out-of-range value was stored previously.
|
||||||
|
const autoplayCountdown = Math.min(
|
||||||
|
60,
|
||||||
|
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
||||||
|
);
|
||||||
|
const castAutoplayCountdown = Math.min(
|
||||||
|
60,
|
||||||
|
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoplayCountdownOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: String(seconds),
|
||||||
|
value: String(seconds),
|
||||||
|
selected: seconds === autoplayCountdown,
|
||||||
|
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[autoplayCountdown, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const castAutoplayCountdownOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: String(seconds),
|
||||||
|
value: String(seconds),
|
||||||
|
selected: seconds === castAutoplayCountdown,
|
||||||
|
onPress: () =>
|
||||||
|
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[castAutoplayCountdown, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const playbackSpeedOptions = useMemo(
|
const playbackSpeedOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -229,7 +273,10 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
disabled={
|
||||||
|
!settings.autoPlayNextEpisode ||
|
||||||
|
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
@@ -248,6 +295,57 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
{/* Media Segment Skip Settings */}
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.segment_skip_settings")}
|
||||||
|
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
||||||
|
onPress={() => router.push("/settings/segment-skip/page")}
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||||
|
disabled={!settings.autoPlayNextEpisode}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={autoplayCountdownOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||||
|
disabled={!settings.autoPlayNextEpisode}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={castAutoplayCountdownOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{castAutoplayCountdown}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
@@ -268,3 +366,6 @@ const AUTOPLAY_EPISODES_COUNT = (
|
|||||||
{ key: "6", value: 6 },
|
{ key: "6", value: 6 },
|
||||||
{ key: "7", value: 7 },
|
{ key: "7", value: 7 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
||||||
|
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
|
renderBubble={() => null}
|
||||||
|
renderThumb={() => null}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
|
||||||
bubbleTextColor: "transparent", // Hide the value text
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useMemo, useState } from "react";
|
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
||||||
@@ -12,9 +13,10 @@ 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 { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||||
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 { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
@@ -35,11 +37,14 @@ interface BottomControlsProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
|
skipButtonText: string;
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
|
skipCreditButtonText: string;
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
skipCredit: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
|
api?: Api | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
handleControlsInteraction: () => void;
|
handleControlsInteraction: () => void;
|
||||||
@@ -90,11 +95,14 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
|
skipButtonText,
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
|
skipCreditButtonText,
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
|
api,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
handleControlsInteraction,
|
handleControlsInteraction,
|
||||||
@@ -125,6 +133,83 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
);
|
);
|
||||||
const hasChapters = chapterMarkerList.length > 1;
|
const hasChapters = chapterMarkerList.length > 1;
|
||||||
|
|
||||||
|
// Autoplay overlay: shown under the same condition the old countdown button used.
|
||||||
|
const autoplayAllowed =
|
||||||
|
settings.autoPlayNextEpisode !== false &&
|
||||||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||||
|
|
||||||
|
const showNextEpisodeCountdown =
|
||||||
|
autoplayAllowed &&
|
||||||
|
(!nextItem
|
||||||
|
? false
|
||||||
|
: // Show during credits if no content after, OR near end of video
|
||||||
|
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||||
|
remainingTime < 10000);
|
||||||
|
|
||||||
|
const [secondsRemaining, setSecondsRemaining] = useState(
|
||||||
|
settings.autoplayCountdownSeconds,
|
||||||
|
);
|
||||||
|
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
// Keep a stable ref to the autoplay handler so the timer effect does not
|
||||||
|
// restart when the handler identity changes.
|
||||||
|
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
|
||||||
|
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showNextEpisodeCountdown || autoplayCancelled) {
|
||||||
|
// Either the show-condition flipped off OR the user cancelled.
|
||||||
|
// In both cases, stop the running timer immediately so autoplay
|
||||||
|
// can't fire after Cancel was pressed.
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
// Only reset cancellation + seconds when the show-condition itself
|
||||||
|
// flipped off — a fresh credits/end-of-video window then starts a
|
||||||
|
// brand-new countdown. If we got here because autoplayCancelled
|
||||||
|
// just flipped true, keep it true so the countdown stays stopped.
|
||||||
|
if (!showNextEpisodeCountdown) {
|
||||||
|
setAutoplayCancelled(false);
|
||||||
|
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setSecondsRemaining((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
autoPlayHandlerRef.current();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
showNextEpisodeCountdown,
|
||||||
|
autoplayCancelled,
|
||||||
|
settings.autoplayCountdownSeconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextEpisodePosterUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
|
||||||
|
[api, nextItem],
|
||||||
|
);
|
||||||
|
|
||||||
// Current chapter name for the always-visible header label (live playback).
|
// Current chapter name for the always-visible header label (live playback).
|
||||||
const currentChapterName = useMemo(
|
const currentChapterName = useMemo(
|
||||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||||
@@ -202,7 +287,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText='Skip Intro'
|
buttonText={skipButtonText}
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -212,24 +297,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText='Skip Credits'
|
buttonText={skipCreditButtonText}
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
<AutoplayCountdown
|
||||||
settings.autoPlayEpisodeCount <
|
nextEpisode={nextItem}
|
||||||
settings.maxAutoPlayEpisodeCount.value) && (
|
posterUrl={nextEpisodePosterUrl}
|
||||||
<NextEpisodeCountDownButton
|
secondsRemaining={secondsRemaining}
|
||||||
show={
|
onPlayNow={handleNextEpisodeManual}
|
||||||
!nextItem
|
onCancel={() => setAutoplayCancelled(true)}
|
||||||
? false
|
/>
|
||||||
: // Show during credits if no content after, OR near end of video
|
)}
|
||||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
|
||||||
remainingTime < 10000
|
|
||||||
}
|
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
|
||||||
onPress={handleNextEpisodeManual}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
|
renderBubble={() => null}
|
||||||
|
renderThumb={() => null}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
|
||||||
bubbleTextColor: "transparent", // Hide the value text
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { type FC, useCallback, useEffect, useState } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -16,17 +24,17 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
|
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { ticksToMs } from "@/utils/time";
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -43,6 +51,9 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
|
// No-op function to avoid creating new references on every render
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -111,6 +122,24 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
|
// Ref to track pending play timeout for cleanup and cancellation
|
||||||
|
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
||||||
|
const playingRef = useRef(isPlaying);
|
||||||
|
useEffect(() => {
|
||||||
|
playingRef.current = isPlaying;
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
// Clean up timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (playTimeoutRef.current) {
|
||||||
|
clearTimeout(playTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -317,27 +346,125 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
// Fetch all segments for the current item
|
||||||
item.Id!,
|
const { data: segments } = useSegments(
|
||||||
currentTime,
|
item.Id ?? "",
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
offline,
|
offline,
|
||||||
api,
|
|
||||||
downloadedFiles,
|
downloadedFiles,
|
||||||
|
api,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
// Convert milliseconds to seconds for segment comparison
|
||||||
useCreditSkipper(
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
item.Id!,
|
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||||
currentTime,
|
|
||||||
seek,
|
// Wrapper to convert segment skip from seconds to milliseconds
|
||||||
play,
|
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||||
offline,
|
const seekMs = useCallback(
|
||||||
api,
|
(timeInSeconds: number) => {
|
||||||
downloadedFiles,
|
// Cancel any pending play call to avoid race conditions
|
||||||
maxMs,
|
if (playTimeoutRef.current) {
|
||||||
);
|
clearTimeout(playTimeoutRef.current);
|
||||||
|
}
|
||||||
|
seek(timeInSeconds * 1000);
|
||||||
|
// Brief delay ensures the seek operation completes before resuming playback
|
||||||
|
// Without this, playback may resume from the old position
|
||||||
|
// Read latest isPlaying from ref to avoid stale closure
|
||||||
|
playTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (playingRef.current) {
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
playTimeoutRef.current = null;
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
[seek, play],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use unified segment skipper for all segment types
|
||||||
|
const introSkipper = useSegmentSkipper({
|
||||||
|
segments: segments?.introSegments || [],
|
||||||
|
segmentType: "Intro",
|
||||||
|
currentTime: currentTimeSeconds,
|
||||||
|
seek: seekMs,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outroSkipper = useSegmentSkipper({
|
||||||
|
segments: segments?.creditSegments || [],
|
||||||
|
segmentType: "Outro",
|
||||||
|
currentTime: currentTimeSeconds,
|
||||||
|
totalDuration: maxSeconds,
|
||||||
|
seek: seekMs,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recapSkipper = useSegmentSkipper({
|
||||||
|
segments: segments?.recapSegments || [],
|
||||||
|
segmentType: "Recap",
|
||||||
|
currentTime: currentTimeSeconds,
|
||||||
|
seek: seekMs,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commercialSkipper = useSegmentSkipper({
|
||||||
|
segments: segments?.commercialSegments || [],
|
||||||
|
segmentType: "Commercial",
|
||||||
|
currentTime: currentTimeSeconds,
|
||||||
|
seek: seekMs,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewSkipper = useSegmentSkipper({
|
||||||
|
segments: segments?.previewSegments || [],
|
||||||
|
segmentType: "Preview",
|
||||||
|
currentTime: currentTimeSeconds,
|
||||||
|
seek: seekMs,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine which segment button to show (priority order)
|
||||||
|
// Commercial > Recap > Intro > Preview > Outro
|
||||||
|
const activeSegment = useMemo(() => {
|
||||||
|
if (commercialSkipper.currentSegment)
|
||||||
|
return { type: "Commercial", ...commercialSkipper };
|
||||||
|
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||||
|
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||||
|
if (previewSkipper.currentSegment)
|
||||||
|
return { type: "Preview", ...previewSkipper };
|
||||||
|
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
commercialSkipper.currentSegment,
|
||||||
|
recapSkipper.currentSegment,
|
||||||
|
introSkipper.currentSegment,
|
||||||
|
previewSkipper.currentSegment,
|
||||||
|
outroSkipper.currentSegment,
|
||||||
|
commercialSkipper,
|
||||||
|
recapSkipper,
|
||||||
|
introSkipper,
|
||||||
|
previewSkipper,
|
||||||
|
outroSkipper,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Legacy compatibility: map to old variable names
|
||||||
|
const showSkipButton = !!(
|
||||||
|
activeSegment &&
|
||||||
|
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||||
|
);
|
||||||
|
const skipIntro = activeSegment?.skipSegment || noop;
|
||||||
|
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||||
|
const skipCredit = outroSkipper.skipSegment || noop;
|
||||||
|
const hasContentAfterCredits =
|
||||||
|
outroSkipper.currentSegment && maxSeconds
|
||||||
|
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Get button text based on segment type using i18n
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const skipButtonText = activeSegment
|
||||||
|
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||||
|
: t("player.skip_intro");
|
||||||
|
const skipCreditButtonText = t("player.skip_outro");
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -564,11 +691,14 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
|
skipButtonText={skipButtonText}
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
|
skipCreditButtonText={skipCreditButtonText}
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
skipCredit={skipCredit}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
|
api={api}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
handleControlsInteraction={handleControlsInteraction}
|
handleControlsInteraction={handleControlsInteraction}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
TouchableOpacity,
|
|
||||||
type TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
|
|
||||||
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
|
||||||
onFinish?: () => void;
|
|
||||||
onPress?: () => void;
|
|
||||||
show: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
|
||||||
onFinish,
|
|
||||||
onPress,
|
|
||||||
show,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (show) {
|
|
||||||
progress.value = 0;
|
|
||||||
progress.value = withTiming(
|
|
||||||
1,
|
|
||||||
{
|
|
||||||
duration: 10000, // 10 seconds
|
|
||||||
easing: Easing.linear,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (finished && onFinish) {
|
|
||||||
runOnJS(onFinish)();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
|
||||||
return () => {
|
|
||||||
cancelAnimation(progress);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, onFinish]);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: `${progress.value * 100}%`,
|
|
||||||
backgroundColor: Colors.primary,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
if (onPress) {
|
|
||||||
onPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
|
|
||||||
{...props}
|
|
||||||
onPress={handlePress}
|
|
||||||
>
|
|
||||||
<Animated.View style={animatedStyle} />
|
|
||||||
<View className='px-3 py-3'>
|
|
||||||
<Text numberOfLines={1} className='text-center font-bold'>
|
|
||||||
{t("player.next_episode")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NextEpisodeCountDownButton;
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
ends at {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
39
docs/chromecast-test-matrix.md
Normal file
39
docs/chromecast-test-matrix.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Chromecast Cast Test Matrix
|
||||||
|
|
||||||
|
Manual verification for the device-profile work. Run each row by casting the
|
||||||
|
matching media from the app to a physical Chromecast and recording the result.
|
||||||
|
|
||||||
|
**Test device:** ___________________ (model name as reported by the app)
|
||||||
|
**App build / commit:** ___________________
|
||||||
|
**Date:** ___________________
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
1. Pick a library item matching the row's codec / audio / container.
|
||||||
|
2. Cast it. Note whether it direct-plays or transcodes (server logs show
|
||||||
|
`Video is being transcoded` vs `Video is being direct played`).
|
||||||
|
3. Record the load result: OK / 2100 / infinite-loading / other.
|
||||||
|
|
||||||
|
## Matrix
|
||||||
|
|
||||||
|
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
|
||||||
|
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
|
||||||
|
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
|
||||||
|
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
|
||||||
|
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
|
||||||
|
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
|
||||||
|
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
|
||||||
|
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
|
||||||
|
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
|
||||||
|
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Highest video bitrate that loads reliably on the test device: ___________
|
||||||
|
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
|
||||||
|
`utils/casting/capabilities.ts` accordingly.
|
||||||
|
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
|
||||||
|
- Confirmed cause of the 5.1 crash (#1085): ___________
|
||||||
|
- Cases where downgrade-on-failure retry rescued playback: ___________
|
||||||
49
eas.json
49
eas.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 9.1.0"
|
"version": ">= 16.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -51,44 +52,70 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production.yml"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production-apk.yml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest",
|
||||||
|
"config": "android-production-tv.yml"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"credentialsSource": "local"
|
"credentialsSource": "local",
|
||||||
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {},
|
"production": {
|
||||||
"production_tv": {}
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "completed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production_tv": {
|
||||||
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
431
hooks/useCastAutoplay.ts
Normal file
431
hooks/useCastAutoplay.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/**
|
||||||
|
* Cast autoplay watcher.
|
||||||
|
*
|
||||||
|
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
|
||||||
|
* currently-playing episode while playback is active, and on either
|
||||||
|
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
|
||||||
|
* (b) `IDLE + FINISHED` (hard end of media),
|
||||||
|
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
|
||||||
|
* the next episode on the cast.
|
||||||
|
*
|
||||||
|
* The countdown atom is driven here; the casting-player overlay reads it.
|
||||||
|
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
|
||||||
|
* the watcher reacts by clearing its interval and refusing to retrigger for
|
||||||
|
* the same item.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
MediaPlayerIdleReason,
|
||||||
|
MediaPlayerState,
|
||||||
|
useCastDevice,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||||
|
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
|
||||||
|
* pair so the network calls are not repeated on every `mediaStatus` tick.
|
||||||
|
*/
|
||||||
|
interface NextEpisodeCache {
|
||||||
|
seriesId: string;
|
||||||
|
currentEpisodeId: string;
|
||||||
|
nextEpisode: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShouldStartCountdownParams {
|
||||||
|
playerState: MediaPlayerState | undefined;
|
||||||
|
idleReason: MediaPlayerIdleReason | undefined;
|
||||||
|
currentPositionMs: number;
|
||||||
|
outroStartMs: number | null;
|
||||||
|
outroEndMs: number | null;
|
||||||
|
skipOutro: string;
|
||||||
|
alreadyTriggered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision helper: should the countdown start *right now*?
|
||||||
|
* Exported for testability.
|
||||||
|
*/
|
||||||
|
export const shouldStartCountdown = ({
|
||||||
|
playerState,
|
||||||
|
idleReason,
|
||||||
|
currentPositionMs,
|
||||||
|
outroStartMs,
|
||||||
|
outroEndMs,
|
||||||
|
skipOutro,
|
||||||
|
alreadyTriggered,
|
||||||
|
}: ShouldStartCountdownParams): boolean => {
|
||||||
|
if (alreadyTriggered) return false;
|
||||||
|
|
||||||
|
// (b) hard end of media — fires regardless of segment availability.
|
||||||
|
if (
|
||||||
|
playerState === MediaPlayerState.IDLE &&
|
||||||
|
idleReason === MediaPlayerIdleReason.FINISHED
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
|
||||||
|
if (
|
||||||
|
skipOutro !== "auto" &&
|
||||||
|
outroStartMs != null &&
|
||||||
|
outroEndMs != null &&
|
||||||
|
currentPositionMs >= outroStartMs &&
|
||||||
|
currentPositionMs < outroEndMs
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCastAutoplay = (): void => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
const remoteMediaClient = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
|
||||||
|
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
|
||||||
|
|
||||||
|
// Continuously captured currently-playing item (full BaseItemDto, fetched
|
||||||
|
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
|
||||||
|
const capturedItemRef = useRef<BaseItemDto | null>(null);
|
||||||
|
const capturedItemIdRef = useRef<string | null>(null);
|
||||||
|
// State mirror of the captured item id so downstream effects/hooks re-run
|
||||||
|
// *after* the async getItem resolves — depending on `contentId` directly
|
||||||
|
// would fire them before the ref is populated and they'd read stale data.
|
||||||
|
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cached next-episode resolution per (seriesId, currentEpisodeId).
|
||||||
|
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
|
||||||
|
|
||||||
|
// Last item id we triggered a countdown for. Reset when captured item changes
|
||||||
|
// so the same finished episode does not retrigger.
|
||||||
|
const triggeredForItemIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Countdown interval handle.
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Track whether the atom transitioned to null while a countdown is running —
|
||||||
|
// that means the overlay cancelled, so we must not retrigger for this item.
|
||||||
|
const autoplayStateRef = useRef(autoplayState);
|
||||||
|
autoplayStateRef.current = autoplayState;
|
||||||
|
|
||||||
|
// Latest settings snapshot reachable from the interval / load callback
|
||||||
|
// without re-creating the interval on every settings change.
|
||||||
|
const settingsRef = useRef(settings);
|
||||||
|
settingsRef.current = settings;
|
||||||
|
|
||||||
|
const updateSettingsRef = useRef(updateSettings);
|
||||||
|
updateSettingsRef.current = updateSettings;
|
||||||
|
|
||||||
|
const apiRef = useRef(api);
|
||||||
|
apiRef.current = api;
|
||||||
|
const userRef = useRef(user);
|
||||||
|
userRef.current = user;
|
||||||
|
const remoteMediaClientRef = useRef(remoteMediaClient);
|
||||||
|
remoteMediaClientRef.current = remoteMediaClient;
|
||||||
|
const castDeviceRef = useRef(castDevice);
|
||||||
|
castDeviceRef.current = castDevice;
|
||||||
|
|
||||||
|
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
|
||||||
|
|
||||||
|
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentId || !api || !user?.Id) {
|
||||||
|
// No active content: clear all captured state so downstream effects /
|
||||||
|
// useSegments stop using a stale previous-item id.
|
||||||
|
capturedItemRef.current = null;
|
||||||
|
capturedItemIdRef.current = null;
|
||||||
|
setCapturedItemId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the captured id changed, reset the trigger guard immediately — the
|
||||||
|
// user moved to another episode, and that new episode should be eligible.
|
||||||
|
if (capturedItemIdRef.current !== contentId) {
|
||||||
|
triggeredForItemIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUserLibraryApi(api).getItem(
|
||||||
|
{ itemId: contentId, userId: user.Id! },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
capturedItemRef.current = res.data;
|
||||||
|
capturedItemIdRef.current = contentId;
|
||||||
|
// Publish the captured id as state *after* the ref is set, so the
|
||||||
|
// next-episode-resolve effect (keyed on this state) sees a populated
|
||||||
|
// ref by the time it runs.
|
||||||
|
setCapturedItemId(contentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError")
|
||||||
|
return;
|
||||||
|
// Non-fatal: keep whatever we last captured.
|
||||||
|
console.error("[useCastAutoplay] Failed to fetch item:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [contentId, api, user?.Id]);
|
||||||
|
|
||||||
|
// --- 2. Resolve next episode (cached per series+episode). ---
|
||||||
|
// This effect runs whenever the captured item id changes; the cache key
|
||||||
|
// prevents refetching on every mediaStatus tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const item = capturedItemRef.current;
|
||||||
|
if (!item || !api || !user) return;
|
||||||
|
if (item.Type !== "Episode") {
|
||||||
|
nextEpisodeCacheRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seriesId = item.SeriesId;
|
||||||
|
const currentEpisodeId = item.Id;
|
||||||
|
if (!seriesId || !currentEpisodeId) {
|
||||||
|
nextEpisodeCacheRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = nextEpisodeCacheRef.current;
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.seriesId === seriesId &&
|
||||||
|
cached.currentEpisodeId === currentEpisodeId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
|
||||||
|
if (cancelled) return;
|
||||||
|
nextEpisodeCacheRef.current = {
|
||||||
|
seriesId,
|
||||||
|
currentEpisodeId,
|
||||||
|
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[useCastAutoplay] Failed to resolve next episode:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Depend on the *state* mirror of the captured id rather than `contentId`
|
||||||
|
// directly: `contentId` flips synchronously on the new episode, but
|
||||||
|
// `capturedItemRef.current` is only populated after the async getItem
|
||||||
|
// resolves. Keying on `capturedItemId` (set right after the ref write)
|
||||||
|
// guarantees the ref points at the new item by the time we read it here.
|
||||||
|
}, [capturedItemId, api, user]);
|
||||||
|
|
||||||
|
// --- 3. Media segments for the captured item (Outro). ---
|
||||||
|
// Matches `useChromecastSegments`: cast playback is online, no downloaded
|
||||||
|
// files context to thread through.
|
||||||
|
const { data: segmentData } = useSegments(
|
||||||
|
capturedItemId ?? "",
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
|
||||||
|
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
|
||||||
|
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
|
||||||
|
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
|
||||||
|
|
||||||
|
// --- 4. Trigger detection. ---
|
||||||
|
useEffect(() => {
|
||||||
|
// Master gate: setting must allow autoplay, and a countdown must not be
|
||||||
|
// already running. The atom drives the countdown; an active atom means
|
||||||
|
// we already triggered (possibly via overlay's Play now).
|
||||||
|
if (!settings.autoPlayNextEpisode) return;
|
||||||
|
if (autoplayState !== null) return;
|
||||||
|
|
||||||
|
const maxValue = settings.maxAutoPlayEpisodeCount.value;
|
||||||
|
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
|
||||||
|
|
||||||
|
const capturedItem = capturedItemRef.current;
|
||||||
|
const capturedItemId = capturedItemIdRef.current;
|
||||||
|
if (!capturedItem || !capturedItemId) return;
|
||||||
|
if (capturedItem.Type !== "Episode") return;
|
||||||
|
|
||||||
|
const cached = nextEpisodeCacheRef.current;
|
||||||
|
if (
|
||||||
|
!cached ||
|
||||||
|
cached.currentEpisodeId !== capturedItemId ||
|
||||||
|
!cached.nextEpisode
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextEpisode = cached.nextEpisode;
|
||||||
|
|
||||||
|
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
|
||||||
|
|
||||||
|
const should = shouldStartCountdown({
|
||||||
|
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
|
||||||
|
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
|
||||||
|
currentPositionMs,
|
||||||
|
outroStartMs,
|
||||||
|
outroEndMs,
|
||||||
|
skipOutro: settings.skipOutro,
|
||||||
|
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!should) return;
|
||||||
|
|
||||||
|
triggeredForItemIdRef.current = capturedItemId;
|
||||||
|
setAutoplayState({
|
||||||
|
nextEpisode,
|
||||||
|
secondsRemaining: settings.castAutoplayCountdownSeconds,
|
||||||
|
});
|
||||||
|
// The countdown interval is started by the effect below (reacts to the
|
||||||
|
// atom transitioning to non-null), so this effect stays pure-decide.
|
||||||
|
}, [
|
||||||
|
mediaStatus?.playerState,
|
||||||
|
mediaStatus?.idleReason,
|
||||||
|
mediaStatus?.streamPosition,
|
||||||
|
outroStartMs,
|
||||||
|
outroEndMs,
|
||||||
|
settings.autoPlayNextEpisode,
|
||||||
|
settings.autoPlayEpisodeCount,
|
||||||
|
settings.maxAutoPlayEpisodeCount,
|
||||||
|
settings.castAutoplayCountdownSeconds,
|
||||||
|
settings.skipOutro,
|
||||||
|
autoplayState,
|
||||||
|
setAutoplayState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- 5. Run countdown interval whenever atom is non-null. ---
|
||||||
|
// Starting/stopping is driven by the atom value, so an external Cancel
|
||||||
|
// (overlay) that sets the atom to null naturally tears the interval down.
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoplayState === null) {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only start an interval if one is not already running.
|
||||||
|
if (intervalRef.current) return;
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
// Read latest atom value from ref to decide what to do next.
|
||||||
|
const current = autoplayStateRef.current;
|
||||||
|
if (current === null) {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current.secondsRemaining - 1;
|
||||||
|
if (next > 0) {
|
||||||
|
setAutoplayState({ ...current, secondsRemaining: next });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time's up — load the next episode and clear.
|
||||||
|
// Snapshot what we need; clear the interval and atom synchronously to
|
||||||
|
// avoid double-fire.
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeToLoad = current.nextEpisode;
|
||||||
|
setAutoplayState(null);
|
||||||
|
|
||||||
|
const apiLocal = apiRef.current;
|
||||||
|
const userLocal = userRef.current;
|
||||||
|
const clientLocal = remoteMediaClientRef.current;
|
||||||
|
const deviceLocal = castDeviceRef.current;
|
||||||
|
const settingsLocal = settingsRef.current;
|
||||||
|
|
||||||
|
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
|
||||||
|
// same start-position derivation.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const startPositionMs =
|
||||||
|
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||||
|
|
||||||
|
const result = await loadCastMedia({
|
||||||
|
client: clientLocal,
|
||||||
|
device: deviceLocal,
|
||||||
|
api: apiLocal,
|
||||||
|
item: episodeToLoad,
|
||||||
|
userId: userLocal.Id!,
|
||||||
|
profileMode: settingsLocal.chromecastProfile,
|
||||||
|
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
|
||||||
|
options: { startPositionMs },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(
|
||||||
|
"[useCastAutoplay] Failed to load next episode:",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the freshest count at the moment of the write — the
|
||||||
|
// overlay's "Play now" can reset this to 0 in parallel, and using
|
||||||
|
// a snapshot taken before the await would clobber that reset.
|
||||||
|
updateSettingsRef.current({
|
||||||
|
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
|
||||||
|
});
|
||||||
|
toast("Playing next episode");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[useCastAutoplay] Failed to load next episode:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoplayState, setAutoplayState]);
|
||||||
|
|
||||||
|
// --- 6. Final unmount cleanup is covered by the interval effect's
|
||||||
|
// return; nothing else to do here.
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCastAutoplay;
|
||||||
69
hooks/useCastDismissGesture.ts
Normal file
69
hooks/useCastDismissGesture.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ImperativeRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
interface UseCastDismissGestureParams {
|
||||||
|
router: ImperativeRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
|
||||||
|
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
|
||||||
|
* style, and the `dismissModal` callback (also invoked by the header button).
|
||||||
|
*/
|
||||||
|
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
|
||||||
|
// Swipe down to dismiss gesture
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const context = useSharedValue({ y: 0 });
|
||||||
|
|
||||||
|
const dismissModal = useCallback(() => {
|
||||||
|
// Navigate immediately without animation to prevent crashes
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const panGesture = Gesture.Pan()
|
||||||
|
.onStart(() => {
|
||||||
|
context.value = { y: translateY.value };
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
// Only allow downward swipes from top of screen
|
||||||
|
if (event.translationY > 0) {
|
||||||
|
translateY.value = context.value.y + event.translationY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
// Dismiss if swiped down more than 150px or fast swipe
|
||||||
|
if (event.translationY > 150 || event.velocityY > 600) {
|
||||||
|
// Animate down and dismiss
|
||||||
|
translateY.value = withSpring(
|
||||||
|
1000,
|
||||||
|
{
|
||||||
|
damping: 20,
|
||||||
|
stiffness: 90,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
runOnJS(dismissModal)();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Spring back to position
|
||||||
|
translateY.value = withSpring(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { panGesture, animatedStyle, dismissModal };
|
||||||
|
}
|
||||||
156
hooks/useCastEpisodes.ts
Normal file
156
hooks/useCastEpisodes.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Device, RemoteMediaClient } from "react-native-google-cast";
|
||||||
|
import type { Settings } from "@/utils/atoms/settings";
|
||||||
|
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||||
|
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||||
|
|
||||||
|
interface UseCastEpisodesParams {
|
||||||
|
api: Api | null;
|
||||||
|
user: UserDto | null;
|
||||||
|
currentItem: BaseItemDto | null;
|
||||||
|
remoteMediaClient: RemoteMediaClient | null;
|
||||||
|
castDevice: Device | null;
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCastEpisodesResult {
|
||||||
|
episodes: BaseItemDto[];
|
||||||
|
nextEpisode: BaseItemDto | null;
|
||||||
|
seasonData: BaseItemDto | null;
|
||||||
|
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Id of the episode currently being loaded onto the cast device, or null
|
||||||
|
* when no load is pending. The cast `customData` (and thus `currentItem`)
|
||||||
|
* lags behind the load, so consumers use this to detect the stale window
|
||||||
|
* between a `loadEpisode` call and the cast reporting the new episode.
|
||||||
|
*/
|
||||||
|
loadingEpisodeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCastEpisodes({
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
currentItem,
|
||||||
|
remoteMediaClient,
|
||||||
|
castDevice,
|
||||||
|
settings,
|
||||||
|
}: UseCastEpisodesParams): UseCastEpisodesResult {
|
||||||
|
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
||||||
|
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
||||||
|
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
||||||
|
// Target episode id while a load is in flight; cleared once it resolves.
|
||||||
|
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load a different episode on the Chromecast
|
||||||
|
const loadEpisode = useCallback(
|
||||||
|
async (episode: BaseItemDto) => {
|
||||||
|
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
||||||
|
|
||||||
|
setLoadingEpisodeId(episode.Id);
|
||||||
|
try {
|
||||||
|
const startPositionMs =
|
||||||
|
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||||
|
|
||||||
|
const result = await loadCastMedia({
|
||||||
|
client: remoteMediaClient,
|
||||||
|
device: castDevice,
|
||||||
|
api,
|
||||||
|
item: episode,
|
||||||
|
userId: user.Id,
|
||||||
|
profileMode: settings.chromecastProfile,
|
||||||
|
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||||
|
options: { startPositionMs },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(
|
||||||
|
"[Casting Player] Failed to load episode:",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Casting Player] Failed to load episode:", error);
|
||||||
|
} finally {
|
||||||
|
// Clear regardless of outcome: on success `currentItem` catches up via
|
||||||
|
// customData; on failure the stale guard must not stay stuck.
|
||||||
|
setLoadingEpisodeId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
remoteMediaClient,
|
||||||
|
castDevice,
|
||||||
|
settings.chromecastProfile,
|
||||||
|
settings.chromecastMaxBitrate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch season data for season poster
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
currentItem?.Type !== "Episode" ||
|
||||||
|
!currentItem.SeasonId ||
|
||||||
|
!api ||
|
||||||
|
!user?.Id
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const fetchSeasonData = async () => {
|
||||||
|
try {
|
||||||
|
const userLibraryApi = getUserLibraryApi(api);
|
||||||
|
const response = await userLibraryApi.getItem({
|
||||||
|
itemId: currentItem.SeasonId!,
|
||||||
|
userId: user.Id!,
|
||||||
|
});
|
||||||
|
setSeasonData(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Casting Player] Failed to fetch season data:", error);
|
||||||
|
setSeasonData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSeasonData();
|
||||||
|
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
||||||
|
|
||||||
|
// Fetch episodes for TV shows
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
currentItem?.Type !== "Episode" ||
|
||||||
|
!currentItem.SeriesId ||
|
||||||
|
!api ||
|
||||||
|
!user
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const fetchEpisodes = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch ALL episodes from ALL seasons (no season filter).
|
||||||
|
const episodeList = await fetchSeriesEpisodes(
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
currentItem.SeriesId!,
|
||||||
|
);
|
||||||
|
setEpisodes(episodeList);
|
||||||
|
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch episodes:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEpisodes();
|
||||||
|
}, [
|
||||||
|
currentItem?.Type,
|
||||||
|
currentItem?.SeriesId,
|
||||||
|
currentItem?.SeasonId,
|
||||||
|
currentItem?.Id,
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
|
||||||
|
}
|
||||||
94
hooks/useCastPlayerItem.ts
Normal file
94
hooks/useCastPlayerItem.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { MediaStatus } from "react-native-google-cast";
|
||||||
|
|
||||||
|
interface UseCastPlayerItemParams {
|
||||||
|
api: Api | null;
|
||||||
|
user: UserDto | null;
|
||||||
|
mediaStatus: MediaStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCastPlayerItemResult {
|
||||||
|
fetchedItem: BaseItemDto | null;
|
||||||
|
currentItem: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCastPlayerItem({
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
mediaStatus,
|
||||||
|
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
|
||||||
|
// Fetch full item data from Jellyfin by ID
|
||||||
|
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchItemData = async () => {
|
||||||
|
const itemId = mediaStatus?.mediaInfo?.contentId;
|
||||||
|
if (!itemId || !api || !user?.Id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getUserLibraryApi(api).getItem(
|
||||||
|
{ itemId, userId: user.Id },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setFetchedItem(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError")
|
||||||
|
return;
|
||||||
|
console.error("[Casting Player] Failed to fetch item:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchItemData();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
||||||
|
|
||||||
|
// Extract item from customData, or use fetched item, or create a minimal fallback
|
||||||
|
const currentItem = useMemo(() => {
|
||||||
|
// Priority 1: Use fetched item from API (most reliable)
|
||||||
|
if (fetchedItem) {
|
||||||
|
return fetchedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Try customData from mediaStatus
|
||||||
|
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||||
|
if (
|
||||||
|
customData?.Type &&
|
||||||
|
(customData.ImageTags || customData.MediaSources || customData.Id)
|
||||||
|
) {
|
||||||
|
// Use customData if it has a real Type AND meaningful metadata
|
||||||
|
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
||||||
|
return customData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Create minimal fallback while loading
|
||||||
|
if (mediaStatus?.mediaInfo) {
|
||||||
|
const { contentId, metadata } = mediaStatus.mediaInfo;
|
||||||
|
// Derive type from metadata if available, otherwise omit to avoid
|
||||||
|
// misrepresenting episodes as movies
|
||||||
|
let metadataType: string | undefined;
|
||||||
|
if (metadata?.type === "movie") {
|
||||||
|
metadataType = "Movie";
|
||||||
|
} else if (metadata?.type === "tvShow") {
|
||||||
|
metadataType = "Episode";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Id: contentId,
|
||||||
|
Name: metadata?.title || "Unknown",
|
||||||
|
...(metadataType ? { Type: metadataType } : {}),
|
||||||
|
ServerId: "",
|
||||||
|
} as BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
||||||
|
|
||||||
|
return { fetchedItem, currentItem };
|
||||||
|
}
|
||||||
148
hooks/useCastPlayerProgress.ts
Normal file
148
hooks/useCastPlayerProgress.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||||
|
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
||||||
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
|
||||||
|
interface TrickplayTime {
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCastPlayerProgressParams {
|
||||||
|
/** Raw Chromecast media status, or null when no session. */
|
||||||
|
mediaStatus: MediaStatus | null;
|
||||||
|
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
||||||
|
fetchedItem: BaseItemDto | null;
|
||||||
|
/** Total media duration, in seconds. */
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||||
|
|
||||||
|
interface UseCastPlayerProgressResult {
|
||||||
|
/** Shared value tracking the slider progress, in milliseconds. */
|
||||||
|
sliderProgress: SharedValue<number>;
|
||||||
|
/** Shared value for the slider minimum, in milliseconds. */
|
||||||
|
sliderMin: SharedValue<number>;
|
||||||
|
/** Shared value for the slider maximum, in milliseconds. */
|
||||||
|
sliderMax: SharedValue<number>;
|
||||||
|
/** Mutable ref flag set true while the user is scrubbing. */
|
||||||
|
isScrubbing: RefObject<boolean>;
|
||||||
|
/** Trickplay time display state for the bubble. */
|
||||||
|
trickplayTime: TrickplayTime;
|
||||||
|
/** Updates the trickplay time display state. */
|
||||||
|
setTrickplayTime: (time: TrickplayTime) => void;
|
||||||
|
/** Current playback progress, in seconds (live-updating). */
|
||||||
|
progress: number;
|
||||||
|
/** Last stable playback position (seconds), for resuming across reloads. */
|
||||||
|
resumePositionRef: RefObject<number>;
|
||||||
|
/** Current trickplay image URL/coordinates, or null. */
|
||||||
|
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||||
|
/** Computes the trickplay URL for a given progress in ticks. */
|
||||||
|
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||||
|
/** Parsed trickplay metadata, or null. */
|
||||||
|
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress/slider/trickplay cluster for the casting player.
|
||||||
|
* Owns the slider shared values, scrub state, live-progress interpolation,
|
||||||
|
* resume-position tracking, and trickplay preview.
|
||||||
|
*/
|
||||||
|
export function useCastPlayerProgress({
|
||||||
|
mediaStatus,
|
||||||
|
fetchedItem,
|
||||||
|
duration,
|
||||||
|
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
||||||
|
// Shared values for progress slider (must be initialized before any early returns)
|
||||||
|
const sliderProgress = useSharedValue(0);
|
||||||
|
const sliderMin = useSharedValue(0);
|
||||||
|
const sliderMax = useSharedValue(100);
|
||||||
|
const isScrubbing = useRef(false);
|
||||||
|
|
||||||
|
// Trickplay time display
|
||||||
|
const [trickplayTime, setTrickplayTime] = useState({
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
seconds: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live progress tracking - update every second
|
||||||
|
const [liveProgress, setLiveProgress] = useState(0);
|
||||||
|
const lastSyncPositionRef = useRef(0);
|
||||||
|
const lastSyncTimestampRef = useRef(Date.now());
|
||||||
|
|
||||||
|
// Last stable playback position (seconds), for resuming across reloads.
|
||||||
|
const resumePositionRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sync refs whenever mediaStatus provides a new position
|
||||||
|
if (mediaStatus?.streamPosition !== undefined) {
|
||||||
|
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||||
|
lastSyncTimestampRef.current = Date.now();
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update every second when playing, deriving from last sync point
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (
|
||||||
|
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||||
|
mediaStatus?.streamPosition !== undefined
|
||||||
|
) {
|
||||||
|
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||||
|
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||||
|
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||||
|
// Sync with actual position when paused/buffering
|
||||||
|
setLiveProgress(mediaStatus.streamPosition);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||||
|
|
||||||
|
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||||
|
useEffect(() => {
|
||||||
|
const pos = mediaStatus?.streamPosition ?? 0;
|
||||||
|
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||||
|
resumePositionRef.current = pos;
|
||||||
|
}
|
||||||
|
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||||
|
|
||||||
|
// Derive state from raw Chromecast hooks
|
||||||
|
const progress = liveProgress; // Use live-updating progress
|
||||||
|
|
||||||
|
// Trickplay for seeking preview - use fetched item with full data
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
|
fetchedItem ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update slider max when duration changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||||
|
}
|
||||||
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
// Update slider progress when not scrubbing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isScrubbing.current && progress > 0) {
|
||||||
|
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||||
|
}
|
||||||
|
}, [progress, sliderProgress]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sliderProgress,
|
||||||
|
sliderMin,
|
||||||
|
sliderMax,
|
||||||
|
isScrubbing,
|
||||||
|
trickplayTime,
|
||||||
|
setTrickplayTime,
|
||||||
|
progress,
|
||||||
|
resumePositionRef,
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
hooks/useCastSelection.ts
Normal file
75
hooks/useCastSelection.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Source of truth for the active cast track / quality / version selection.
|
||||||
|
*
|
||||||
|
* Truth = the CastSelection echoed back in the cast media customData. A local
|
||||||
|
* `pending` selection is shown optimistically while a reload re-transcodes, then
|
||||||
|
* cleared once the cast reports it (reconciled) or the reload fails.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { MediaStatus } from "react-native-google-cast";
|
||||||
|
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
|
||||||
|
import type { CastSelection } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
interface UseCastSelectionParams {
|
||||||
|
currentItem: BaseItemDto | null;
|
||||||
|
mediaStatus: MediaStatus | null | undefined;
|
||||||
|
/** Reload the cast stream with the given selection. Resolves true on success. */
|
||||||
|
reload: (selection: CastSelection) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCastSelectionResult {
|
||||||
|
/** Effective selection: optimistic pending, else cast truth, else default. */
|
||||||
|
currentSelection: CastSelection | null;
|
||||||
|
/** Merge a partial selection, show it optimistically, and reload the stream. */
|
||||||
|
applySelection: (partial: Partial<CastSelection>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCastSelection = ({
|
||||||
|
currentItem,
|
||||||
|
mediaStatus,
|
||||||
|
reload,
|
||||||
|
}: UseCastSelectionParams): UseCastSelectionResult => {
|
||||||
|
const [pending, setPending] = useState<CastSelection | null>(null);
|
||||||
|
|
||||||
|
// Truth: the selection the cast reports as loaded, via customData.
|
||||||
|
const truth =
|
||||||
|
(
|
||||||
|
mediaStatus?.mediaInfo?.customData as
|
||||||
|
| { selection?: CastSelection }
|
||||||
|
| undefined
|
||||||
|
)?.selection ?? null;
|
||||||
|
|
||||||
|
const currentSelection: CastSelection | null =
|
||||||
|
pending ??
|
||||||
|
truth ??
|
||||||
|
(currentItem ? resolveSelection(currentItem, {}) : null);
|
||||||
|
|
||||||
|
// A new media item invalidates any pending selection from the previous one.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
|
||||||
|
useEffect(() => {
|
||||||
|
setPending(null);
|
||||||
|
}, [currentItem?.Id]);
|
||||||
|
|
||||||
|
// Reconcile: once the cast reports the pending selection as loaded, clear it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending && truth && selectionsEqual(pending, truth)) {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}, [pending, truth]);
|
||||||
|
|
||||||
|
const applySelection = useCallback(
|
||||||
|
(partial: Partial<CastSelection>) => {
|
||||||
|
if (!currentSelection) return;
|
||||||
|
const next: CastSelection = { ...currentSelection, ...partial };
|
||||||
|
setPending(next);
|
||||||
|
reload(next).then((ok) => {
|
||||||
|
if (!ok) setPending(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentSelection, reload],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { currentSelection, applySelection };
|
||||||
|
};
|
||||||
407
hooks/useCasting.ts
Normal file
407
hooks/useCasting.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Hook
|
||||||
|
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||||
|
* Architecture allows for future protocol integrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
CastState,
|
||||||
|
useCastDevice,
|
||||||
|
useCastState,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
||||||
|
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified hook for managing casting
|
||||||
|
* Extensible architecture supporting multiple protocols
|
||||||
|
*/
|
||||||
|
export const useCasting = (item: BaseItemDto | null) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// Chromecast hooks
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const castState = useCastState();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||||
|
const lastReportedProgressRef = useRef(0);
|
||||||
|
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||||
|
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
||||||
|
|
||||||
|
// Helper to update both state and ref
|
||||||
|
const updateState = useCallback(
|
||||||
|
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
stateRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
|
||||||
|
const playSessionId =
|
||||||
|
(
|
||||||
|
mediaStatus?.mediaInfo?.customData as
|
||||||
|
| { playSessionId?: string }
|
||||||
|
| undefined
|
||||||
|
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
|
||||||
|
|
||||||
|
const playMethod =
|
||||||
|
(
|
||||||
|
mediaStatus?.mediaInfo?.customData as
|
||||||
|
| { playMethod?: "Transcode" | "DirectPlay" }
|
||||||
|
| undefined
|
||||||
|
)?.playMethod ?? "Transcode";
|
||||||
|
|
||||||
|
// Detect which protocol is active - use CastState for reliable detection
|
||||||
|
const chromecastConnected = castState === CastState.CONNECTED;
|
||||||
|
// Future: Add detection for other protocols here
|
||||||
|
|
||||||
|
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||||
|
? "chromecast"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isConnected = chromecastConnected;
|
||||||
|
|
||||||
|
// Update current device
|
||||||
|
useEffect(() => {
|
||||||
|
if (chromecastConnected && castDevice) {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isConnected: true,
|
||||||
|
protocol: "chromecast",
|
||||||
|
currentDevice: {
|
||||||
|
id: castDevice.deviceId,
|
||||||
|
name: castDevice.friendlyName || castDevice.deviceId,
|
||||||
|
protocol: "chromecast",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isConnected: false,
|
||||||
|
protocol: null,
|
||||||
|
currentDevice: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Future: Add device detection for other protocols
|
||||||
|
}, [chromecastConnected, castDevice]);
|
||||||
|
|
||||||
|
// Chromecast: Update playback state
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isPlaying: mediaStatus.playerState === "playing",
|
||||||
|
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||||
|
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
||||||
|
isBuffering: mediaStatus.playerState === "buffering",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [mediaStatus, activeProtocol, updateState]);
|
||||||
|
|
||||||
|
// Chromecast: Sync volume from mediaStatus
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProtocol !== "chromecast") return;
|
||||||
|
|
||||||
|
// Sync from mediaStatus when available
|
||||||
|
if (mediaStatus?.volume !== undefined) {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volume: mediaStatus.volume,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
||||||
|
|
||||||
|
// Progress reporting to Jellyfin (matches native player behavior)
|
||||||
|
// Uses stateRef to read current progress/volume without adding them as deps
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
||||||
|
|
||||||
|
const playStateApi = getPlaystateApi(api);
|
||||||
|
|
||||||
|
// Report playback start when media begins (only once per item)
|
||||||
|
// Don't require progress > 0 — playback can legitimately start at position 0
|
||||||
|
const currentState = stateRef.current;
|
||||||
|
const isPlaybackActive =
|
||||||
|
currentState.isPlaying ||
|
||||||
|
mediaStatus?.playerState === "playing" ||
|
||||||
|
currentState.progress > 0;
|
||||||
|
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
||||||
|
// Set synchronously before async call to prevent race condition duplicates
|
||||||
|
hasReportedStartRef.current = item.Id || null;
|
||||||
|
|
||||||
|
playStateApi
|
||||||
|
.reportPlaybackStart({
|
||||||
|
playbackStartInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: Math.floor(currentState.progress * 10000),
|
||||||
|
PlayMethod: playMethod,
|
||||||
|
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||||
|
IsMuted: currentState.volume === 0,
|
||||||
|
PlaySessionId: playSessionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Revert on failure so it can be retried
|
||||||
|
hasReportedStartRef.current = null;
|
||||||
|
console.error("[useCasting] Failed to report playback start:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportProgress = () => {
|
||||||
|
const s = stateRef.current;
|
||||||
|
// Don't report if no meaningful progress or if buffering
|
||||||
|
if (s.progress <= 0 || s.isBuffering) return;
|
||||||
|
|
||||||
|
const progressMs = Math.floor(s.progress);
|
||||||
|
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
||||||
|
const progressSeconds = Math.floor(progressMs / 1000);
|
||||||
|
|
||||||
|
// When paused, always report to keep server in sync
|
||||||
|
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
||||||
|
if (
|
||||||
|
s.isPlaying &&
|
||||||
|
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReportedProgressRef.current = progressSeconds;
|
||||||
|
|
||||||
|
playStateApi
|
||||||
|
.reportPlaybackProgress({
|
||||||
|
playbackProgressInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: progressTicks,
|
||||||
|
IsPaused: !s.isPlaying,
|
||||||
|
PlayMethod: playMethod,
|
||||||
|
VolumeLevel: Math.floor(s.volume * 100),
|
||||||
|
IsMuted: s.volume === 0,
|
||||||
|
PlaySessionId: playSessionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[useCasting] Failed to report progress:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Report progress on a fixed interval, reading latest state from ref
|
||||||
|
const interval = setInterval(reportProgress, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item?.Id,
|
||||||
|
user?.Id,
|
||||||
|
isConnected,
|
||||||
|
activeProtocol,
|
||||||
|
playSessionId,
|
||||||
|
playMethod,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Play/Pause controls
|
||||||
|
const play = useCallback(async () => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
// Check if there's an active media session
|
||||||
|
if (!client || !mediaStatus?.mediaInfo) {
|
||||||
|
console.warn(
|
||||||
|
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useCasting] Error playing:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Future: Add play control for other protocols
|
||||||
|
}, [client, mediaStatus, activeProtocol]);
|
||||||
|
|
||||||
|
const pause = useCallback(async () => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
try {
|
||||||
|
await client?.pause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useCasting] Error pausing:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Future: Add pause control for other protocols
|
||||||
|
}, [client, activeProtocol]);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(async () => {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
await pause();
|
||||||
|
} else {
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
}, [state.isPlaying, play, pause]);
|
||||||
|
|
||||||
|
// Seek controls
|
||||||
|
const seek = useCallback(
|
||||||
|
async (positionMs: number) => {
|
||||||
|
// Validate position
|
||||||
|
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
||||||
|
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionSeconds = positionMs / 1000;
|
||||||
|
|
||||||
|
// Additional validation for Chromecast
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
||||||
|
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
||||||
|
const durationSeconds = state.duration / 1000;
|
||||||
|
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
||||||
|
console.warn(
|
||||||
|
"[useCasting] Seek position exceeds duration, clamping:",
|
||||||
|
positionSeconds,
|
||||||
|
"->",
|
||||||
|
durationSeconds,
|
||||||
|
);
|
||||||
|
await client?.seek({ position: durationSeconds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client?.seek({ position: positionSeconds });
|
||||||
|
}
|
||||||
|
// Future: Add seek control for other protocols
|
||||||
|
},
|
||||||
|
[client, activeProtocol, state.duration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipForward = useCallback(
|
||||||
|
async (seconds = 10) => {
|
||||||
|
const newPosition = state.progress + seconds * 1000;
|
||||||
|
await seek(Math.min(newPosition, state.duration));
|
||||||
|
},
|
||||||
|
[state.progress, state.duration, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipBackward = useCallback(
|
||||||
|
async (seconds = 10) => {
|
||||||
|
const newPosition = state.progress - seconds * 1000;
|
||||||
|
await seek(Math.max(newPosition, 0));
|
||||||
|
},
|
||||||
|
[state.progress, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop and disconnect
|
||||||
|
const stop = useCallback(
|
||||||
|
async (onStopComplete?: () => void) => {
|
||||||
|
try {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.stop();
|
||||||
|
}
|
||||||
|
// Future: Add stop control for other protocols
|
||||||
|
|
||||||
|
// Report stop to Jellyfin
|
||||||
|
if (api && item?.Id && user?.Id) {
|
||||||
|
const playStateApi = getPlaystateApi(api);
|
||||||
|
await playStateApi.reportPlaybackStopped({
|
||||||
|
playbackStopInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: stateRef.current.progress * 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useCasting] Error during stop:", error);
|
||||||
|
} finally {
|
||||||
|
hasReportedStartRef.current = null;
|
||||||
|
setState(DEFAULT_CAST_STATE);
|
||||||
|
stateRef.current = DEFAULT_CAST_STATE;
|
||||||
|
|
||||||
|
// Call callback after stop completes (e.g., to navigate away)
|
||||||
|
if (onStopComplete) {
|
||||||
|
onStopComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, api, item?.Id, user?.Id, activeProtocol],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Volume control (debounced to reduce API calls)
|
||||||
|
const setVolume = useCallback(
|
||||||
|
(volume: number) => {
|
||||||
|
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
|
||||||
|
// Update UI immediately
|
||||||
|
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||||
|
|
||||||
|
// Debounce API call
|
||||||
|
if (volumeDebounceRef.current) {
|
||||||
|
clearTimeout(volumeDebounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeDebounceRef.current = setTimeout(async () => {
|
||||||
|
if (activeProtocol === "chromecast" && client && isConnected) {
|
||||||
|
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
||||||
|
// Physical volume buttons are handled automatically by the framework
|
||||||
|
await client.setStreamVolume(clampedVolume).catch(() => {
|
||||||
|
// Ignore errors - session might have ended
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Future: Add volume control for other protocols
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[client, activeProtocol, isConnected],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (volumeDebounceRef.current) {
|
||||||
|
clearTimeout(volumeDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isConnected,
|
||||||
|
protocol: activeProtocol,
|
||||||
|
isPlaying: state.isPlaying,
|
||||||
|
isBuffering: state.isBuffering,
|
||||||
|
currentItem: item,
|
||||||
|
currentDevice: state.currentDevice,
|
||||||
|
progress: state.progress,
|
||||||
|
duration: state.duration,
|
||||||
|
volume: state.volume,
|
||||||
|
|
||||||
|
// Availability - derived from actual cast state
|
||||||
|
isChromecastAvailable:
|
||||||
|
castState === CastState.CONNECTED ||
|
||||||
|
castState === CastState.CONNECTING ||
|
||||||
|
castState === CastState.NOT_CONNECTED,
|
||||||
|
|
||||||
|
// Raw clients (for advanced operations)
|
||||||
|
remoteMediaClient: client,
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlayPause,
|
||||||
|
seek,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
|||||||
64
hooks/useRemoteControl.ts
Normal file
64
hooks/useRemoteControl.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Dispatches Jellyfin remote-control WebSocket messages to the active
|
||||||
|
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
|
||||||
|
* controller.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
|
||||||
|
import {
|
||||||
|
mapRemoteCommand,
|
||||||
|
type RemoteWsMessage,
|
||||||
|
} from "@/utils/playback/remoteCommands";
|
||||||
|
|
||||||
|
/** Handle one remote-control message (call it whenever a new WS message arrives). */
|
||||||
|
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
|
||||||
|
const controller = useAtomValue(activePlaybackControllerAtom);
|
||||||
|
const handledRef = useRef<RemoteWsMessage | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastMessage || lastMessage === handledRef.current) return;
|
||||||
|
handledRef.current = lastMessage;
|
||||||
|
const action = mapRemoteCommand(lastMessage);
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
if (action.kind === "displayMessage") {
|
||||||
|
toast(action.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller) return;
|
||||||
|
|
||||||
|
switch (action.kind) {
|
||||||
|
case "playPause":
|
||||||
|
controller.playPause();
|
||||||
|
break;
|
||||||
|
case "pause":
|
||||||
|
controller.pause();
|
||||||
|
break;
|
||||||
|
case "unpause":
|
||||||
|
controller.unpause();
|
||||||
|
break;
|
||||||
|
case "stop":
|
||||||
|
controller.stop();
|
||||||
|
break;
|
||||||
|
case "seek":
|
||||||
|
controller.seek(action.positionMs);
|
||||||
|
break;
|
||||||
|
case "next":
|
||||||
|
controller.next();
|
||||||
|
break;
|
||||||
|
case "previous":
|
||||||
|
controller.previous();
|
||||||
|
break;
|
||||||
|
case "setVolume":
|
||||||
|
controller.setVolume(action.level);
|
||||||
|
break;
|
||||||
|
case "toggleMute":
|
||||||
|
controller.toggleMute();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [lastMessage, controller]);
|
||||||
|
};
|
||||||
113
hooks/useSegmentSkipper.ts
Normal file
113
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||||
|
|
||||||
|
interface UseSegmentSkipperProps {
|
||||||
|
segments: MediaTimeSegment[];
|
||||||
|
segmentType: SegmentType;
|
||||||
|
currentTime: number;
|
||||||
|
totalDuration?: number;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
isPaused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSegmentSkipperReturn {
|
||||||
|
currentSegment: MediaTimeSegment | null;
|
||||||
|
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||||
|
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||||
|
*/
|
||||||
|
export const useSegmentSkipper = ({
|
||||||
|
segments,
|
||||||
|
segmentType,
|
||||||
|
currentTime,
|
||||||
|
totalDuration,
|
||||||
|
seek,
|
||||||
|
isPaused,
|
||||||
|
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Get skip mode based on segment type
|
||||||
|
const skipMode = (() => {
|
||||||
|
switch (segmentType) {
|
||||||
|
case "Intro":
|
||||||
|
return settings.skipIntro;
|
||||||
|
case "Outro":
|
||||||
|
return settings.skipOutro;
|
||||||
|
case "Recap":
|
||||||
|
return settings.skipRecap;
|
||||||
|
case "Commercial":
|
||||||
|
return settings.skipCommercial;
|
||||||
|
case "Preview":
|
||||||
|
return settings.skipPreview;
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Find current segment
|
||||||
|
const currentSegment =
|
||||||
|
segments.find(
|
||||||
|
(segment) =>
|
||||||
|
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
// Skip function with optional haptic feedback
|
||||||
|
const skipSegment = useCallback(
|
||||||
|
(notifyOrUseHaptics = true) => {
|
||||||
|
if (!currentSegment || skipMode === "none") return;
|
||||||
|
|
||||||
|
// For Outro segments, prevent seeking past the end
|
||||||
|
if (
|
||||||
|
segmentType === "Outro" &&
|
||||||
|
totalDuration != null &&
|
||||||
|
Number.isFinite(totalDuration)
|
||||||
|
) {
|
||||||
|
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||||
|
seek(seekTime);
|
||||||
|
} else {
|
||||||
|
seek(currentSegment.endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||||
|
if (notifyOrUseHaptics) {
|
||||||
|
haptic();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
||||||
|
);
|
||||||
|
// Auto-skip logic when mode is 'auto'
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipMode !== "auto" || isPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track segment identity to avoid re-triggering on pause/unpause
|
||||||
|
const segmentId = currentSegment
|
||||||
|
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
||||||
|
autoSkipTriggeredRef.current = segmentId;
|
||||||
|
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSegment) {
|
||||||
|
autoSkipTriggeredRef.current = null;
|
||||||
|
}
|
||||||
|
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||||
|
|
||||||
|
// Return null segment if skip mode is 'none'
|
||||||
|
return {
|
||||||
|
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||||
|
skipSegment,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -17,20 +17,24 @@ interface TrickplayUrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hook to handle trickplay logic for a given item. */
|
/** Hook to handle trickplay logic for a given item. */
|
||||||
export const useTrickplay = (item: BaseItemDto) => {
|
export const useTrickplay = (item: BaseItemDto | null) => {
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
const lastCalculationTime = useRef(0);
|
const lastCalculationTime = useRef(0);
|
||||||
const throttleDelay = 200;
|
const throttleDelay = 200;
|
||||||
const isOffline = useGlobalSearchParams().offline === "true";
|
const isOffline = useGlobalSearchParams().offline === "true";
|
||||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
const trickplayInfo = useMemo(
|
||||||
|
() => (item ? getTrickplayInfo(item) : null),
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
|
||||||
/** Generates the trickplay URL for the given item and sheet index.
|
/** Generates the trickplay URL for the given item and sheet index.
|
||||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||||
const getTrickplayUrl = useCallback(
|
const getTrickplayUrl = useCallback(
|
||||||
(item: BaseItemDto, sheetIndex: number) => {
|
(item: BaseItemDto, sheetIndex: number) => {
|
||||||
|
if (!item.Id) return null;
|
||||||
// If we are offline, we can use the downloaded item's trickplay data path
|
// If we are offline, we can use the downloaded item's trickplay data path
|
||||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
const downloadedItem = getDownloadedItemById(item.Id);
|
||||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
!trickplayInfo ||
|
!trickplayInfo ||
|
||||||
!item.Id ||
|
!item?.Id ||
|
||||||
now - lastCalculationTime.current < throttleDelay
|
now - lastCalculationTime.current < throttleDelay
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||||
if (!trickplayInfo || !item.Id) return;
|
if (!trickplayInfo || !item?.Id) return;
|
||||||
const maxConcurrent = 4;
|
const maxConcurrent = 4;
|
||||||
const total = trickplayInfo.totalImageSheets;
|
const total = trickplayInfo.totalImageSheets;
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach surface and re-enable video output.
|
* Attach surface and ensure video output is active.
|
||||||
* Based on Findroid's implementation.
|
*
|
||||||
|
* During PiP transitions, the surface is destroyed and recreated by Android.
|
||||||
|
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
||||||
|
* resumes immediately when the new surface is attached — avoiding the black
|
||||||
|
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
||||||
*/
|
*/
|
||||||
fun attachSurface(surface: Surface) {
|
fun attachSurface(surface: Surface) {
|
||||||
this.surface = surface
|
this.surface = surface
|
||||||
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.attachSurface(surface)
|
MPVLib.attachSurface(surface)
|
||||||
// Re-enable video output after attaching surface (Findroid approach)
|
|
||||||
MPVLib.setOptionString("force-window", "yes")
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
MPVLib.setOptionString("vo", voDriver)
|
// Read back vo to confirm it's still active
|
||||||
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach surface and disable video output.
|
* Detach surface without killing the VO pipeline.
|
||||||
* Based on Findroid's implementation.
|
*
|
||||||
|
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
||||||
|
* output pipeline on every surface transition. During PiP mode, the rapid
|
||||||
|
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
||||||
|
* did not properly re-initialize rendering into the new PiP surface.
|
||||||
|
*
|
||||||
|
* By keeping the VO alive, frames are simply dropped while no surface is
|
||||||
|
* attached, and rendering resumes immediately when the new surface arrives.
|
||||||
*/
|
*/
|
||||||
fun detachSurface() {
|
fun detachSurface() {
|
||||||
this.surface = null
|
this.surface = null
|
||||||
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
try {
|
|
||||||
// Disable video output before detaching surface (Findroid approach)
|
|
||||||
MPVLib.setOptionString("vo", "null")
|
|
||||||
MPVLib.setOptionString("force-window", "no")
|
|
||||||
Log.i(TAG, "Video output disabled before surface detach")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to disable video output: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
MPVLib.detachSurface()
|
MPVLib.detachSurface()
|
||||||
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "Surface size updated: ${width}x$height")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force mpv to render a frame to the current surface.
|
||||||
|
* Steps forward one frame then seeks back to the original position.
|
||||||
|
* Used after PiP entry to work around mpv stopping pixel output.
|
||||||
|
*/
|
||||||
|
fun forceRedraw() {
|
||||||
|
if (!isRunning) return
|
||||||
|
val pos = cachedPosition
|
||||||
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
|
MPVLib.command(arrayOf("frame-step"))
|
||||||
|
if (pos > 0) {
|
||||||
|
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// dropped), so we (re)apply here for embedded and external alike.
|
// dropped), so we (re)apply here for embedded and external alike.
|
||||||
// This is what makes a carried-over subtitle show up on the next
|
// This is what makes a carried-over subtitle show up on the next
|
||||||
// episode without a manual re-selection.
|
// episode without a manual re-selection.
|
||||||
if (initialAudioId != null && initialAudioId > 0) {
|
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||||
setAudioTrack(initialAudioId)
|
|
||||||
}
|
|
||||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
|
||||||
if (!isReadyToSeek) {
|
if (!isReadyToSeek) {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.graphics.Rect
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
import expo.modules.kotlin.views.ExpoView
|
||||||
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
* This mirrors the iOS MpvPlayerView implementation.
|
* Uses TextureView for reliable Picture-in-Picture support.
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event dispatchers
|
// Event dispatchers
|
||||||
val onLoad by EventDispatcher()
|
val onLoad by EventDispatcher()
|
||||||
val onPlaybackStateChange by EventDispatcher()
|
val onPlaybackStateChange by EventDispatcher()
|
||||||
val onProgress by EventDispatcher()
|
val onProgress by EventDispatcher()
|
||||||
val onError by EventDispatcher()
|
val onError by EventDispatcher()
|
||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
private var surfaceView: SurfaceView
|
|
||||||
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
private var currentUrl: String? = null
|
private var currentUrl: String? = null
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
private var cachedDuration: Double = 0.0
|
private var cachedDuration: Double = 0.0
|
||||||
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var pendingSurface: Surface? = null
|
private var pendingSurface: Surface? = null
|
||||||
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
|
|
||||||
|
// PiP state tracking
|
||||||
|
private var isWaitingForPiPTransition: Boolean = false
|
||||||
|
private var isPiPSurfaceForced: Boolean = false
|
||||||
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// Create SurfaceView for video rendering
|
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||||
surfaceView = SurfaceView(context).apply {
|
textureView = TextureView(context).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
holder.addCallback(this@MpvPlayerView)
|
surfaceTextureListener = this@MpvPlayerView
|
||||||
}
|
}
|
||||||
addView(surfaceView)
|
addView(textureView)
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(surfaceView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
override fun onSeekBy(seconds: Double) {
|
override fun onSeekBy(seconds: Double) {
|
||||||
seekBy(seconds)
|
seekBy(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
|
if (isInPiP) {
|
||||||
|
if (!isWaitingForPiPTransition) {
|
||||||
|
isWaitingForPiPTransition = true
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
restoreFromPiP()
|
||||||
|
}
|
||||||
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||||
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
try {
|
try {
|
||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
Log.i(TAG, "Renderer started with vo=$voDriver")
|
|
||||||
|
|
||||||
// If surface was created before renderer started, attach it now
|
|
||||||
pendingSurface?.let { surface ->
|
pendingSurface?.let { surface ->
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
pendingSurface = null
|
pendingSurface = null
|
||||||
Log.i(TAG, "Attached pending surface after renderer start")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SurfaceHolder.Callback
|
// MARK: - TextureView.SurfaceTextureListener
|
||||||
|
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
Log.i(TAG, "Surface created")
|
this.surfaceTexture = surfaceTexture
|
||||||
|
val surface = Surface(surfaceTexture)
|
||||||
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
renderer?.attachSurface(holder.surface)
|
renderer?.attachSurface(surface)
|
||||||
} else {
|
} else {
|
||||||
// Renderer not started yet - store surface to attach after start
|
pendingSurface = surface
|
||||||
pendingSurface = holder.surface
|
|
||||||
Log.i(TAG, "Surface created before renderer started, storing as pending")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pendingConfig = null
|
pendingConfig = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
Log.i(TAG, "Surface changed: ${width}x${height}")
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
// Update MPV with the new surface size (Findroid approach)
|
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||||
Log.i(TAG, "Surface destroyed")
|
this.surfaceTexture = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
|
return false // mpv manages the SurfaceTexture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||||
|
// Called every frame — no action needed, mpv drives rendering directly
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
|
|
||||||
fun loadVideo(config: VideoLoadConfig) {
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
loadVideoInternal(config)
|
loadVideoInternal(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadVideoInternal(config: VideoLoadConfig) {
|
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||||
currentUrl = config.url
|
currentUrl = config.url
|
||||||
|
|
||||||
renderer?.load(
|
renderer?.load(
|
||||||
url = config.url,
|
url = config.url,
|
||||||
headers = config.headers,
|
headers = config.headers,
|
||||||
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId
|
initialAudioId = config.initialAudioId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(mapOf("url" to config.url))
|
onLoad(mapOf("url" to config.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience method for simple loads
|
// Convenience method for simple loads
|
||||||
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
||||||
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
intendedPlayState = false
|
intendedPlayState = false
|
||||||
renderer?.pause()
|
renderer?.pause()
|
||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(offset: Double) {
|
fun seekBy(offset: Double) {
|
||||||
renderer?.seekBy(offset)
|
renderer?.seekBy(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
renderer?.setSpeed(speed)
|
renderer?.setSpeed(speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return renderer?.getSpeed() ?: 1.0
|
return renderer?.getSpeed() ?: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPaused(): Boolean {
|
fun isPaused(): Boolean {
|
||||||
return renderer?.isPausedState ?: true
|
return renderer?.isPausedState ?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentPosition(): Double {
|
fun getCurrentPosition(): Double {
|
||||||
return cachedPosition
|
return cachedPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDuration(): Double {
|
fun getDuration(): Double {
|
||||||
return cachedDuration
|
return cachedDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
Log.i(TAG, "startPictureInPicture called")
|
isWaitingForPiPTransition = true
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
|
// Resize buffer to match PiP window after animation settles
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||||
|
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||||
|
*/
|
||||||
|
private fun forcePiPBufferSize() {
|
||||||
|
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||||
|
|
||||||
|
val rect = Rect()
|
||||||
|
textureView.getGlobalVisibleRect(rect)
|
||||||
|
val visW = rect.width()
|
||||||
|
val visH = rect.height()
|
||||||
|
val vw = textureView.width
|
||||||
|
val vh = textureView.height
|
||||||
|
|
||||||
|
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||||
|
|
||||||
|
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||||
|
renderer?.updateSurfaceSize(visW, visH)
|
||||||
|
|
||||||
|
// Force TextureView layout to match PiP visible area.
|
||||||
|
// layoutParams alone doesn't work during PiP because the parent
|
||||||
|
// never re-lays out its children.
|
||||||
|
textureView.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||||
|
)
|
||||||
|
textureView.layout(0, 0, visW, visH)
|
||||||
|
isPiPSurfaceForced = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreFromPiP() {
|
||||||
|
if (!isPiPSurfaceForced) return
|
||||||
|
isPiPSurfaceForced = false
|
||||||
|
|
||||||
|
val lp = textureView.layoutParams
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
textureView.layoutParams = lp
|
||||||
|
textureView.requestLayout()
|
||||||
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPictureInPictureSupported(): Boolean {
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
return pipController?.isPictureInPictureSupported() ?: false
|
return pipController?.isPictureInPictureSupported() ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPictureInPictureActive(): Boolean {
|
fun isPictureInPictureActive(): Boolean {
|
||||||
return pipController?.isPictureInPictureActive() ?: false
|
return pipController?.isPictureInPictureActive() ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
return renderer?.getSubtitleTracks() ?: emptyList()
|
return renderer?.getSubtitleTracks() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
renderer?.setSubtitleTrack(trackId)
|
renderer?.setSubtitleTrack(trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
renderer?.disableSubtitles()
|
renderer?.disableSubtitles()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return renderer?.getCurrentSubtitleTrack() ?: 0
|
return renderer?.getCurrentSubtitleTrack() ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
renderer?.addSubtitleFile(url, select)
|
renderer?.addSubtitleFile(url, select)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
renderer?.setSubtitlePosition(position)
|
renderer?.setSubtitlePosition(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
renderer?.setSubtitleScale(scale)
|
renderer?.setSubtitleScale(scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
renderer?.setSubtitleMarginY(margin)
|
renderer?.setSubtitleMarginY(margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
renderer?.setSubtitleAlignX(alignment)
|
renderer?.setSubtitleAlignX(alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
renderer?.setSubtitleAlignY(alignment)
|
renderer?.setSubtitleAlignY(alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
renderer?.setSubtitleFontSize(size)
|
renderer?.setSubtitleFontSize(size)
|
||||||
}
|
}
|
||||||
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
return renderer?.getAudioTracks() ?: emptyList()
|
return renderer?.getAudioTracks() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
renderer?.setAudioTrack(trackId)
|
renderer?.setAudioTrack(trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return renderer?.getCurrentAudioTrack() ?: 0
|
return renderer?.getCurrentAudioTrack() ?: 0
|
||||||
}
|
}
|
||||||
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MPVLayerRenderer.Delegate
|
// MARK: - MPVLayerRenderer.Delegate
|
||||||
|
|
||||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
// Update PiP progress
|
// Update PiP progress
|
||||||
if (pipController?.isPictureInPictureActive() == true) {
|
if (pipController?.isPictureInPictureActive() == true) {
|
||||||
pipController?.setCurrentTime(position, duration)
|
pipController?.setCurrentTime(position, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(mapOf(
|
onProgress(mapOf(
|
||||||
"position" to position,
|
"position" to position,
|
||||||
"duration" to duration,
|
"duration" to duration,
|
||||||
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
"cacheSeconds" to cacheSeconds
|
"cacheSeconds" to cacheSeconds
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPauseChanged(isPaused: Boolean) {
|
override fun onPauseChanged(isPaused: Boolean) {
|
||||||
// Sync PiP playback rate
|
|
||||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||||
|
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isPaused" to isPaused,
|
"isPaused" to isPaused,
|
||||||
"isPlaying" to !isPaused
|
"isPlaying" to !isPaused
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingChanged(isLoading: Boolean) {
|
override fun onLoadingChanged(isLoading: Boolean) {
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isLoading" to isLoading
|
"isLoading" to isLoading
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReadyToSeek() {
|
override fun onReadyToSeek() {
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isReadyToSeek" to true
|
"isReadyToSeek" to true
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTracksReady() {
|
override fun onTracksReady() {
|
||||||
onTracksReady(emptyMap<String, Any>())
|
onTracksReady(emptyMap<String, Any>())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||||
// Update PiP controller with video dimensions for proper aspect ratio
|
|
||||||
pipController?.setVideoDimensions(width, height)
|
pipController?.setVideoDimensions(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(message: String) {
|
override fun onError(message: String) {
|
||||||
onError(mapOf("error" to message))
|
onError(mapOf("error" to message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
surfaceView.holder.removeCallback(this)
|
surfaceTexture = null
|
||||||
|
surfaceReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
super.onDetachedFromWindow()
|
super.onDetachedFromWindow()
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|||||||
@@ -1,51 +1,62 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
|
|
||||||
/**
|
|
||||||
* Picture-in-Picture controller for Android.
|
|
||||||
* This mirrors the iOS PiPController implementation.
|
|
||||||
*/
|
|
||||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PiPController"
|
private const val TAG = "PiPController"
|
||||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||||
|
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
||||||
|
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
||||||
|
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Delegate {
|
interface Delegate {
|
||||||
fun onPlay()
|
fun onPlay()
|
||||||
fun onPause()
|
fun onPause()
|
||||||
fun onSeekBy(seconds: Double)
|
fun onSeekBy(seconds: Double)
|
||||||
|
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: Delegate? = null
|
var delegate: Delegate? = null
|
||||||
|
|
||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
|
|
||||||
// Video dimensions for proper aspect ratio
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
|
|
||||||
// Reference to the player view for source rect
|
|
||||||
private var playerView: View? = null
|
private var playerView: View? = null
|
||||||
|
|
||||||
/**
|
// PiP state tracking
|
||||||
* Check if Picture-in-Picture is supported on this device
|
private var isInPiPMode: Boolean = false
|
||||||
*/
|
private var pipEntryNotified: Boolean = false
|
||||||
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||||
|
private var lifecycleRegistered = false
|
||||||
|
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
fun isPictureInPictureSupported(): Boolean {
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Picture-in-Picture is currently active
|
|
||||||
*/
|
|
||||||
fun isPictureInPictureActive(): Boolean {
|
fun isPictureInPictureActive(): Boolean {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Picture-in-Picture mode
|
|
||||||
*/
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val activity = getActivity()
|
|
||||||
if (activity == null) {
|
val activity = getActivity() ?: run {
|
||||||
Log.e(TAG, "Cannot start PiP: no activity found")
|
Log.e(TAG, "Cannot start PiP: no activity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPictureInPictureSupported()) {
|
||||||
|
Log.e(TAG, "PiP not supported on this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = buildPiPParams(forEntering = true)
|
||||||
|
val result = activity.enterPictureInPictureMode(params)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
||||||
|
isInPiPMode = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPictureInPictureSupported()) {
|
isInPiPMode = true
|
||||||
Log.e(TAG, "PiP not supported on this device")
|
pipEntryNotified = true
|
||||||
return
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
}
|
registerLifecycleCallbacks()
|
||||||
|
} catch (e: Exception) {
|
||||||
try {
|
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||||
val params = buildPiPParams(forEntering = true)
|
|
||||||
activity.enterPictureInPictureMode(params)
|
|
||||||
Log.i(TAG, "Entered PiP mode")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "PiP requires Android O or higher")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Picture-in-Picture mode
|
|
||||||
*/
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
// On Android, exiting PiP is typically done by the user
|
isInPiPMode = false
|
||||||
// or by finishing the activity. We can request to move task to back.
|
pipEntryNotified = false
|
||||||
|
unregisterLifecycleCallbacks()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
// Move task to back which will exit PiP
|
|
||||||
activity.moveTaskToBack(false)
|
activity.moveTaskToBack(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
* Update the current playback position and duration
|
|
||||||
* Note: We don't update PiP params here as we're not using progress in PiP controls
|
|
||||||
*/
|
|
||||||
fun setCurrentTime(position: Double, duration: Double) {
|
fun setCurrentTime(position: Double, duration: Double) {
|
||||||
currentPosition = position
|
currentPosition = position
|
||||||
currentDuration = duration
|
currentDuration = duration
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the playback rate (0.0 for paused, 1.0 for playing)
|
|
||||||
*/
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
|
|
||||||
// Update PiP params to reflect play/pause state
|
if (rate > 0) {
|
||||||
|
registerLifecycleCallbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
if (activity != null) {
|
||||||
try {
|
try {
|
||||||
activity.setPictureInPictureParams(buildPiPParams())
|
activity.setPictureInPictureParams(buildPiPParams())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the video dimensions for proper aspect ratio calculation
|
|
||||||
*/
|
|
||||||
fun setVideoDimensions(width: Int, height: Int) {
|
fun setVideoDimensions(width: Int, height: Int) {
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
videoWidth = width
|
videoWidth = width
|
||||||
videoHeight = height
|
videoHeight = height
|
||||||
Log.i(TAG, "Video dimensions set: ${width}x${height}")
|
|
||||||
|
|
||||||
// Update PiP params if active
|
|
||||||
updatePiPParamsIfNeeded()
|
updatePiPParamsIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the player view reference for source rect hint
|
|
||||||
*/
|
|
||||||
fun setPlayerView(view: View?) {
|
fun setPlayerView(view: View?) {
|
||||||
playerView = view
|
playerView = view
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePiPParamsIfNeeded() {
|
private fun updatePiPParamsIfNeeded() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Picture-in-Picture params for the current player state.
|
|
||||||
* Calculates proper aspect ratio and source rect based on video and view dimensions.
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||||
val view = playerView
|
val view = playerView
|
||||||
val viewWidth = view?.width ?: 0
|
val viewWidth = view?.width ?: 0
|
||||||
val viewHeight = view?.height ?: 0
|
val viewHeight = view?.height ?: 0
|
||||||
|
|
||||||
// Display aspect ratio from view (exactly like Findroid)
|
|
||||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||||
|
|
||||||
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
|
// Video aspect ratio with 2.39:1 clamping
|
||||||
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
|
|
||||||
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
|
|
||||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||||
Rational(
|
Rational(
|
||||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||||
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
} else {
|
} else {
|
||||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source rect hint calculation (exactly like Findroid)
|
|
||||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||||
if (displayAspectRatio < aspectRatio) {
|
if (displayAspectRatio < aspectRatio) {
|
||||||
// Letterboxing - black bars top/bottom
|
|
||||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(
|
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
||||||
0,
|
|
||||||
space,
|
|
||||||
viewWidth,
|
|
||||||
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Pillarboxing - black bars left/right
|
|
||||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(
|
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
||||||
space,
|
|
||||||
0,
|
|
||||||
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
|
|
||||||
viewHeight
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val builder = PictureInPictureParams.Builder()
|
val builder = PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(aspectRatio)
|
.setAspectRatio(aspectRatio)
|
||||||
|
|
||||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||||
|
|
||||||
// On Android 12+, enable auto-enter (like Findroid)
|
ensurePiPReceiverRegistered()
|
||||||
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(true)
|
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getActivity(): Activity? {
|
private fun getActivity(): Activity? {
|
||||||
// First try Expo's AppContext (preferred in React Native)
|
|
||||||
appContext?.currentActivity?.let { return it }
|
appContext?.currentActivity?.let { return it }
|
||||||
|
|
||||||
// Fallback: Try to get from context wrapper chain
|
|
||||||
var ctx = context
|
var ctx = context
|
||||||
while (ctx is android.content.ContextWrapper) {
|
while (ctx is android.content.ContextWrapper) {
|
||||||
if (ctx is Activity) {
|
if (ctx is Activity) return ctx
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
ctx = ctx.baseContext
|
ctx = ctx.baseContext
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// MARK: - Lifecycle-based PiP Detection
|
||||||
* Handle PiP action (called from activity when user taps PiP controls)
|
|
||||||
*/
|
private fun registerLifecycleCallbacks() {
|
||||||
fun handlePiPAction(action: String) {
|
if (lifecycleRegistered) return
|
||||||
when (action) {
|
|
||||||
"play" -> delegate?.onPlay()
|
val app = context.applicationContext as? Application ?: run {
|
||||||
"pause" -> delegate?.onPause()
|
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
||||||
"skip_forward" -> delegate?.onSeekBy(10.0)
|
startFallbackPolling()
|
||||||
"skip_backward" -> delegate?.onSeekBy(-10.0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||||
|
override fun onActivityStarted(activity: Activity) {}
|
||||||
|
|
||||||
|
override fun onActivityResumed(activity: Activity) {
|
||||||
|
if (!isInPiPMode) return
|
||||||
|
if (!activity.isInPictureInPictureMode) {
|
||||||
|
isInPiPMode = false
|
||||||
|
pipEntryNotified = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityPaused(activity: Activity) {
|
||||||
|
// Proactively hide controls when user leaves while playing,
|
||||||
|
// before the PiP window captures the UI. onActivityStopped
|
||||||
|
// will restore if PiP didn't actually enter.
|
||||||
|
if (playbackRate > 0 && !isInPiPMode) {
|
||||||
|
isInPiPMode = true
|
||||||
|
pipEntryNotified = true
|
||||||
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
pipHandler.postDelayed({
|
||||||
|
val inPip = activity.isInPictureInPictureMode
|
||||||
|
|
||||||
|
if (inPip && !isInPiPMode) {
|
||||||
|
isInPiPMode = true
|
||||||
|
pipEntryNotified = true
|
||||||
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInPiPMode) return@postDelayed
|
||||||
|
if (inPip) return@postDelayed
|
||||||
|
|
||||||
|
// Not in PiP after 1s — check again to avoid false positive during transition
|
||||||
|
pipHandler.postDelayed({
|
||||||
|
if (!isInPiPMode) return@postDelayed
|
||||||
|
if (!activity.isInPictureInPictureMode) {
|
||||||
|
isInPiPMode = false
|
||||||
|
pipEntryNotified = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||||
|
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
|
isInPiPMode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||||
|
lifecycleRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterLifecycleCallbacks() {
|
||||||
|
if (!lifecycleRegistered) return
|
||||||
|
lifecycleCallbacks?.let {
|
||||||
|
(context.applicationContext as? Application)
|
||||||
|
?.unregisterActivityLifecycleCallbacks(it)
|
||||||
|
}
|
||||||
|
lifecycleCallbacks = null
|
||||||
|
lifecycleRegistered = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
unregisterPiPBroadcastReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startFallbackPolling() {
|
||||||
|
var falseReadCount = 0
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
pipHandler.postDelayed(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!isInPiPMode) return
|
||||||
|
|
||||||
|
var ctx = context
|
||||||
|
var activity: Activity? = null
|
||||||
|
while (ctx is android.content.ContextWrapper) {
|
||||||
|
if (ctx is Activity) { activity = ctx; break }
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
|
||||||
|
val stillInPip = activity?.isInPictureInPictureMode == true
|
||||||
|
|
||||||
|
if (!stillInPip) {
|
||||||
|
falseReadCount++
|
||||||
|
if (falseReadCount >= 3) {
|
||||||
|
isInPiPMode = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pipHandler.postDelayed(this, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
falseReadCount = 0
|
||||||
|
pipHandler.postDelayed(this, 1000)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PiP Remote Actions
|
||||||
|
|
||||||
|
private fun ensurePiPReceiverRegistered() {
|
||||||
|
if (pipBroadcastReceiver != null) return
|
||||||
|
|
||||||
|
pipBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_PIP_PLAY_PAUSE -> {
|
||||||
|
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
||||||
|
}
|
||||||
|
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
||||||
|
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(ACTION_PIP_PLAY_PAUSE)
|
||||||
|
addAction(ACTION_PIP_SKIP_FORWARD)
|
||||||
|
addAction(ACTION_PIP_SKIP_BACKWARD)
|
||||||
|
}
|
||||||
|
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Context.RECEIVER_EXPORTED
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterPiPBroadcastReceiver() {
|
||||||
|
pipBroadcastReceiver?.let {
|
||||||
|
try {
|
||||||
|
context.applicationContext.unregisterReceiver(it)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
pipBroadcastReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPiPActions(): List<RemoteAction> {
|
||||||
|
val isPlaying = playbackRate > 0
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
||||||
|
"Rewind", "Skip backward 10 seconds",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(
|
||||||
|
context,
|
||||||
|
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||||
|
),
|
||||||
|
if (isPlaying) "Pause" else "Play",
|
||||||
|
if (isPlaying) "Pause playback" else "Resume playback",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
||||||
|
"Fast Forward", "Skip forward 10 seconds",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return android.app.PendingIntent.getBroadcast(
|
||||||
|
context.applicationContext, 0, Intent(action), flags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'MpvPlayer'
|
s.name = 'MpvPlayer'
|
||||||
s.version = '1.0.0'
|
s.version = '1.0.0'
|
||||||
s.summary = 'MPVKit for Expo'
|
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||||
s.description = 'MPVKit for Expo'
|
s.author = 'Streamyfin'
|
||||||
s.author = 'mpvkit'
|
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||||
s.platforms = {
|
s.source = { git: '' }
|
||||||
:ios => '15.1',
|
|
||||||
:tvos => '15.1'
|
|
||||||
}
|
|
||||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
|
||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.dependency 'MPVKit-GPL'
|
s.dependency 'MPVKit'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
'DEFINES_MODULE' => 'YES',
|
'DEFINES_MODULE' => 'YES',
|
||||||
'VALID_ARCHS' => 'arm64',
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
|
||||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
|
||||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
|
||||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
|
||||||
}
|
|
||||||
|
|
||||||
s.user_target_xcconfig = {
|
|
||||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type OnPictureInPictureChangePayload = {
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
export type NowPlayingMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
|
|||||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||||
|
onPictureInPictureChange?: (event: {
|
||||||
|
nativeEvent: OnPictureInPictureChangePayload;
|
||||||
|
}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
|||||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
requireNativeView("MpvPlayer");
|
requireNativeView("MpvPlayer");
|
||||||
|
|
||||||
|
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
||||||
|
|
||||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
function MpvPlayerView(props, ref) {
|
function MpvPlayerView(props, ref) {
|
||||||
const nativeRef = useRef<any>(null);
|
const nativeRef = useRef<any>(null);
|
||||||
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
return await nativeRef.current?.getDuration();
|
return await nativeRef.current?.getDuration();
|
||||||
},
|
},
|
||||||
startPictureInPicture: async () => {
|
startPictureInPicture: async () => {
|
||||||
|
console.log(PIP_LOG, "startPictureInPicture → native");
|
||||||
await nativeRef.current?.startPictureInPicture();
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
||||||
},
|
},
|
||||||
stopPictureInPicture: async () => {
|
stopPictureInPicture: async () => {
|
||||||
|
console.log(PIP_LOG, "stopPictureInPicture → native");
|
||||||
await nativeRef.current?.stopPictureInPicture();
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
||||||
},
|
},
|
||||||
isPictureInPictureSupported: async () => {
|
isPictureInPictureSupported: async () => {
|
||||||
return await nativeRef.current?.isPictureInPictureSupported();
|
const result = await nativeRef.current?.isPictureInPictureSupported();
|
||||||
|
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
isPictureInPictureActive: async () => {
|
isPictureInPictureActive: async () => {
|
||||||
return await nativeRef.current?.isPictureInPictureActive();
|
const result = await nativeRef.current?.isPictureInPictureActive();
|
||||||
|
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
getSubtitleTracks: async () => {
|
getSubtitleTracks: async () => {
|
||||||
return await nativeRef.current?.getSubtitleTracks();
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
|||||||
@@ -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 } })}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -32,9 +32,10 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
|
"react-native-tab-view": "4.3.0",
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -162,10 +164,5 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
],
|
]
|
||||||
"patchedDependencies": {
|
|
||||||
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
|
||||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
|
||||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
|
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
new file mode 100644
|
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
||||||
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
|
|
||||||
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
||||||
--- a/ios/BottomAccessoryProvider.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
+++ b/ios/BottomAccessoryProvider.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
}
|
}
|
||||||
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
|
|||||||
@available(iOS 26.0, *)
|
@available(iOS 26.0, *)
|
||||||
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
||||||
var placementValue = "none"
|
var placementValue = "none"
|
||||||
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
||||||
--- a/ios/TabView/NewTabView.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
+++ b/ios/TabView/NewTabView.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+
|
+
|
||||||
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
||||||
--- a/ios/TabViewImpl.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
+++ b/ios/TabViewImpl.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
@@ -281,7 +281,7 @@ extension View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
|
|||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
if #available(iOS 26.0, macOS 26.0, *) {
|
||||||
if let behavior {
|
if let behavior {
|
||||||
self.tabBarMinimizeBehavior(behavior.convert())
|
self.tabBarMinimizeBehavior(behavior.convert())
|
||||||
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
||||||
--- a/ios/TabViewProps.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
+++ b/ios/TabViewProps.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
||||||
case onScrollUp
|
case onScrollUp
|
||||||
case onScrollDown
|
case onScrollDown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
||||||
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
@@ -25,15 +25,14 @@ public extension RCTView {
|
@@ -25,15 +25,14 @@ public extension RCTView {
|
||||||
return rootView.recursivelyFindSubview(whereType: targetType);
|
return rootView.recursivelyFindSubview(whereType: targetType);
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
|
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
new file mode 100644
|
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
||||||
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
|
|
||||||
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
||||||
--- a/react-native-udp.podspec
|
--- a/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
+++ b/react-native-udp.podspec
|
+++ b/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
||||||
s.homepage = package_json["homepage"]
|
s.homepage = package_json["homepage"]
|
||||||
s.license = package_json["license"]
|
s.license = package_json["license"]
|
||||||
@@ -39,6 +39,28 @@ function buildPatch() {
|
|||||||
" end",
|
" end",
|
||||||
" end",
|
" end",
|
||||||
"",
|
"",
|
||||||
|
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
|
||||||
|
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
|
||||||
|
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
|
||||||
|
" # files, so the pods-only flag above is not enough — the app's link still fails with",
|
||||||
|
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
|
||||||
|
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
|
||||||
|
" if ENV['EXPO_TV'] != '1'",
|
||||||
|
" installer.aggregate_targets.each do |agg|",
|
||||||
|
" next unless agg.user_project",
|
||||||
|
" agg.user_project.native_targets.each do |target|",
|
||||||
|
" target.build_configurations.each do |cfg|",
|
||||||
|
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
|
||||||
|
" existing = existing.join(' ') if existing.is_a?(Array)",
|
||||||
|
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
|
||||||
|
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
" agg.user_project.save",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
"",
|
||||||
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
||||||
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
||||||
" if File.exist?(filepath)",
|
" if File.exist?(filepath)",
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Segment {
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -56,6 +50,12 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
|
/** The recap segments for the item. */
|
||||||
|
recapSegments?: MediaTimeSegment[];
|
||||||
|
/** The commercial segments for the item. */
|
||||||
|
commercialSegments?: MediaTimeSegment[];
|
||||||
|
/** The preview segments for the item. */
|
||||||
|
previewSegments?: MediaTimeSegment[];
|
||||||
/** The user data for the item. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,12 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
|
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
||||||
|
recapSegments?: MediaTimeSegment[];
|
||||||
|
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
||||||
|
commercialSegments?: MediaTimeSegment[];
|
||||||
|
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
||||||
|
previewSegments?: MediaTimeSegment[];
|
||||||
/** The audio stream index selected for this download */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const initialApi = (() => {
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
const jellyfinInstance = new Jellyfin({
|
const jellyfinInstance = new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -69,6 +69,13 @@ const initialApi = (() => {
|
|||||||
|
|
||||||
const initialUser = (() => {
|
const initialUser = (() => {
|
||||||
try {
|
try {
|
||||||
|
// Only return a stored user if we also have a token. Otherwise the
|
||||||
|
// user atom would be populated while the api atom is null (e.g. after
|
||||||
|
// a logout that left stale user JSON in storage), which causes
|
||||||
|
// useProtectedRoute to keep us inside the (auth) group instead of
|
||||||
|
// redirecting to /login.
|
||||||
|
const token = storage.getString("token");
|
||||||
|
if (!token) return null;
|
||||||
const userStr = storage.getString("user");
|
const userStr = storage.getString("user");
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
return JSON.parse(userStr) as UserDto;
|
return JSON.parse(userStr) as UserDto;
|
||||||
@@ -128,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
return new Jellyfin({
|
return new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -162,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.54.0"`,
|
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
clearTVDiscoverySafely();
|
clearTVDiscoverySafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
|||||||
import { settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import {
|
||||||
|
type PlaybackController,
|
||||||
|
useRegisterPlaybackController,
|
||||||
|
} from "@/utils/playback/playbackController";
|
||||||
|
|
||||||
// Conditionally import TrackPlayer only on non-TV platforms
|
// Conditionally import TrackPlayer only on non-TV platforms
|
||||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||||
@@ -1621,6 +1625,43 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
settings?.audioLookaheadCount,
|
settings?.audioLookaheadCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// App-wide remote-control surface: wraps the existing music controls so
|
||||||
|
// remote commands can target whatever player is currently active.
|
||||||
|
const isMusicActive = state.currentTrack !== null;
|
||||||
|
|
||||||
|
const playbackController = useMemo<PlaybackController>(
|
||||||
|
() => ({
|
||||||
|
playPause: () => {
|
||||||
|
togglePlayPause();
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
pause();
|
||||||
|
},
|
||||||
|
unpause: () => {
|
||||||
|
resume();
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
stop();
|
||||||
|
},
|
||||||
|
// TrackPlayer works in seconds; the controller contract is milliseconds.
|
||||||
|
seek: (positionMs: number) => {
|
||||||
|
seek(positionMs / 1000);
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
previous: () => {
|
||||||
|
previous();
|
||||||
|
},
|
||||||
|
// The music player exposes no volume API — keep these as no-ops.
|
||||||
|
setVolume: () => {},
|
||||||
|
toggleMute: () => {},
|
||||||
|
}),
|
||||||
|
[togglePlayPause, pause, resume, stop, seek, next, previous],
|
||||||
|
);
|
||||||
|
|
||||||
|
useRegisterPlaybackController(playbackController, isMusicActive);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
|
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
|
// Route Jellyfin remote-control messages to the active player.
|
||||||
|
useRemoteControl(lastMessage);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
@@ -219,7 +222,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
IconUrl:
|
IconUrl:
|
||||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||||
PlayableMediaTypes: ["Audio", "Video"],
|
PlayableMediaTypes: ["Audio", "Video"],
|
||||||
SupportedCommands: ["Play"],
|
SupportedCommands: [
|
||||||
|
"Play",
|
||||||
|
"DisplayMessage",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
],
|
||||||
SupportsMediaControl: true,
|
SupportsMediaControl: true,
|
||||||
SupportsPersistentIdentifier: true,
|
SupportsPersistentIdentifier: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -608,7 +701,35 @@
|
|||||||
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen"
|
"downloaded_file_cancel": "Abbrechen",
|
||||||
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
|
"ends_at": "Endet um {{time}}",
|
||||||
|
"search_subtitles": "Search Subtitles",
|
||||||
|
"subtitle_tracks": "Tracks",
|
||||||
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
|
"language": "Language",
|
||||||
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search_failed": "Search failed",
|
||||||
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
@@ -617,6 +738,11 @@
|
|||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"seasons": "Staffeln",
|
"seasons": "Staffeln",
|
||||||
"season": "Staffel",
|
"season": "Staffel",
|
||||||
|
"from_this_series": "From This Series",
|
||||||
|
"more_from_this_season": "More from this Season",
|
||||||
|
"view_series": "View Series",
|
||||||
|
"view_season": "View Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
|
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
|
||||||
"overview": "Überblick",
|
"overview": "Überblick",
|
||||||
"more_with": "Mehr mit {{name}}",
|
"more_with": "Mehr mit {{name}}",
|
||||||
@@ -627,10 +753,21 @@
|
|||||||
"media_options": "Medienoptionen",
|
"media_options": "Medienoptionen",
|
||||||
"quality": "Qualität",
|
"quality": "Qualität",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Untertitel",
|
"subtitles": {
|
||||||
|
"label": "Subtitle",
|
||||||
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
|
},
|
||||||
"show_more": "Mehr anzeigen",
|
"show_more": "Mehr anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Erschien in",
|
"appeared_in": "Erschien in",
|
||||||
|
"movies": "Movies",
|
||||||
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Konnte Element nicht laden",
|
"could_not_load_item": "Konnte Element nicht laden",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -641,7 +778,13 @@
|
|||||||
"download_x_item": "{{item_count}} Elemente herunterladen",
|
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||||
"download_unwatched_only": "Nur Ungesehene",
|
"download_unwatched_only": "Nur Ungesehene",
|
||||||
"download_button": "Herunterladen"
|
"download_button": "Herunterladen"
|
||||||
}
|
},
|
||||||
|
"mark_played": "Mark as Watched",
|
||||||
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
|
"resume_playback": "Resume Playback",
|
||||||
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
|
"play_from_start": "Play from Start",
|
||||||
|
"continue_from": "Continue from {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Nächste",
|
"next": "Nächste",
|
||||||
@@ -652,7 +795,18 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Für Kinder",
|
"for_kids": "Für Kinder",
|
||||||
"news": "Nachrichten"
|
"news": "Nachrichten",
|
||||||
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
|
"no_programs": "No programs available",
|
||||||
|
"no_channels": "No channels available",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
@@ -697,6 +851,12 @@
|
|||||||
"decline": "Ablehnen",
|
"decline": "Ablehnen",
|
||||||
"requested_by": "Angefragt von {{user}}",
|
"requested_by": "Angefragt von {{user}}",
|
||||||
"unknown_user": "Unbekannter Nutzer",
|
"unknown_user": "Unbekannter Nutzer",
|
||||||
|
"select": "Select",
|
||||||
|
"request_all": "Request All",
|
||||||
|
"request_seasons": "Request Seasons",
|
||||||
|
"select_seasons": "Select Seasons",
|
||||||
|
"request_selected": "Request Selected",
|
||||||
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
@@ -716,7 +876,8 @@
|
|||||||
"search": "Suche",
|
"search": "Suche",
|
||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
"custom_links": "Links",
|
"custom_links": "Links",
|
||||||
"favorites": "Favoriten"
|
"favorites": "Favoriten",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
@@ -841,5 +1002,36 @@
|
|||||||
"show": "Nur diese Serie",
|
"show": "Nur diese Serie",
|
||||||
"all": "Alle (Standard)"
|
"all": "Alle (Standard)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"companion_login": {
|
||||||
|
"title": "Pair with TV",
|
||||||
|
"align_qr": "Align the QR code within the frame",
|
||||||
|
"enter_code_manually": "Enter code manually",
|
||||||
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
|
"pairing_code_label": "Pairing code",
|
||||||
|
"server": "Server",
|
||||||
|
"authorize_button": "Authorize",
|
||||||
|
"authorizing": "Authorizing...",
|
||||||
|
"scan_again": "Scan Again",
|
||||||
|
"done": "Done",
|
||||||
|
"success_title": "Authorization Sent",
|
||||||
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
|
"error_title": "Authorization Failed",
|
||||||
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
|
"login_as": "Log in as {{username}}?",
|
||||||
|
"on_server": "on {{server}}",
|
||||||
|
"use_different_user": "Use a different user",
|
||||||
|
"open_settings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pairing": {
|
||||||
|
"pair_with_phone": "Pair with Phone",
|
||||||
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,112 @@
|
|||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"live": "LIVE",
|
||||||
|
"mpv_player_title": "MPV Player",
|
||||||
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
|
"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_credits": "Skip Credits",
|
||||||
|
"stopPlayback": "Stop Playback",
|
||||||
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
"downloaded": "Downloaded",
|
||||||
|
"skip_outro": "Skip Outro",
|
||||||
|
"skip_recap": "Skip Recap",
|
||||||
|
"skip_commercial": "Skip Commercial",
|
||||||
|
"skip_preview": "Skip Preview",
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
|
"client_error": "Client Error",
|
||||||
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
"next_episode": "Next Episode",
|
||||||
|
"refresh_tracks": "Refresh Tracks",
|
||||||
|
"audio_tracks": "Audio Tracks:",
|
||||||
|
"playback_state": "Playback State:",
|
||||||
|
"index": "Index:",
|
||||||
|
"continue_watching": "Continue Watching",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel",
|
||||||
|
"up_next": "Up next",
|
||||||
|
"next_episode_in": "Next episode in {{seconds}}s",
|
||||||
|
"play_now": "Play now",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"casting_player": {
|
||||||
|
"buffering": "Buffering...",
|
||||||
|
"changing_audio": "Changing audio...",
|
||||||
|
"changing_subtitles": "Changing subtitles...",
|
||||||
|
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||||
|
"connecting": "Connecting to Chromecast...",
|
||||||
|
"unknown_device": "Unknown Device",
|
||||||
|
"ending_at": "Ending at {{time}}",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"connected": "Connected",
|
||||||
|
"volume": "Volume",
|
||||||
|
"muted": "Muted",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"stop_casting": "Stop Casting",
|
||||||
|
"disconnecting": "Disconnecting...",
|
||||||
|
"chromecast": "Chromecast",
|
||||||
|
"device_name": "Device Name",
|
||||||
|
"playback_settings": "Playback Settings",
|
||||||
|
"version": "Version",
|
||||||
|
"stop": "Stop",
|
||||||
|
"quality": "Quality",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Subtitles",
|
||||||
|
"none": "None",
|
||||||
|
"playback_speed": "Playback Speed",
|
||||||
|
"normal": "Normal",
|
||||||
|
"episodes": "Episodes",
|
||||||
|
"season": "Season {{number}}",
|
||||||
|
"minutes_short": "min",
|
||||||
|
"episode_label": "Episode {{number}}",
|
||||||
|
"forced": "Forced",
|
||||||
|
"device": "Device",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"connection_quality": {
|
||||||
|
"excellent": "Excellent",
|
||||||
|
"good": "Good",
|
||||||
|
"fair": "Fair",
|
||||||
|
"poor": "Poor",
|
||||||
|
"disconnected": "Disconnected"
|
||||||
|
},
|
||||||
|
"error_title": "Chromecast Error",
|
||||||
|
"error_description": "Something went wrong with the cast session",
|
||||||
|
"retry": "Try Again",
|
||||||
|
"critical_error_title": "Multiple Errors Detected",
|
||||||
|
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
||||||
|
"track_changed": "Track changed successfully",
|
||||||
|
"audio_track_changed": "Audio track changed",
|
||||||
|
"subtitle_track_changed": "Subtitle track changed",
|
||||||
|
"seeking": "Seeking...",
|
||||||
|
"seeking_error": "Failed to seek",
|
||||||
|
"load_failed": "Failed to load media",
|
||||||
|
"load_retry": "Retrying media load..."
|
||||||
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
@@ -365,6 +471,23 @@
|
|||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||||
|
"segment_skip_settings": "Segment Skip Settings",
|
||||||
|
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
||||||
|
"skip_intro": "Skip Intro",
|
||||||
|
"skip_intro_description": "Action when intro segment is detected",
|
||||||
|
"skip_outro": "Skip Outro/Credits",
|
||||||
|
"skip_outro_description": "Action when outro/credits segment is detected",
|
||||||
|
"skip_recap": "Skip Recap",
|
||||||
|
"skip_recap_description": "Action when recap segment is detected",
|
||||||
|
"skip_commercial": "Skip Commercial",
|
||||||
|
"skip_commercial_description": "Action when commercial segment is detected",
|
||||||
|
"skip_preview": "Skip Preview",
|
||||||
|
"skip_preview_description": "Action when preview segment is detected",
|
||||||
|
"segment_skip_none": "None",
|
||||||
|
"segment_skip_ask": "Show Skip Button",
|
||||||
|
"segment_skip_auto": "Auto Skip",
|
||||||
|
"autoplay_countdown_seconds": "Player countdown (seconds)",
|
||||||
|
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -534,6 +657,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 +701,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 +775,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",
|
||||||
@@ -677,49 +804,6 @@
|
|||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No Links"
|
"no_links": "No Links"
|
||||||
},
|
},
|
||||||
"player": {
|
|
||||||
"live": "LIVE",
|
|
||||||
"error": "Error",
|
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
|
||||||
"client_error": "Client Error",
|
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
|
||||||
"next_episode": "Next Episode",
|
|
||||||
"refresh_tracks": "Refresh Tracks",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Playback State:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue Watching",
|
|
||||||
"go_back": "Go Back",
|
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
|
||||||
"downloaded_file_yes": "Yes",
|
|
||||||
"downloaded_file_no": "No",
|
|
||||||
"downloaded_file_cancel": "Cancel",
|
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
|
||||||
"ends_at": "ends at",
|
|
||||||
"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": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
"chapter_number": "Chapter {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user