Compare commits

..

16 Commits

Author SHA1 Message Date
Gauvain
1f7d24d63f fix(nav): block same-batch duplicate pushes from TV remote double-fire 2026-07-15 01:12:52 +02:00
Gauvain
7b15b15af3 fix(nav): clarify the focus-guard comment scope 2026-07-15 00:33:27 +02:00
Gauvain
f8b8cddbfa fix(navigation): dismiss the keyboard when using the header back button
Leaving a screen with the keyboard up (e.g. the Jellyseerr login) left it
lingering over the previous screen. Dismiss it before navigating back.
2026-07-15 00:32:53 +02:00
Gauvain
3bd7507462 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-07-15 00:32:03 +02:00
github-actions[bot]
95b5dab293 feat: New Crowdin Translations (#1758)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 00:23:30 +02:00
Gauvain
2df160adb7 ci: skip PR title comment when the lint run was cancelled (#1803) 2026-07-14 19:45:42 +02:00
lance chant
8d54d65b63 feat: adding episode count indicator (#1787)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-14 19:04:15 +02:00
Gauvain
95f4d15e98 chore: remove unused TrackSheet and TVSubtitleSheet component (#1780) 2026-07-14 18:23:54 +02:00
Gauvain
77156630d6 ci: run independent workflow steps in parallel (#1788)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-07-07 23:26:52 +02:00
Gauvain
680f930a47 fix(tv): re-negotiate the stream when changing audio track while transcoding (#1791)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-07 11:46:35 +02:00
renovate[bot]
ed8744389b chore(deps): Update dependency xcode to v26.6 (#1793)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-07 00:33:41 +02:00
renovate[bot]
b879026745 chore(deps): Update dependency expo-doctor to v1.20.0 (#1792) 2026-07-06 23:57:06 +02:00
Fredrik Burmester
28a75a2b8c fix(tv): "See All" opens library and Back returns to library list (#1782)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-06-30 11:57:51 +02:00
lance chant
aa0eb0a655 fix: changing pip to use surface view (#1778)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-30 09:58:25 +02:00
Gauvain
286a3cad47 refactor: migrate app.config and Expo config plugins to TypeScript (#1718) 2026-06-30 09:03:47 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-29 19:48:32 +02:00
97 changed files with 1738 additions and 2272 deletions

View File

@@ -6,6 +6,4 @@
## Detail
**Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
@@ -30,64 +30,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
@@ -130,64 +132,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
@@ -229,19 +233,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -259,19 +277,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -301,19 +306,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -331,12 +343,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -368,19 +374,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -398,19 +418,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
@@ -438,19 +445,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -468,12 +482,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1

View File

@@ -18,19 +18,19 @@ jobs:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0

View File

@@ -20,17 +20,18 @@ jobs:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
run: bun scripts/detect-duplicate-issue.ts
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
on:
pull_request:
types: [opened, edited, synchronize, reopened]
types: [opened, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
@@ -11,39 +11,11 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-26.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-26.04
@@ -67,18 +39,26 @@ jobs:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-26.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- parallel:
- name: 🛒 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
@@ -103,24 +83,26 @@ jobs:
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- parallel:
- name: "📥 Checkout PR code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# renovate: datasource=node-version depName=node versioning=node
node-version: "24.18.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

81
.github/workflows/pr-title-comment.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: 📝 PR Title Comment
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
# read-only token on forks). workflow_run runs from the base branch with the base
# repo's token. This is comment-only — the required check stays pr-title.yml.
on:
workflow_run:
workflows: ["📝 PR Title"]
types: [completed]
permissions:
contents: read
actions: read # download the artifact from the triggering run
pull-requests: write # post/update/delete the sticky comment
concurrency:
group: pr-title-comment-${{ github.event.workflow_run.id }}
cancel-in-progress: true
jobs:
comment:
# Only run when the lint run actually produced a result artifact: a run
# cancelled by concurrency (rapid pushes to the same PR) completes with
# conclusion "cancelled" before uploading it, and would fail the download.
if: >
github.event.workflow_run.event == 'pull_request' &&
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-26.04
steps:
- name: ⬇️ Download lint result
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh run download "${{ github.event.workflow_run.id }}" \
--repo "${{ github.repository }}" \
--name pr-title-result \
--dir result
- name: 🔎 Read result
id: result
run: |
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
if [ -s result/error ]; then
echo "has_error=true" >> "$GITHUB_OUTPUT"
else
echo "has_error=false" >> "$GITHUB_OUTPUT"
fi
- name: 📥 Load error message
if: steps.result.outputs.has_error == 'true'
run: |
{
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
cat result/error
echo
echo "PR_TITLE_ERR_EOF"
} >> "$GITHUB_ENV"
- name: 💬 Post title error
if: steps.result.outputs.has_error == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ env.LINT_ERROR }}
```
- name: 🧹 Clear title error
if: steps.result.outputs.has_error == 'false'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
delete: true

42
.github/workflows/pr-title.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: 📝 PR Title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-26.04
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
# off the result (PR number + lint error) as an artifact for it to read.
- if: always()
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
run: |
mkdir -p pr-title-result
printf '%s' "$PR_NUMBER" > pr-title-result/number
printf '%s' "$LINT_ERROR" > pr-title-result/error
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-title-result
path: pr-title-result/
retention-days: 1

View File

@@ -13,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Queue successive releases in order instead of dropping the extra pending run.
queue: max
on:
push:
@@ -63,18 +65,26 @@ jobs:
artifact_name: streamyfin-android-tv-apk
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -89,13 +99,6 @@ jobs:
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 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

View File

@@ -25,19 +25,20 @@ jobs:
contents: write
pull-requests: write
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔢 Populate version dropdown from GitHub releases
id: populate

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js)
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -1,9 +1,13 @@
const { execFileSync } = require("node:child_process");
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args) => {
const git = (args: string[]): string | null => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
@@ -42,16 +46,16 @@ const buildMeta = {
builtAt: new Date().toISOString(),
};
module.exports = ({ config }) => {
export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins?.push("expo-background-task");
config.plugins.push([
config.plugins?.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
config.plugins?.push([
"expo-camera",
{
cameraPermission:
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
} as ExpoConfig;
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
[
"expo-build-properties",
{
@@ -134,17 +134,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
[
"./plugins/withGitPod.js",
"./plugins/withGitPod.ts",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -1,13 +1,12 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -22,7 +21,6 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -52,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -61,58 +59,6 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr connection state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
// Settings load storage + plugin overrides after mount, and a plugin can lock
// the URL at runtime. Keep the local field in sync with the effective value
// so it never shows stale/empty text or overwrites a locked URL on blur.
useEffect(() => {
setJellyseerrServerUrl(settings.jellyseerrServerUrl || "");
}, [settings.jellyseerrServerUrl]);
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -931,72 +877,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.password")}
value={jellyseerrPassword}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting")
: t("common.connect")
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected")
: t("common.not_connected")
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -12,11 +12,16 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
BackHandler,
FlatList,
Platform,
ScrollView,
@@ -80,8 +85,9 @@ const Page = () => {
sortBy?: string;
sortOrder?: string;
filterBy?: string;
fromSeeAll?: string;
};
const { libraryId } = searchParams;
const { libraryId, fromSeeAll } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
@@ -112,6 +118,22 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
// When this library detail was opened from the home "See All" button, its
// libraries stack is just [detail], so the default TV Back would exit to home.
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
// route to the library list instead, so the user can switch libraries. Normal
// entries from the list keep their native pop-to-list behavior.
useFocusEffect(
useCallback(() => {
if (!Platform.isTV || fromSeeAll !== "true") return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
router.replace("/(auth)/(tabs)/(libraries)");
return true;
});
return () => sub.remove();
}, [fromSeeAll, router]),
);
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
@@ -269,6 +291,23 @@ const Page = () => {
});
}, [library]);
// If this See-All detail was deep-linked on top of the libraries index, collapse
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
// which the native bottom tab reliably auto-pops back to the index (the detail
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
const didCollapseRef = useRef(false);
useEffect(() => {
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
return;
const state = navigation.getState();
if (state?.routes && state.routes.length > 1) {
didCollapseRef.current = true;
const top = state.routes[state.routes.length - 1];
navigation.reset({ index: 0, routes: [top] } as any);
}
}, [navigation, fromSeeAll]);
const fetchItems = useCallback(
async ({
pageParam,

View File

@@ -7,12 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
@@ -25,13 +20,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
@@ -52,10 +41,7 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -120,60 +106,8 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings();
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
// Prompt the user to connect when a Jellyseerr server is configured but no
// session exists yet (once per focus, and only while the tab is focused).
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
// Reset when the tab loses focus so the prompt can show again next time
// (e.g. after the user disconnects and returns).
if (!isFocused) {
jellyseerrAlertedRef.current = false;
return;
}
if (!settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr"),
t("jellyseerr.connect_in_settings"),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate the Jellyseerr session when switching to Discover; if the session
// has expired, clear the stale "connected" state and prompt to reconnect.
useEffect(() => {
if (
!isFocused ||
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
let cancelled = false;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (cancelled || status.valid || status.reason !== "expired") return;
clearAllJellyseerData();
Alert.alert(
t("jellyseerr.session_expired"),
t("jellyseerr.session_expired_connect_again"),
);
});
return () => {
cancelled = true;
};
}, [
isFocused,
searchType,
jellyseerrApi,
settings?.jellyseerrServerUrl,
clearAllJellyseerData,
t,
]);
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[

View File

@@ -893,6 +893,27 @@ export default function DirectPlayerPage() {
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// A transcoded stream only carries the audio track the server encoded
// into it — switching requires re-negotiating the stream with the new
// index (like the mobile menu's replacePlayer), not an mpv aid change.
if (isTranscoding) {
const queryParams = new URLSearchParams({
itemId: item?.Id ?? "",
audioIndex: String(index),
subtitleIndex: String(currentSubtitleIndex),
mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(),
}).toString();
// Destroy the current mpv instance BEFORE navigating, same rationale as
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
// during the transition, and two simultaneous decoders OOM-kill low-RAM
// devices. Resume is preserved via the playbackPosition param.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
return;
}
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
@@ -904,7 +925,14 @@ export default function DirectPlayerPage() {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
[
stream?.mediaSource,
item?.Id,
currentSubtitleIndex,
bitrateValue,
router,
progress,
],
);
// TV subtitle track change handler

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
InteractionManager,
ScrollView,
StyleSheet,
TVFocusGuideView,
@@ -11,10 +12,7 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv";
import {
useScaledTVTypography,
useTVRelativeScale,
} from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
@@ -25,7 +23,6 @@ export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const relativeScale = useTVRelativeScale();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
@@ -79,6 +76,20 @@ export default function TVOptionModal() {
}, [isReady]);
const handleSelect = (value: any) => {
if (modalState?.deferApplyUntilDismissed) {
// onSelect navigates (the transcode audio switch replacing the player);
// a router.replace fired while this modal is the active route would be
// swallowed. Close FIRST, apply after dismissal.
const onSelect = modalState.onSelect;
store.set(tvOptionModalAtom, null);
router.back();
InteractionManager.runAfterInteractions(() => onSelect?.(value));
return;
}
// State-only callers (detail page, library filters, settings): run before
// closing so the re-render happens while the modal is up. Deferring it until
// after dismissal re-renders the page after focus returns and yanks TV
// focus, leaving navigation stuck.
modalState?.onSelect(value);
store.set(tvOptionModalAtom, null);
router.back();
@@ -101,15 +112,8 @@ export default function TVOptionModal() {
}
const { title, options } = modalState;
// Honor the caller-provided card size (e.g. wider cards for long root-folder
// paths) and grow it in step with the user's text-scale setting so larger
// fonts don't get clipped.
const scaledCardWidth = scaleSize(
(modalState.cardWidth ?? 160) * relativeScale,
);
const scaledCardHeight = scaleSize(
(modalState.cardHeight ?? 75) * relativeScale,
);
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>

View File

@@ -15,12 +15,11 @@ import {
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton } from "@/components/tv";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
@@ -36,7 +35,6 @@ export default function TVRequestModalPage() {
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showOptions } = useTVOptionModal();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
@@ -45,6 +43,10 @@ export default function TVRequestModalPage() {
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
@@ -240,14 +242,17 @@ export default function TVRequestModalPage() {
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
@@ -348,37 +353,18 @@ export default function TVRequestModalPage() {
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() =>
showOptions({
title: t("jellyseerr.quality_profile"),
options: qualityProfileOptions,
onSelect: handleProfileChange,
})
}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() =>
showOptions({
title: t("jellyseerr.root_folder"),
options: rootFolderOptions,
onSelect: handleFolderChange,
cardWidth: 280,
})
}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() =>
showOptions({
title: t("jellyseerr.request_as"),
options: userOptions,
onSelect: handleUserChange,
})
}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
@@ -423,6 +409,33 @@ export default function TVRequestModalPage() {
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}

View File

@@ -26,7 +26,6 @@ import {
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
@@ -50,7 +49,6 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -121,10 +119,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
<Text
style={[
styles.seasonTitle,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
{ color: focused ? "#000000" : "#FFFFFF" },
]}
numberOfLines={1}
>
@@ -137,7 +132,6 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
style={[
styles.episodeCount,
{
fontSize: typography.callout - 4,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
@@ -257,15 +251,14 @@ export default function TVSeasonSelectModalPage() {
};
if (modalState.hasAdvancedRequestPermission) {
// Replace this sheet with the advanced request modal so it takes our
// place in the stack instead of stacking on top (which breaks focus).
// Close this modal and open the advanced request modal
router.back();
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
replace: true,
});
return;
}
@@ -408,7 +401,7 @@ const styles = StyleSheet.create({
gap: 16,
},
seasonCard: {
width: scaleSize(220),
width: 160,
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
@@ -422,10 +415,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
seasonInfo: {
// Note: no `flex: 1` here — the card is an auto-height column, so flex:1
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
// its content instead.
alignSelf: "stretch",
flex: 1,
},
seasonTitle: {
fontWeight: "600",
@@ -436,7 +426,9 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {},
episodeCount: {
fontSize: 14,
},
statusBadge: {
width: 22,
height: 22,

View File

@@ -109,10 +109,11 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.20.0",
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3",
},
},
@@ -294,6 +295,58 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
@@ -908,6 +961,8 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -960,7 +1015,7 @@
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
@@ -1808,6 +1863,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],

View File

@@ -1,99 +0,0 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
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";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
streamType?: string;
title: string;
}
export const TrackSheet: React.FC<Props> = ({
source,
onChange,
selected,
streamType,
title,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType],
);
const selectedSteam = useMemo(
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</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}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => {
const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index);
}
}}
/>
</View>
);
};

View File

@@ -1,43 +1,119 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { Platform, View } from "react-native";
import React from "react";
import { Platform, View, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (Platform.isTV) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
const isAggregateType = (item: BaseItemDto) =>
item.Type === "Series" || item.Type === "BoxSet";
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
const tvBadgeBase: ViewStyle = {
position: "absolute",
top: scaleSize(8),
right: scaleSize(8),
height: scaleSize(28),
borderRadius: scaleSize(14),
backgroundColor: "rgba(255,255,255,0.92)",
alignItems: "center",
justifyContent: "center",
};
// Mobile uses raw dp — no scaling.
const mobileBadgeBase: ViewStyle = {
position: "absolute",
top: 4,
right: 4,
height: 20,
borderRadius: 10,
backgroundColor: "#9333ea",
alignItems: "center",
justifyContent: "center",
};
/**
* Renders the unplayed-episode count badge for Series/BoxSet items that still
* have episodes left to watch. Returns null for non-aggregate types, fully
* watched items, or items with no unplayed count, so it is safe to mount
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
* the watched checkmark is drawn natively and only the count needs RN).
*/
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
if (!isAggregateType(item)) return null;
if (item.UserData?.Played) return null;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
if (unplayed <= 0) return null;
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
if (Platform.isTV) {
return (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
style={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Ionicons name='checkmark' size={18} color='black' />
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{label}
</Text>
</View>
);
}
return null;
return (
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
{label}
</Text>
</View>
);
},
);
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
const isAggregate = isAggregateType(item);
const isPlayed = item.UserData?.Played === true;
if (Platform.isTV) {
// Fully watched → white checkmark badge (top-right)
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
return (
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
</View>
);
}
// Series/BoxSet with remaining episodes → count badge
return <UnplayedCountBadge item={item} />;
}
// Mobile: Show purple triangle for unwatched
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
return (
<>
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
{isMovieOrEpisode && item.UserData?.Played === false && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Fully watched Series/BoxSet → small purple checkmark */}
{isAggregate && isPlayed && (
<View style={[mobileBadgeBase, { width: 20 }]}>
<Ionicons name='checkmark' size={13} color='white' />
</View>
)}
{/* Series/BoxSet with remaining episodes → count badge */}
<UnplayedCountBadge item={item} />
</>
);
};

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native";
import { Keyboard, Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
@@ -16,10 +16,17 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") {
return (
<Pressable
onPress={() => router.back()}
onPress={handleBack}
className='flex items-center justify-center w-9 h-9'
{...pressableProps}
>
@@ -30,7 +37,7 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android")
return (
<Pressable onPress={() => router.back()} {...pressableProps}>
<Pressable onPress={handleBack} {...pressableProps}>
<BlurView
{...props}
intensity={100}
@@ -48,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<Pressable
onPress={() => router.back()}
onPress={handleBack}
className=' rounded-full p-2'
{...pressableProps}
>

View File

@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const handleSeeAllPress = useCallback(() => {
if (!parentId) return;
// Navigate into the library detail (lives in the libraries tab) sorted by most
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
// the libraries stack so the native tab can't auto-pop it back to the list, and
// (b) intercept Back to route to the library list so the user can switch
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
pathname: "/[libraryId]",
params: {
libraryId: parentId,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
fromSeeAll: "true",
},
} as any);
}, [router, parentId]);
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
// No fixed width: the footer must size to the "See All" card so the
// FlatList's scrollable content extends to fully reveal it. A fixed
// (narrow) width clipped the card at the right edge. Trailing space is
// provided by contentContainerStyle.paddingRight.
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -167,7 +166,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -240,7 +238,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: sizes.padding.horizontal,
marginLeft: SCALE_PADDING,
}}
>
{slideTitle}
@@ -251,7 +249,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.horizontal,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}

View File

@@ -685,21 +685,29 @@ export const TVJellyseerrPage: React.FC = () => {
hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
>
<Ionicons
name='bag-add'
size={28}
color='#FFFFFF'
style={{ marginRight: 10 }}
/>
<Text
<View
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
{t("jellyseerr.request_all")}
</Text>
<Ionicons
name='bag-add'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_all")}
</Text>
</View>
</TVButton>
)}
@@ -711,21 +719,29 @@ export const TVJellyseerrPage: React.FC = () => {
onPress={handleOpenSeasonSelectModal}
variant='secondary'
>
<Ionicons
name='list'
size={28}
color='#FFFFFF'
style={{ marginRight: 10 }}
/>
<Text
<View
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
{t("jellyseerr.request_seasons")}
</Text>
<Ionicons
name='list'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_seasons")}
</Text>
</View>
</TVButton>
)}
</View>

View File

@@ -1,8 +1,8 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps {
item: BaseItemDto;

View File

@@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
<WatchedIndicator item={item} />
</View>
);
};

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -14,7 +13,8 @@ import type {
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { scaleSize } from "@/utils/scaleSize";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps {
item: MovieResult | TvResult;
@@ -39,8 +39,6 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
const title = getTitle(item);
const year = getYear(item);
const posterWidth = scaleSize(210);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
@@ -56,7 +54,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
style={[
animatedStyle,
{
width: posterWidth,
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -66,7 +64,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
>
<View
style={{
width: posterWidth,
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
@@ -159,16 +157,13 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null;
const containerWidth = scaleSize(160);
const avatarSize = scaleSize(140);
return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View
style={[
animatedStyle,
{
width: containerWidth,
width: 160,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
@@ -179,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
>
<View
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
width: 140,
height: 140,
borderRadius: 70,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
@@ -238,7 +233,6 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -249,7 +243,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: sizes.padding.horizontal,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -260,8 +254,8 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
@@ -291,7 +285,6 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -302,7 +295,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: sizes.padding.horizontal,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -313,8 +306,8 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
@@ -344,7 +337,6 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -355,7 +347,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: sizes.padding.horizontal,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -366,8 +358,8 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}

View File

@@ -22,6 +22,7 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
@@ -162,7 +163,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
discoverSliders,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
) : (
<View
style={{
marginHorizontal: sizes.padding.horizontal,
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
@@ -280,15 +280,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
// Top padding so the focus-scale/shadow on the first row (filter
// badges) isn't clipped against the ScrollView's top edge.
paddingTop: 16,
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}

View File

@@ -12,7 +12,10 @@ import {
} from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
{/*
The glass view draws the watched checkmark natively but cannot show
an unplayed-episode count, so render it as an RN overlay on top.
Returns null when not applicable (non-series / fully watched).
*/}
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
</View>
);
}
@@ -459,7 +468,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
<WatchedIndicator item={item} />
{showWatchedIndicator && <WatchedIndicator item={item} />}
<ProgressBar item={item} />
</View>
);

View File

@@ -564,6 +564,9 @@ export const Controls: FC<Props> = ({
title: t("item_card.audio"),
options: audioOptions,
onSelect: handleAudioChange,
// In-player audio selection navigates (replacePlayer while transcoding);
// apply it after the modal is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
});
controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]);

View File

@@ -1,560 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVLanguageCard,
TVSubtitleResultCard,
TVTabButton,
TVTrackCard,
} from "@/components/tv";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
visible: boolean;
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: MediaStream[];
currentSubtitleIndex: number;
onSubtitleIndexChange: (index: number) => void;
onClose: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
}
type TabType = "tracks" | "download";
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleIndexChange,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
console.log(
"[TVSubtitleSheet] visible:",
visible,
"tracks:",
subtitleTracks.length,
);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(null);
const {
hasOpenSubtitlesApiKey,
isSearching,
searchError,
searchResults,
search,
downloadAsync,
reset,
} = useRemoteSubtitles({
itemId: item.Id ?? "",
item,
mediaSourceId,
});
const resetRef = useRef(reset);
resetRef.current = reset;
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (!visible) {
setHasSearchedThisSession(false);
setActiveTab("tracks");
resetRef.current();
setIsReady(false);
}
}, [visible]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (visible && activeTab === "download" && !hasSearchedThisSession) {
search({ language: selectedLanguage });
setHasSearchedThisSession(true);
}
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
useEffect(() => {
if (isReady) {
setIsTabContentReady(false);
const timer = setTimeout(() => setIsTabContentReady(true), 50);
return () => clearTimeout(timer);
}
setIsTabContentReady(false);
}, [activeTab, isReady]);
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
search({ language: code });
},
[search],
);
const handleTrackSelect = useCallback(
(index: number) => {
onSubtitleIndexChange(index);
onClose();
},
[onSubtitleIndexChange, onClose],
);
const handleDownload = useCallback(
async (result: SubtitleSearchResult) => {
setDownloadingId(result.id);
try {
const downloadResult = await downloadAsync(result);
if (downloadResult.type === "server") {
onServerSubtitleDownloaded?.();
} else if (downloadResult.type === "local" && downloadResult.path) {
onLocalSubtitleDownloaded?.(downloadResult.path);
}
onClose();
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {
setDownloadingId(null);
}
},
[
downloadAsync,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
onClose,
],
);
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
const trackOptions = useMemo(() => {
const noneOption = {
label: t("item_card.subtitles.none"),
sublabel: undefined as string | undefined,
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
value: track.Index!,
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<TVLanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<TVSubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
{/* Cancel button */}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={onClose}
label={t("common.cancel") || "Cancel"}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
maxHeight: "70%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 48,
},
header: {
paddingHorizontal: 48,
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
tabRow: {
flexDirection: "row",
gap: 24,
},
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
},
tracksScroll: {
overflow: "visible",
},
tracksScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.6)",
marginTop: 12,
fontSize: 14,
},
errorContainer: {
paddingVertical: 40,
paddingHorizontal: 48,
alignItems: "center",
},
errorText: {
color: "rgba(255,100,100,0.9)",
marginTop: 8,
fontSize: 16,
fontWeight: "500",
},
errorHint: {
color: "rgba(255,255,255,0.5)",
marginTop: 4,
fontSize: 13,
textAlign: "center",
},
emptyContainer: {
paddingVertical: 40,
alignItems: "center",
},
emptyText: {
color: "rgba(255,255,255,0.5)",
marginTop: 8,
fontSize: 14,
},
apiKeyHint: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 48,
paddingTop: 8,
},
apiKeyHintText: {
color: "rgba(255,255,255,0.4)",
fontSize: 12,
},
cancelButtonContainer: {
paddingHorizontal: 48,
paddingTop: 20,
alignItems: "flex-start",
},
});

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export default {
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

@@ -24,7 +24,7 @@ export const TVTypography = {
heading: 32,
/** Overview, actor names, card titles, metadata */
body: 32,
body: 40,
/** Secondary text, labels, subtitles */
callout: 26,
@@ -55,20 +55,6 @@ export type ScaledTVTypography = {
callout: number;
};
/**
* Returns the user's text-scale factor relative to the Default scale (1.0 at
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
* (e.g. option-card width/height) in step with the scaled font so larger text
* settings don't overflow fixed boxes.
*/
export const useTVRelativeScale = (): number => {
const { settings } = useSettings();
const scale =
scaleMultipliers[settings.tvTypographyScale] ??
scaleMultipliers[TVTypographyScale.Default];
return scale / scaleMultipliers[TVTypographyScale.Default];
};
/**
* Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling.

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -1,13 +1,19 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
/**
* Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation.
* preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
*
* - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
*
* @example
* import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,36 @@ export function useAppRouter() {
const router = useRouter();
const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
// Synchronous re-entry guard for TV: a single remote "select" on Android TV
// can fire onPress more than once within the same JS batch
// (react-native-tvos#110/#138), BEFORE react-navigation commits the pushed
// route — so isFocused() still reads true for the duplicate. The ref flips
// synchronously on the first push and resets when the screen regains focus.
const pushInFlightRef = useRef(false);
useEffect(() => {
if (!navigation) return;
return navigation.addListener("focus", () => {
pushInFlightRef.current = false;
});
}, [navigation]);
const push = useCallback(
(href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation) {
if (navigation.isFocused?.() === false || pushInFlightRef.current)
return;
pushInFlightRef.current = true;
}
if (typeof href === "string") {
router.push(href as any);
} else {
@@ -36,7 +70,7 @@ export function useAppRouter() {
} as any);
}
},
[router, isOffline],
[router, isOffline, navigation],
);
const replace = useCallback(

View File

@@ -70,45 +70,6 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
/**
* Checks whether the persisted Jellyseerr session (user + cookies) is still
* valid by hitting an authenticated endpoint (`/auth/me`).
*
* Returns `expired` only when the server actually rejects the session
* (HTTP 401/403). Transient failures (network down, server unreachable, 5xx)
* are treated as `valid` so a flaky connection doesn't log the user out. This
* helper does not mutate persisted state — the caller decides whether to clear
* the session (e.g. via `clearAllJellyseerData`).
*/
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.AUTH_ME);
return { valid: true };
} catch (error) {
const status = (error as AxiosError)?.response?.status;
// Only an auth rejection means the session is gone. Anything else
// (offline, DNS failure, server error) should not be reported as expired.
if (status === 401 || status === 403) {
return { valid: false, reason: "expired" };
}
return { valid: true };
}
}
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -133,7 +94,6 @@ export enum Endpoints {
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`,
AUTH_JELLYFIN = "/auth/jellyfin",
AUTH_ME = "/auth/me",
}
export type DiscoverEndpoint =
@@ -490,8 +450,7 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient, setJellyseerrUser, updateSettings]);
}, []);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -12,6 +12,7 @@ interface ShowOptionsParams<T> {
onSelect: (value: T) => void;
cardWidth?: number;
cardHeight?: number;
deferApplyUntilDismissed?: boolean;
}
export const useTVOptionModal = () => {
@@ -26,6 +27,7 @@ export const useTVOptionModal = () => {
onSelect: params.onSelect,
cardWidth: params.cardWidth,
cardHeight: params.cardHeight,
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
});
router.push("/(auth)/tv-option-modal");
},

View File

@@ -11,12 +11,6 @@ interface ShowRequestModalParams {
id: number;
mediaType: MediaType;
onRequested: () => void;
/**
* Replace the current route instead of pushing. Use when opening the request
* modal from another modal (e.g. the season selector) so the new sheet takes
* its place rather than stacking on top of it (which breaks TV focus).
*/
replace?: boolean;
}
export const useTVRequestModal = () => {
@@ -31,11 +25,7 @@ export const useTVRequestModal = () => {
mediaType: params.mediaType,
onRequested: params.onRequested,
});
if (params.replace) {
router.replace("/(auth)/tv-request-modal");
} else {
router.push("/(auth)/tv-request-modal");
}
router.push("/(auth)/tv-request-modal");
},
[router],
);

View File

View File

@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -30,15 +28,26 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null
val demuxerMaxBackBytes: Int? = null,
)
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* Uses TextureView for reliable Picture-in-Picture support.
*
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerView"
@@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
@@ -63,31 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: 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 {
setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
// SurfaceView for video rendering. Routes the surface directly to
// SurfaceFlinger (the OS compositor), giving mpv a standalone
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
surfaceTextureListener = this@MpvPlayerView
}
addView(textureView)
surfaceView.holder.addCallback(this@MpvPlayerView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView)
pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
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
// Post size syncs after the PiP layout settles. Two passes
// catch both the immediate surface re-attach and the
// post-animation layout pass. Replaces the old TextureView
// measure/layout polling hack (forcePiPBufferSize).
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
} else {
// Restore from PiP: surface resized back to fullscreen.
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
@@ -126,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/**
* Start the renderer with the given VO driver.
* Called lazily on first loadVideo so the voDriver setting is available.
* Called lazily on first loadVideo so user settings are available.
*/
private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return
@@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
pendingSurface?.let { surface ->
// If the surface is already alive (surfaceCreated fired before
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface)
pendingSurface = null
syncSurfaceSizeToView()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -146,23 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// MARK: - TextureView.SurfaceTextureListener
// MARK: - SurfaceHolder.Callback
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
override fun surfaceCreated(holder: SurfaceHolder) {
val surface = holder.surface
surfaceReady = true
if (rendererStarted) {
// Release the previous wrapper Surface before losing the only
// reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
// The previous Surface reference is holder-owned; do NOT release
// it (SurfaceView manages its lifecycle). Just track the new one.
activeSurface = surface
renderer?.attachSurface(surface)
} else {
pendingSurface = surface
// Push the actual view dimensions immediately so mpv doesn't
// render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
}
// If we have a pending load, execute it now
@@ -173,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height)
}
}
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
// Do NOT issue mpv "stop" here. Playback continues against the
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
/**
* Read the actual SurfaceView width/height and push them to mpv.
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
}
// MARK: - Video Loading
@@ -275,7 +315,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// TextureView surface. Without this, rendererStarted stays true and
// SurfaceView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
@@ -286,13 +326,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount.
//
// SurfaceView note: the surface is owned by the holder and survives
// across destroy()/loadVideo() on the same view instance. The next
// ensureRendererStarted() reads it from surfaceView.holder.surface.
rendererStarted = false
currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null
}
@@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture
fun startPictureInPicture() {
isWaitingForPiPTransition = true
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() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
}
@@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path.
*/
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
renderer?.delegate = null
// Release the Surface that wraps the SurfaceTexture. These Surface
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
// SurfaceView owns the Surface via its holder — do NOT release it.
activeSurface = null
surfaceReady = false
currentUrl = null

View File

@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false)
val activity = getActivity() ?: return
// Push minimal params with just auto-enter disabled. Do NOT call
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
}
}
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
}
fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) {
playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) {
registerLifecycleCallbacks()
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
}
return builder.build()

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "node scripts/typecheck.js",
"typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
@@ -132,10 +132,11 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.20.0",
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3"
},
"expo": {

View File

@@ -1,4 +1,4 @@
const { withPodfile } = require("expo/config-plugins");
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch() {
function buildPatch(): string {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch() {
].join("\n");
}
module.exports = function withRuntimeFrameworkHeaders(config) {
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,3 +125,5 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

@@ -1,10 +1,20 @@
const {
import {
type ConfigPlugin,
withAndroidColors,
withAndroidColorsNight,
} = require("expo/config-plugins");
} from "expo/config-plugins";
const withAndroidAlertColors = (config) => {
const setColor = (colorsList, name, value) => {
interface ColorResourceItem {
$: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name,
);
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColors(config, (config) => {
const colors = config.modResults;
const colorsList = colors.resources.color || [];
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList;
return config;
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults;
const colorsList = colors.resources.color || [];
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList;
return config;
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
return config;
};
module.exports = withAndroidAlertColors;
export default withAndroidAlertColors;

View File

@@ -1,8 +1,12 @@
const { withAndroidManifest } = require("expo/config-plugins");
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const _withGoogleCastAndroidManifest = (config) =>
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application[0];
const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
return mod;
});
module.exports = _withGoogleCastAndroidManifest;
export default withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
const { readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { withDangerousMod } = require("expo/config-plugins");
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
withDangerousMod(expoConfig, [
"android",
(modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
},
]);
module.exports = withChangeNativeAndroidTextToWhite;
export default withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
const { withAppBuildGradle } = require("expo/config-plugins");
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
module.exports = function withExcludeMedia3Dash(config) {
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -32,3 +32,5 @@ configurations.all {
return config;
});
};
export default withExcludeMedia3Dash;

View File

@@ -1,6 +1,14 @@
const { withPodfile } = require("@expo/config-plugins");
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const withGitPod = (config, { podName, podspecUrl }) => {
interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
return withPodfile(config, (config) => {
const podfile = config.modResults.contents;
@@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => {
});
};
module.exports = withGitPod;
export default withGitPod;

View File

@@ -1,12 +1,21 @@
const { withGradleProperties } = require("expo/config-plugins");
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
function setGradlePropertiesValue(config, key, value) {
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property = {
const property: AndroidConfig.Properties.PropertiesItem = {
type: "property",
key,
value,
@@ -22,7 +31,7 @@ function setGradlePropertiesValue(config, key, value) {
});
}
module.exports = function withCustomPlugin(config) {
const withCustomGradleProperties: ConfigPlugin = (config) => {
// Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -38,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
);
return config;
};
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
const { withXcodeProject } = require("@expo/config-plugins");
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const withTVOSAppIcon = (config) => {
const withTVOSAppIcon: ConfigPlugin = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => {
});
};
module.exports = withTVOSAppIcon;
export default withTVOSAppIcon;

View File

@@ -1,8 +1,10 @@
const {
import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
} = require("@expo/config-plugins");
} from "expo/config-plugins";
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
@@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier";
function getBundleIdentifier(config) {
interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
}
function getAppGroupIdentifier(config) {
function getAppGroupIdentifier(config: ExpoConfig): string {
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
}
function getKeychainAccessGroupIdentifier(config) {
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
}
function getBuildConfigurations(project, configurationListId) {
// The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations
.map((config) => buildConfigurations[config.value])
.map((config: { value: string }) => buildConfigurations[config.value])
.filter(Boolean);
}
function ensureAppGroup(value, appGroupIdentifier) {
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier)
? groups
: [...groups, appGroupIdentifier];
}
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier)
? groups
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
}
function ensureAppExtension(
appExtensions,
targetName,
bundleIdentifier,
appGroupIdentifier,
keychainAccessGroupIdentifier,
) {
const extensionConfig = {
appExtensions: unknown,
targetName: string,
bundleIdentifier: string,
appGroupIdentifier: string,
keychainAccessGroupIdentifier: string,
): AppExtensionConfig[] {
const extensionConfig: AppExtensionConfig = {
targetName,
bundleIdentifier,
entitlements: {
@@ -63,7 +78,9 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier],
},
};
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
// Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName,
@@ -78,7 +95,7 @@ function ensureAppExtension(
);
}
const withTVOSTopShelf = (config) => {
const withTVOSTopShelf: ConfigPlugin = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config);
@@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => {
});
};
module.exports = withTVOSTopShelf;
export default withTVOSTopShelf;

View File

@@ -1,9 +1,9 @@
const { withEntitlementsPlist } = require("expo/config-plugins");
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
/**
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement = (config) => {
const withTVUserManagement: ConfigPlugin = (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
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
});
};
module.exports = withTVUserManagement;
export default withTVUserManagement;

View File

@@ -1,7 +1,7 @@
const { withDangerousMod } = require("@expo/config-plugins");
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
/**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
@@ -12,7 +12,7 @@ const path = require("node:path");
*
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/
const withTVXcodeEnv = (config) => {
const withTVXcodeEnv: ConfigPlugin = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
/**
* Get the actual node binary path, handling nvm installations.
*/
function getNodeBinaryPath() {
function getNodeBinaryPath(): string | null {
try {
// First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
return null;
}
module.exports = withTVXcodeEnv;
export default withTVXcodeEnv;

View File

@@ -1,18 +1,29 @@
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
const path = require("node:path");
const fs = require("node:fs");
import fs from "node:fs";
import path from "node:path";
import {
AndroidConfig,
type ConfigPlugin,
type ExportedConfigWithProps,
withAndroidManifest,
} from "expo/config-plugins";
const fsPromises = fs.promises;
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
const withTrustLocalCerts = (config) => {
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
const withTrustLocalCerts: ConfigPlugin = (config) => {
return withAndroidManifest(config, async (mod) => {
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
return mod;
});
};
async function setCustomConfigAsync(config, androidManifest) {
async function setCustomConfigAsync(
config: ExportedConfigWithProps<AndroidManifest>,
androidManifest: AndroidManifest,
): Promise<AndroidManifest> {
const src_file_path = path.join(__dirname, "network_security_config.xml");
const res_file_path = path.join(
await AndroidConfig.Paths.getResourceFolderAsync(
@@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) {
return androidManifest;
}
module.exports = withTrustLocalCerts;
export default withTrustLocalCerts;

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file.
*
* Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
@@ -34,9 +34,20 @@ import {
} from "node:fs";
import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name, def) => {
const flag = (name: string, def: string | boolean): string | boolean => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = {
const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
const config: I18nConfig = existsSync(CONFIG_PATH)
? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => {
const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v;
else out[key] = v as string;
}
return out;
};
const globMatch = (key, pattern) => {
const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir, files = []) => {
let entries;
const walk = (dir: string, files: string[] = []): string[] => {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
}
for (const name of entries) {
const full = join(dir, name);
let st;
let st: ReturnType<typeof statSync>;
try {
st = statSync(full);
} catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
);
const sourceKeySet = new Set(sourceKeys);
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>();
const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) =>
const literalUsed = (key: string): boolean =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key) =>
const isUsed = (key: string): boolean =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => {
const removeKey = (obj: LocaleTree, parts: string[]): void => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
const child = obj[head];
if (!child || typeof child !== "object") return;
removeKey(child, rest);
if (Object.keys(child).length === 0) delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,8 +21,14 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => {
const numEnv = (name: string, def: number): number => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
const tokens = (s: string | null): string[] =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
let issues: Issue[];
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
} else {
const raw = execFileSync(
"gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
.map((l) => JSON.parse(l) as Issue);
}
const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
const safeTitle = (t: string): string =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -1,5 +1,8 @@
const { execFileSync } = require("node:child_process");
const process = require("node:process");
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles
const colors = {
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") {
function log(message: string, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
@@ -40,7 +43,7 @@ function log(message, color = "") {
}
}
function formatError(errorLine) {
function formatError(errorLine: string): string {
if (!useColor) return errorLine;
// Color file paths in cyan
@@ -70,12 +73,15 @@ function formatError(errorLine) {
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput) {
function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map();
const formattedErrors = [];
const errorsByFile = new Map<string, number>();
const formattedErrors: string[] = [];
let currentError = [];
let currentError: string[] = [];
for (const line of lines) {
const trimmedLine = line.trim();
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
// Start new error
currentError.push(formatError(line));
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile) {
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
return table;
}
function runTypeCheck() {
function runTypeCheck(): { ok: boolean } {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
@@ -150,16 +156,13 @@ function runTypeCheck() {
"false",
...extraArgs,
];
let execArgs = null;
let execArgs: { cmd: string; args: string[] };
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch {
// fallback to PATH tsc
execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
execArgs = { cmd: "tsc", args: runnerArgs };
}
try {
@@ -183,7 +186,21 @@ function runTypeCheck() {
);
return { ok: true };
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
const execError = error as { stderr?: string; stdout?: string };
const errorOutput = [execError.stdout, execError.stderr]
.filter((chunk): chunk is string => Boolean(chunk))
.join("\n");
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
// launch failure fall through to the "passed" branch and green-light CI.
if (!errorOutput) {
const message = error instanceof Error ? error.message : String(error);
log(
`${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
colors.red,
);
return { ok: false };
}
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو",
"audio": "الصوت",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Vídeo",
"audio": "Àudio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video",
"audio": "Zvuk",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video",
"audio": "Lyd",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Sprache",
"results": "Ergebnisse",
"searching": "Suche ...",
"search_failed": "Suche fehlgeschlagen",
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
"no_subtitles_found": "Keine Untertitel gefunden",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο",
"audio": "Ήχος",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -505,11 +504,7 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"not_connected": "Not connected"
"seeAll": "See all"
},
"search": {
"search": "Search...",
@@ -625,7 +620,6 @@
"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",
@@ -736,10 +730,6 @@
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"cast": "Cast",
"details": "Details",
"status": "Status",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video",
"audio": "Ääni",
@@ -621,7 +620,6 @@
"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",

View File

@@ -4,9 +4,9 @@
"error_title": "Erreur",
"login_title": "Se connecter",
"login_to_title": "Se connecter à",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"select_user": "Sélectionnez un utilisateur pour vous connecter",
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
"add_user": "Ajouter un utilisateur",
"username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe",
"login_button": "Se connecter",
@@ -42,19 +42,19 @@
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
"remove_saved_login": "Supprimer l'identifiant enregistré",
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom dutilisateur et votre mot de passe la prochaine fois.",
"accounts_count": "Comptes {{count}}",
"accounts_count": "{{count}} comptes",
"select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_server": "Supprimer le serveur",
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
"select_your_server": "Sélectionnez votre serveur",
"add_server_to_get_started": "Ajouter un serveur pour commencer",
"add_server": "Ajouter un serveur",
"change_server": "Changer de serveur"
},
"save_account": {
"title": "Sauvegarder le compte",
"title": "Enregistrer le compte",
"save_for_later": "Enregistrer ce compte",
"security_option": "Options de sécurité",
"no_protection": "Aucune protection",
@@ -95,9 +95,9 @@
"oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder",
"continue": "Continue",
"continue": "Continuer",
"next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre",
"continue_and_next_up": "Continuer à regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
"suggested_movies": "Films suggérés",
"suggested_episodes": "Épisodes suggérés",
@@ -120,10 +120,10 @@
"settings_title": "Paramètres",
"log_out_button": "Déconnexion",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
"title": "Changer d'utilisateur",
"account": "Compte",
"switch_user": "Changer d'utilisateur sur ce serveur",
"current": "actuel"
},
"categories": {
"title": "Catégories"
@@ -136,32 +136,32 @@
},
"appearance": {
"title": "Apparence",
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
"hide_remote_session_button": "Masquer le bouton de session distante",
"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"
"show_home_backdrop": "Arrière-plan d'accueil dynamique",
"show_hero_carousel": "Carrousel principal",
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
"theme_music": "Musique de thème",
"display_size": "Taille d'affichage",
"display_size_small": "Petite",
"display_size_default": "Par défaut",
"display_size_large": "Grande",
"display_size_extra_large": "Très grande"
},
"network": {
"title": "Réseau",
"local_network": "Réseau local",
"auto_switch_enabled": "Basculement automatique quand à la maison",
"auto_switch_enabled": "Basculement automatique à la maison",
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
"local_url": "URL locale",
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
"add_current_network": "Ajouter \"{{ssid}}\"",
"not_connected_to_wifi": "Non connecté au WiFi",
"not_connected_to_wifi": "Non connecté au Wi-Fi",
"no_networks_configured": "Pas de réseau configuré",
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
"current_wifi": "WiFi actuel",
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
"current_wifi": "Wi-Fi actuel",
"using_url": "Utilisant",
"local": "URL locale",
"remote": "URL à distance",
@@ -172,9 +172,9 @@
"not_configured": "Non configuré",
"network_added": "Réseau ajouté",
"network_already_added": "Réseau déjà ajouté",
"no_wifi_connected": "Non connecté au WiFi",
"no_wifi_connected": "Non connecté au Wi-Fi",
"permission_denied": "Autorisation de localisation refusée",
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez lactiver dans les paramètres."
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
},
"user_info": {
"user_info_title": "Informations utilisateur",
@@ -184,35 +184,35 @@
"app_version": "Version de l'application"
},
"quick_connect": {
"quick_connect_title": "Connexion Rapide",
"authorize_button": "Autoriser une Connexion Rapide",
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
"quick_connect_title": "Connexion rapide",
"authorize_button": "Autoriser une connexion rapide",
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
"success": "Succès",
"quick_connect_autorized": "Connexion Rapide autorisé",
"quick_connect_autorized": "Connexion rapide autorisée",
"error": "Erreur",
"invalid_code": "Code invalide",
"authorize": "Autoriser"
},
"media_controls": {
"media_controls_title": "Contrôles Média",
"media_controls_title": "Contrôles média",
"forward_skip_length": "Durée de saut en avant",
"rewind_length": "Durée de retour en arrière",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"title": "Paramètres du tampon",
"cache_mode": "Mode de cache",
"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"
"cache_yes": "Activé",
"cache_no": "Désactivé",
"buffer_duration": "Durée du tampon",
"max_cache_size": "Taille maximale du cache",
"max_backward_cache": "Cache arrière maximal"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"title": "Sortie vidéo",
"vo_mode": "Pilote VO",
"gpu_next": "gpu-next (Recommandé)",
"gpu": "gpu"
},
"gesture_controls": {
@@ -225,8 +225,8 @@
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
"hide_volume_slider": "Masquer le curseur de volume",
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
"hide_brightness_slider": "Cacher le curseur de luminosité",
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
"hide_brightness_slider": "Masquer le curseur de luminosité",
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
},
"audio": {
"audio_title": "Audio",
@@ -234,14 +234,14 @@
"audio_language": "Langue audio",
"audio_hint": "Choisissez une langue audio par défaut.",
"none": "Aucune",
"language": "Langage",
"language": "Langue",
"transcode_mode": {
"title": "Transcodage audio",
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
"auto": "Auto",
"stereo": "Forcer la stéréo",
"5_1": "Autoriser 5.1",
"passthrough": "Intercommunication"
"passthrough": "Passthrough"
}
},
"subtitles": {
@@ -252,7 +252,7 @@
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
"subtitle_size": "Taille des sous-titres",
"none": "Aucune",
"language": "Langage",
"language": "Langue",
"loading": "Chargement",
"modes": {
"Default": "Par défaut",
@@ -262,20 +262,20 @@
"OnlyForced": "Forcés seulement"
},
"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",
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
"opensubtitles_api_key": "Clé API",
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
"mpv_subtitle_scale": "Échelle des sous-titres",
"mpv_subtitle_margin_y": "Marge verticale",
"mpv_subtitle_align_x": "Alignement horizontal",
"mpv_subtitle_align_y": "Alignement vertical",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
"left": "Gauche",
"center": "Centre",
"right": "Droite",
"top": "Haut",
"bottom": "Bas"
}
},
"other": {
@@ -283,38 +283,38 @@
"video_orientation": "Orientation vidéo",
"orientation": "Orientation",
"orientations": {
"DEFAULT": "Par défaut",
"DEFAULT": "Suivre l'orientation de l'appareil",
"ALL": "Toutes",
"PORTRAIT": "Portrait",
"PORTRAIT_UP": "Portrait Haut",
"PORTRAIT_DOWN": "Portrait Bas",
"LANDSCAPE": "Paysage",
"LANDSCAPE_LEFT": "Paysage Gauche",
"LANDSCAPE_RIGHT": "Paysage Droite",
"PORTRAIT": "Portrait auto",
"PORTRAIT_UP": "Portrait haut",
"PORTRAIT_DOWN": "Portrait bas",
"LANDSCAPE": "Paysage auto",
"LANDSCAPE_LEFT": "Paysage gauche",
"LANDSCAPE_RIGHT": "Paysage droite",
"OTHER": "Autre",
"UNKNOWN": "Inconnu"
},
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
"show_custom_menu_links": "Afficher les liens personnalisés",
"show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)",
"hide_libraries": "Cacher des bibliothèques",
"hide_libraries": "Masquer les bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut",
"default_playback_speed": "Vitesse de lecture par défaut",
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
"disabled": "Désactivé"
},
"music": {
"title": "Musique",
"playback_title": "Lecture",
"playback_description": "Configurer le mode de lecture de la musique.",
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
"prefer_downloaded": "Préférer les musiques téléchargées",
"caching_title": "Mise en cache",
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
"lookahead_enabled": "Activer la mise en cache guidée",
"lookahead_count": "Pistes à pré-mettre en cache",
"lookahead_enabled": "Activer la mise en cache anticipée",
"lookahead_count": "Pistes à mettre en cache à l'avance",
"max_cache_size": "Taille max de cache"
},
"plugins": {
@@ -333,7 +333,7 @@
"tv_quota_days": "Jours de quota de séries",
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
"unlimited": "Illimité",
"plus_n_more": "+{{n}} Plus",
"plus_n_more": "+{{n}} de plus",
"order_by": {
"DEFAULT": "Par défaut",
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
@@ -341,7 +341,7 @@
}
},
"marlin_search": {
"enable_marlin_search": "Activer Marlin Search",
"enable_marlin_search": "Activer la recherche Marlin",
"url": "URL",
"server_url_placeholder": "http(s)://domaine.org:port",
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
@@ -362,10 +362,10 @@
"features_title": "Fonctionnalités",
"enable_movie_recommendations": "Recommandations de films",
"enable_series_recommendations": "Recommandations de séries",
"enable_promoted_watchlists": "Listes de lecture promues",
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page daccueil.",
"recommended_movies": "Films Recommandés",
"enable_promoted_watchlists": "Listes de suivi promues",
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page daccueil.",
"recommended_movies": "Films recommandés",
"recommended_series": "Séries recommandées",
"toasts": {
"saved": "Enregistré",
@@ -375,7 +375,7 @@
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
},
"kefinTweaks": {
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
"watchlist_enabler": "Activer l'intégration de la liste de suivi"
}
},
"storage": {
@@ -392,10 +392,10 @@
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
"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_error_desc": "An error occurred while clearing the cache."
"clear_all_cache": "Effacer tout le cache",
"clear_all_cache_confirm": "Effacer tout le cache ?",
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
},
"intro": {
"title": "Introduction",
@@ -419,17 +419,17 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers"
},
"security": {
"title": "Security",
"title": "Sécurité",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"disabled": "Disabled",
"title": "Délai d'inactivité",
"disabled": "Désactivé",
"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"
"1_hour": "1 heure",
"4_hours": "4 heures",
"24_hours": "24 heures"
}
}
},
@@ -446,7 +446,7 @@
"delete_all_movies_button": "Supprimer tous les films",
"delete_all_series_button": "Supprimer toutes les séries",
"delete_all_button": "Supprimer tous les médias",
"delete_all_other_media_button": "Supprimer un autre média",
"delete_all_other_media_button": "Supprimer les autres médias",
"active_download": "Téléchargement actif",
"no_active_downloads": "Pas de téléchargements actifs",
"active_downloads": "Téléchargements actifs",
@@ -454,7 +454,7 @@
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
"back": "Retour",
"delete": "Supprimer",
"delete_download": "Delete Download",
"delete_download": "Supprimer le téléchargement",
"something_went_wrong": "Quelque chose s'est mal passé",
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
"eta": "ETA {{eta}}",
@@ -465,7 +465,7 @@
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
"failed_to_delete_media": "Échec de la suppression d'un autre média",
"failed_to_delete_media": "Échec de la suppression des autres médias",
"download_cancelled": "Téléchargement annulé",
"could_not_delete_download": "Impossible de supprimer le téléchargement",
"download_completed": "Téléchargement terminé",
@@ -483,17 +483,16 @@
}
},
"common": {
"no_results": "No Results",
"select": "Sélectionner",
"no_results": "Aucun résultat",
"no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo",
"audio": "Audio",
"subtitle": "Sous-titres",
"play": "Lecture",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"mark_as_played": "Marquer comme vu",
"mark_as_not_played": "Marquer comme non vu",
"none": "Aucun",
"track": "Suivre",
"track": "Piste",
"cancel": "Annuler",
"delete": "Supprimer",
"ok": "Ok",
@@ -501,15 +500,15 @@
"back": "Précédent",
"continue": "Continuer",
"verifying": "Vérification...",
"login": "Login",
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"login": "Connexion",
"episodes": "Épisodes",
"movies": "Films",
"loading": "Chargement…",
"seeAll": "Tout afficher"
},
"search": {
"search": "Rechercher...",
"x_items": "{{count}} Médias",
"x_items": "{{count}} médias",
"library": "Bibliothèque",
"discover": "Découvrir",
"no_results": "Aucun résultat",
@@ -527,7 +526,7 @@
"request_series": "Demander une série",
"recently_added": "Ajoutés récemment",
"recent_requests": "Demandes récentes",
"plex_watchlist": "Liste de lecture Plex",
"plex_watchlist": "Liste de suivi Plex",
"trending": "Tendance",
"popular_movies": "Films populaires",
"movie_genres": "Genres de films",
@@ -536,7 +535,7 @@
"popular_tv": "Séries populaires",
"tv_genres": "Genres des séries",
"upcoming_tv": "Séries à venir",
"networks": "Studios",
"networks": "Chaînes",
"tmdb_movie_keyword": "Mots-clés de film TMDB",
"tmdb_movie_genre": "Genre de film TMDB",
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
@@ -553,7 +552,7 @@
"item_types": {
"movies": "Films",
"series": "Séries",
"boxsets": "Coffrets ",
"boxsets": "Coffrets",
"playlists": "Playlists",
"items": "Médias"
},
@@ -575,10 +574,10 @@
"filter_by": "Filtrer par",
"sort_order": "Ordre de tri",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"all": "Tout",
"reset": "Réinitialiser",
"asc": "Croissant",
"desc": "Décroissant"
}
},
"favorites": {
@@ -592,11 +591,11 @@
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
},
"custom_links": {
"no_links": "Aucuns liens"
"no_links": "Aucun lien"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"live": "EN DIRECT",
"mpv_player_title": "Lecteur MPV",
"error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
@@ -611,72 +610,71 @@
"downloaded_file_yes": "Oui",
"downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded",
"missing_parameters": "Missing playback parameters"
"swipe_down_settings": "Balayez vers le bas pour les paramètres",
"ends_at": "Se termine à {{time}}",
"search_subtitles": "Rechercher des sous-titres",
"subtitle_tracks": "Pistes",
"subtitle_search": "Rechercher et télécharger",
"download": "Télécharger",
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
"language": "Langue",
"results": "Résultats",
"search_failed": "Recherche échouée",
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
"no_subtitles_found": "Aucun sous-titre trouvé",
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
"settings": "Paramètres",
"skip_intro": "Passer l'intro",
"skip_credits": "Passer le générique",
"stopPlayback": "Arrêter la lecture",
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
"downloaded": "Téléchargé",
"missing_parameters": "Paramètres de lecture manquants"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"title": "Chapitres",
"chapter_number": "Chapitre {{number}}",
"open": "Ouvrir les chapitres",
"close": "Fermer les chapitres"
},
"item_card": {
"next_up": "À suivre",
"no_items_to_display": "Aucuns médias à afficher",
"no_items_to_display": "Aucun média à afficher",
"cast_and_crew": "Distribution et équipe",
"series": "Séries",
"seasons": "Saisons",
"season": "Saison",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"from_this_series": "De cette série",
"more_from_this_season": "Plus de cette saison",
"view_series": "Voir la série",
"view_season": "Voir la saison",
"select_season": "Sélectionner une saison",
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu",
"more_with": "Plus avec {{name}}",
"similar_items": "Médias similaires",
"no_similar_items_found": "Aucuns médias similaires trouvés",
"no_similar_items_found": "Aucun média similaire trouvé",
"video": "Vidéo",
"more_details": "Plus de détails",
"media_options": "Options média",
"quality": "Qualité",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
"label": "Sous-titres",
"none": "Aucun",
"tracks": "Pistes"
},
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"left": "left",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"left": "restant",
"director": "Réalisateur",
"cast": "Distribution",
"technical_details": "Détails techniques",
"appeared_in": "Apparu dans",
"movies": "Movies",
"shows": "Shows",
"movies": "Films",
"shows": "Séries",
"could_not_load_item": "Impossible de charger le média",
"none": "Aucun",
"download": {
@@ -688,13 +686,13 @@
"download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}",
"no_data_available": "No data available"
"mark_played": "Marquer comme vu",
"mark_unplayed": "Marquer comme non vu",
"resume_playback": "Reprendre la lecture",
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
"play_from_start": "Lire depuis le début",
"continue_from": "Continuer depuis {{time}}",
"no_data_available": "Aucune donnée disponible"
},
"live_tv": {
"next": "Suivant",
@@ -706,16 +704,16 @@
"sports": "Sports",
"for_kids": "Pour enfants",
"news": "Actualités",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"page_of": "Page {{current}} sur {{total}}",
"no_programs": "Aucun programme disponible",
"no_channels": "Aucune chaîne disponible",
"tabs": {
"programs": "Programs",
"programs": "Programmes",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
"channels": "Chaînes",
"recordings": "Enregistrements",
"schedule": "Programmation",
"series": "Séries"
}
},
"jellyseerr": {
@@ -725,7 +723,7 @@
"whats_wrong": "Quel est le problème?",
"issue_type": "Type de problème",
"select_an_issue": "Sélectionnez un problème",
"types": "Types de fichiers",
"types": "Types",
"describe_the_issue": "(optionnel) Décrivez le problème...",
"submit_button": "Soumettre",
"report_issue_button": "Signaler un problème",
@@ -734,7 +732,7 @@
"failed_to_login": "Échec de la connexion",
"cast": "Distribution",
"details": "Détails",
"status": "Statuts",
"status": "Statut",
"original_title": "Titre original",
"series_type": "Type de série",
"release_dates": "Dates de sortie",
@@ -752,32 +750,32 @@
"tags": "Tags",
"quality_profile": "Profil de qualité",
"root_folder": "Dossier racine",
"season_all": "Saison (Tous)",
"season_all": "Saison (Toutes)",
"season_number": "Saison {{season_number}}",
"number_episodes": "{{episode_number}} Épisodes",
"number_episodes": "{{episode_number}} épisodes",
"born": "Né(e) le",
"appearances": "Apparences",
"appearances": "Apparitions",
"approve": "Valider",
"decline": "Refuser",
"requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"select": "Sélectionner",
"request_all": "Tout demander",
"request_seasons": "Demander des saisons",
"select_seasons": "Sélectionner les saisons",
"request_selected": "Demander la sélection",
"n_selected": "{{count}} sélectionnés",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
"issue_submitted": "Problème soumis!",
"requested_item": "{{item}}} demandé!",
"requested_item": "{{item}} demandé !",
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
"request_approved": "Demande approuvée !",
"request_declined": "Demande déclinée !",
"failed_to_approve_request": "Échec d'approbation de la demande",
"request_declined": "Demande refusée !",
"failed_to_approve_request": "Échec de l'approbation de la demande",
"failed_to_decline_request": "Échec du refus de la demande"
}
},
@@ -787,7 +785,7 @@
"library": "Bibliothèque",
"custom_links": "Liens personnalisés",
"favorites": "Favoris",
"settings": "Settings"
"settings": "Réglages"
},
"music": {
"title": "Musique",
@@ -807,13 +805,13 @@
"play_top_tracks": "Jouer les pistes les plus populaires",
"no_suggestions": "Pas de suggestion disponible",
"no_albums": "Pas d'albums trouvés",
"no_artists": "Pas d'artistes trouvé",
"no_artists": "Pas d'artistes trouvés",
"no_playlists": "Pas de playlists trouvées",
"album_not_found": "Album introuvable",
"artist_not_found": "Artiste introuvable",
"playlist_not_found": "Playlist introuvable",
"track_options": {
"play_next": "Lecture suivante",
"play_next": "Lire ensuite",
"add_to_queue": "Ajouter à la file d'attente",
"add_to_playlist": "Ajouter à la playlist",
"download": "Télécharger",
@@ -822,15 +820,15 @@
"cached": "En cache",
"delete_download": "Supprimer un téléchargement",
"delete_cache": "Supprimer du cache",
"go_to_artist": "Voir l'artiste",
"go_to_album": "Aller à lalbum",
"go_to_artist": "Aller à l'artiste",
"go_to_album": "Aller à l'album",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"remove_from_playlist": "Retirer de la playlist"
},
"playlists": {
"create_playlist": "Créer une Playlist",
"playlist_name": "Nom de la Playlist",
"create_playlist": "Créer une playlist",
"playlist_name": "Nom de la playlist",
"enter_name": "Entrer le nom de la playlist",
"create": "Créer",
"search_playlists": "Rechercher des playlists...",
@@ -841,10 +839,10 @@
"created": "Playlist créée",
"create_new": "Créer une nouvelle playlist",
"failed_to_add": "Échec de l'ajout à la playlist",
"failed_to_remove": "Échec de la suppression de la playlist",
"failed_to_create": "Échec de la suppression de la playlist",
"failed_to_remove": "Échec du retrait de la playlist",
"failed_to_create": "Échec de la création de la playlist",
"delete_playlist": "Supprimer la playlist",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.",
"deleted": "Playlist supprimée",
"failed_to_delete": "Échec de la suppression de la playlist"
},
@@ -855,46 +853,46 @@
}
},
"watchlists": {
"title": "Listes de lecture",
"my_watchlists": "Mes listes de lecture",
"public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist",
"create_button": "Créer une Watchlist",
"title": "Listes de suivi",
"my_watchlists": "Mes listes de suivi",
"public_watchlists": "Listes de suivi publiques",
"create_title": "Créer une liste de suivi",
"edit_title": "Modifier la liste de suivi",
"create_button": "Créer une liste de suivi",
"save_button": "Enregistrer les modifications",
"delete_button": "Supprimer",
"remove_button": "Retirer",
"cancel_button": "Annuler",
"name_label": "Nom",
"name_placeholder": "Entrer le nom de la playlist",
"name_placeholder": "Entrez le nom de la liste de suivi",
"description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Liste de lecture Publique",
"is_public_label": "Liste de suivi publique",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut",
"empty_title": "Pas de Watchlists",
"empty_title": "Aucune liste de suivi",
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
"empty_watchlist": "Cette liste de suivi est vide",
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
"not_configured_title": "Streamystats non configuré",
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
"go_to_settings": "Accédez aux Paramètres",
"add_to_watchlist": "Ajouter à la Watchlist",
"remove_from_watchlist": "Retirer de la Watchlist",
"go_to_settings": "Accédez aux paramètres",
"add_to_watchlist": "Ajouter à la liste de suivi",
"remove_from_watchlist": "Retirer de la liste de suivi",
"select_watchlist": "Sélectionner la liste de suivi",
"create_new": "Créer une Watchlist",
"item": "médias",
"items": "élément",
"create_new": "Créer une nouvelle liste de suivi",
"item": "élément",
"items": "éléments",
"public": "Publique",
"private": "Privée",
"you": "Vous-même",
"you": "Vous",
"by_owner": "Par un autre utilisateur",
"not_found": "Playlist introuvable",
"delete_confirm_title": "Supprimer la Watchlist",
"delete_confirm_message": "Tous les médias (par défaut)",
"remove_item_title": "Retirer de la Watchlist",
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
"not_found": "Liste de suivi introuvable",
"delete_confirm_title": "Supprimer la liste de suivi",
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
"remove_item_title": "Retirer de la liste de suivi",
"remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?",
"loading": "Chargement des listes de suivi...",
"no_compatible_watchlists": "Aucune liste de suivi compatible",
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
@@ -910,33 +908,33 @@
}
},
"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"
"title": "Associer à la TV",
"align_qr": "Alignez le code QR dans le cadre",
"enter_code_manually": "Saisir le code manuellement",
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
"pairing_code_label": "Code d'appairage",
"server": "Serveur",
"authorize_button": "Autoriser",
"authorizing": "Autorisation en cours...",
"scan_again": "Scanner à nouveau",
"done": "Terminé",
"success_title": "Autorisation envoyée",
"pairing_tv_connecting": "La TV se connecte à votre compte",
"error_title": "Échec de l'autorisation",
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
"login_as": "Se connecter en tant que {{username}} ?",
"on_server": "sur {{server}}",
"use_different_user": "Utiliser un autre utilisateur",
"open_settings": "Ouvrir les paramètres"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"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"
"pair_with_phone": "Associer au téléphone",
"pair_with_phone_title": "Se connecter sur la TV",
"waiting_for_phone": "En attente du téléphone...",
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
"logging_in": "Connexion en cours...",
"logging_in_description": "Connexion à votre serveur"
}
}

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין",
"video": "וידאו",
"audio": "שמע",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Videó",
"audio": "Hang",
@@ -621,7 +620,6 @@
"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",

View File

@@ -226,7 +226,7 @@
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
},
"audio": {
"audio_title": "Audio",
@@ -237,10 +237,10 @@
"language": "Lingua",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
"auto": "Automatico",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"5_1": "Consenti 5.1",
"passthrough": "Passthrough"
}
},
@@ -262,20 +262,20 @@
"OnlyForced": "Solo forzati"
},
"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_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
"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",
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su 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"
"left": "Sinistra",
"center": "Centro",
"right": "Destra",
"top": "Alto",
"bottom": "Basso"
}
},
"other": {
@@ -307,9 +307,9 @@
"disabled": "Disabilitato"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"title": "Musica",
"playback_title": "Riproduzione",
"playback_description": "Configura come viene riprodotta la musica.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
@@ -333,7 +333,7 @@
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato",
"plus_n_more": "+{{n}} more",
"plus_n_more": "+{{n}} altro",
"order_by": {
"DEFAULT": "Predefinito",
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
@@ -352,25 +352,25 @@
}
},
"streamystats": {
"disable_streamystats": "Disable Streamystats",
"disable_streamystats": "Disabilita Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save": "Save",
"features_title": "Features",
"save": "Salva",
"features_title": "Funzionalità",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"saved": "Salvato",
"refreshed": "Impostazioni aggiornate dal server",
"disabled": "Streamystats disabilitato"
},
"refresh_from_server": "Refresh Settings from Server"
},
@@ -385,17 +385,17 @@
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"music_cache_size": "{{size}} nella cache",
"music_cache_cleared": "Cache musicale cancellata",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"downloaded_songs_size": "{{size}} scaricato",
"downloaded_songs_deleted": "Brani scaricati eliminati",
"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_error_desc": "An error occurred while clearing the cache."
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
},
"intro": {
"title": "Intro",
@@ -404,8 +404,8 @@
},
"logs": {
"logs_title": "Log",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"export_logs": "Esporta i logs",
"click_for_more_info": "Clicca per maggiori informazioni",
"level": "Livello",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
@@ -419,17 +419,17 @@
"error_deleting_files": "Errore nella cancellazione dei file"
},
"security": {
"title": "Security",
"title": "Sicurezza",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"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"
"disabled": "Disabilitato",
"1_minute": "1 minuto",
"5_minutes": "5 minuti",
"15_minutes": "15 minuti",
"30_minutes": "30 minuti",
"1_hour": "1 ora",
"4_hours": "4 ore",
"24_hours": "24 ore"
}
}
},
@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile",
"video": "Video",
"audio": "Audio",
@@ -494,18 +493,18 @@
"mark_as_not_played": "Mark as not Played",
"none": "Nulla",
"track": "Traccia",
"cancel": "Cancel",
"delete": "Delete",
"cancel": "Annulla",
"delete": "Cancella",
"ok": "OK",
"remove": "Remove",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"remove": "Rimuovi",
"back": "Indietro",
"continue": "Continua",
"verifying": "Verifica in corso...",
"login": "Accedi",
"episodes": "Episodi",
"movies": "Film",
"loading": "Caricamento…",
"seeAll": "Visualizza tutti"
},
"search": {
"search": "Cerca...",
@@ -519,10 +518,10 @@
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"artists": "Artisti",
"albums": "Album",
"songs": "Tracce",
"playlists": "Playlist",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
@@ -554,7 +553,7 @@
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"playlists": "Playlists",
"playlists": "Playlist",
"items": "elementi"
},
"options": {
@@ -566,7 +565,7 @@
"cover": "Copertina",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche",
"options_title": "Options"
"options_title": "Impostazioni"
},
"filters": {
"genres": "Generi",
@@ -575,10 +574,10 @@
"filter_by": "Filter By",
"sort_order": "Criterio di ordinamento",
"tags": "Tag",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"all": "Tutto",
"reset": "Ripristina",
"asc": "Crescente",
"desc": "Decrescente"
}
},
"favorites": {
@@ -595,7 +594,7 @@
"no_links": "Nessun link"
},
"player": {
"live": "LIVE",
"live": "IN DIRETTA",
"mpv_player_title": "MPV Player",
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
@@ -606,40 +605,39 @@
"next_episode": "Prossimo Episodio",
"continue_watching": "Continua a guardare",
"go_back": "Indietro",
"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_title": "Questo file è stato scaricato",
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
"downloaded_file_yes": "Si",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"downloaded_file_cancel": "Annulla",
"swipe_down_settings": "Scorri in basso per le impostazioni",
"ends_at": "Termina alle {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_tracks": "Tracce",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"download": "Scarica",
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
"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",
"language": "Lingua",
"results": "Risultati",
"search_failed": "Ricerca fallita",
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
"no_subtitles_found": "Nessun sottotitolo trovato",
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
"settings": "Impostazioni",
"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",
"missing_parameters": "Missing playback parameters"
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
"downloaded": "Scaricato",
"missing_parameters": "Parametri di riproduzione mancanti"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"title": "Capitoli",
"chapter_number": "Capitolo {{number}}",
"open": "Apri capitoli",
"close": "Chiudi i capitoli"
},
"item_card": {
"next_up": "Il prossimo",
@@ -664,19 +662,19 @@
"quality": "Qualità",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
"label": "Sottotitoli",
"none": "Vuoto",
"tracks": "Tracce"
},
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"left": "left",
"director": "Director",
"left": "sinistra",
"director": "Regista",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in",
"movies": "Movies",
"shows": "Shows",
"movies": "Film",
"shows": "Serie",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
@@ -691,10 +689,10 @@
"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?",
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}",
"no_data_available": "No data available"
"continue_from": "Continua da {{time}}",
"no_data_available": "Nessun dato disponibile"
},
"live_tv": {
"next": "Prossimo",
@@ -706,16 +704,16 @@
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"page_of": "Pagina {{current}} di {{total}}",
"no_programs": "Nessun programma disponibile",
"no_channels": "Nessun canale disponibile",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
"programs": "Programmi",
"guide": "Guida",
"channels": "Canali",
"recordings": "Registrazioni",
"schedule": "Pianifica",
"series": "Serie Tv"
}
},
"jellyseerr": {
@@ -761,12 +759,12 @@
"decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto",
"select": "Select",
"select": "Seleziona",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"n_selected": "{{count}} selezionati",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -787,39 +785,39 @@
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti",
"settings": "Settings"
"settings": "Impostazioni"
},
"music": {
"title": "Music",
"title": "Musica",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"suggestions": "Suggerimenti",
"albums": "Album",
"artists": "Artisti",
"playlists": "Playlist",
"tracks": "tracks"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play": "Riproduci",
"shuffle": "Riproduzione casuale",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"no_suggestions": "Nessun suggerimento disponibile",
"no_albums": "Nessun album trovato",
"no_artists": "Artista non trovato",
"no_playlists": "Nessuna playlist trovata",
"album_not_found": "Album non trovato",
"artist_not_found": "Artista non trovato",
"playlist_not_found": "Playlist non trovata",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"download": "Scarica",
"downloaded": "Scaricato",
"downloading": "Scaricamento...",
"cached": "Memorizzato nella cache",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
@@ -831,112 +829,112 @@
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"enter_name": "Inserisci il nome della playlist",
"create": "Crea",
"search_playlists": "Cerca playlist...",
"added_to": "Aggiunto a {{name}}",
"added": "Aggiunto alla playlist",
"removed_from": "Rimosso da {{name}}",
"removed": "Rimosso dalla playlist",
"created": "Playlist creata",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"failed_to_add": "Impossibile aggiungere alla playlist",
"failed_to_remove": "Impossibile rimuovere dalla playlist",
"failed_to_create": "Impossibile creare la playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
"deleted": "Playlist eliminata",
"failed_to_delete": "Impossibile eliminare la playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"alphabetical": "Alfabetico",
"date_created": "Date Created"
}
},
"watchlists": {
"title": "Watchlists",
"title": "Da vedere",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"delete_button": "Cancella",
"remove_button": "Rimuovi",
"cancel_button": "Annulla",
"name_label": "Nome",
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
"description_label": "Descrizione",
"description_placeholder": "Inserisci descrizione (opzionale)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"is_public_description": "Permetti ad altri di vedere questa lista",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
"empty_watchlist": "Questa lista è vuota",
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
"go_to_settings": "Vai alle impostazioni",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"item": "elemento",
"items": "elementi",
"public": "Pubblico",
"private": "Privato",
"you": "Tu",
"by_owner": "Da un altro utente",
"not_found": "\"Da vedere\" non trovata",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
"loading": "Caricamento liste...",
"no_compatible_watchlists": "Nessuna lista compatibile",
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"speed": "Velocità",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "Solo questo media",
"show": "Questo show",
"all": "Tutti i media (predefinito)"
}
},
"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",
"title": "Associa con la TV",
"align_qr": "Allinea il QR code all'interno del riquadro",
"enter_code_manually": "Inserisci il codice manualmente",
"pairing_enter_credentials": "Inserire le credenziali per la TV",
"pairing_code_label": "Codice di associazione",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"authorize_button": "Autorizza",
"authorizing": "Autorizzando...",
"scan_again": "Scan Again",
"done": "Done",
"done": "Fatto",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"pairing_tv_connecting": "La TV si sta collegando al tuo 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"
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
"error_generic": "Si è verificato un errore. Riprova.",
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
"login_as": "Accedi come {{username}}?",
"on_server": "su {{server}}",
"use_different_user": "Usa un altro utente",
"open_settings": "Apri le impostazioni"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"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"
"waiting_for_phone": "In attesa del telefono...",
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
"logging_in": "Accesso in corso...",
"logging_in_description": "Sto connettendo al server"
}
}

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません",
"video": "映像",
"audio": "音声",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video",
"audio": "Lyd",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Wybierz",
"no_trailer_available": "Brak dostępnego zwiastunu",
"video": "Wideo",
"audio": "Dźwięk",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Selecionar",
"no_trailer_available": "Nenhum trailer disponível",
"video": "Vídeo",
"audio": "Áudio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Selectare",
"no_trailer_available": "Nicio remorcă disponibilă",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Выбрать",
"no_trailer_available": "Трейлер недоступен",
"video": "Видео",
"audio": "Звук",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "Inga resultat",
"select": "Välj",
"no_trailer_available": "Ingen trailer tillgänglig",
"video": "Video",
"audio": "Ljud",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Använder Jellyfin-server",
"language": "Språk",
"results": "Resultat",
"searching": "Söker...",
"search_failed": "Sökningen misslyckades",
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
"no_subtitles_found": "Inga undertexter hittades",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "mu'tlhegh",
"audio": "QoQ",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Seç",
"no_trailer_available": "Fragman mevcut değil",
"video": "Video",
"audio": "Ses",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Âm thanh",
@@ -621,7 +620,6 @@
"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",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"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",

View File

@@ -13,6 +13,15 @@ export type TVOptionModalState = {
onSelect: (value: any) => void;
cardWidth?: number;
cardHeight?: number;
/**
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
* navigates (the in-player audio switch replacing the player while
* transcoding), which the still-active modal route would otherwise swallow.
* Default (false) runs onSelect before closing, so state-only callers (detail
* page, library filters, settings) don't re-render after focus returns and
* lose TV focus.
*/
deferApplyUntilDismissed?: boolean;
} | null;
export const tvOptionModalAtom = atom<TVOptionModalState>(null);

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { generateDeviceProfile } from "./native";
/**
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*/
import type {
DeviceProfile,
SubtitleProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
/**
* Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles.
*/
const downloadSubtitleProfiles = [
const downloadSubtitleProfiles: SubtitleProfile[] = [
// Official formats
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
/**
* Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/
export const generateDownloadProfile = (audioMode = "auto") => {
export const generateDownloadProfile = (
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
// Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
},
],
SubtitleProfiles: getSubtitleProfiles(),
};
} satisfies DeviceProfile;
return profile;
};

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub",
"teletext",
"vobsub",
];
] as const;
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
"text",
"vplayer",
"xsub",
];
] as const;
export const getSubtitleProfiles = () => {
const profiles = [];
export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles: SubtitleProfile[] = [];
// Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) {
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;

View File

@@ -1,19 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,23 +3,25 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import type { PlatformType } from "./native";
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
/**
* Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/
const getDirectPlayProfile = (platform) => {
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform) => {
const getCodecProfile = (platform: PlatformType): CodecProfile => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
* This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS
* - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
export const generateTrackPlayerProfile = (
options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return {
Name: "Track Player",

View File

@@ -5,7 +5,7 @@ import Constants from "expo-constants";
* clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
export interface BuildMeta {
commit?: string | null;
branch?: string | null;