Compare commits

..

16 Commits

Author SHA1 Message Date
Gauvain
039d7cfaec chore(i18n): localize hardcoded UI strings 2026-07-15 19:05:09 +02:00
Gauvain
2e79815d78 fix(downloads): delete-all confirmation, live storage usage, series poster cache (#1808)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 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-15 11:19:51 +02:00
Gauvain
43ce50fe70 fix(notifications): drop deprecated handler flags and payload logging (#1805)
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 10:50:47 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks failed
🏗️ 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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (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
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 08:36:11 +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
129 changed files with 1977 additions and 2375 deletions

View File

@@ -6,6 +6,4 @@
## Detail ## 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`.) 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`.
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards ## 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 - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push: push:
branches: [develop, master] 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 # 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 # EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access. # run (artifacts + logs) without needing Expo access.
@@ -30,64 +30,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 🗑️ Free Disk Space - parallel:
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - name: 🗑️ Free Disk Space
with: uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
tool-cache: false with:
mandb: true tool-cache: false
android: false mandb: true
dotnet: true android: false
haskell: true dotnet: true
large-packages: false haskell: true
docker-images: true large-packages: false
swap-storage: false docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: ☕ Set up JDK 17
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
with: # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
path: ~/.bun/install/cache # fail). Pin Temurin 17 for a deterministic Android build.
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
restore-keys: | with:
${{ runner.os }}-${{ runner.arch }}-bun- 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 - name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload 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 - name: 🛠️ Generate project files
run: bun run prebuild run: bun run prebuild
@@ -130,64 +132,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 🗑️ Free Disk Space - parallel:
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - name: 🗑️ Free Disk Space
with: uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
tool-cache: false with:
mandb: true tool-cache: false
android: false mandb: true
dotnet: true android: false
haskell: true dotnet: true
large-packages: false haskell: true
docker-images: true large-packages: false
swap-storage: false docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: ☕ Set up JDK 17
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
with: # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
path: ~/.bun/install/cache # fail). Pin Temurin 17 for a deterministic Android build.
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
restore-keys: | with:
${{ runner.os }}-${{ runner.arch }}-bun- 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 - name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload 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 - name: 🛠️ Generate project files
run: bun run prebuild:tv run: bun run prebuild:tv
@@ -229,19 +233,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" 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 - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -259,19 +277,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild 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 - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
@@ -301,19 +306,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" 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 - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -331,12 +343,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild 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 - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
@@ -368,19 +374,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" 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 - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -398,19 +418,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild:tv 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 - name: 🚀 Build iOS app
env: env:
EXPO_TV: 1 EXPO_TV: 1
@@ -438,19 +445,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" 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 - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -468,12 +482,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild:tv 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 - name: 🚀 Build iOS app
env: env:
EXPO_TV: 1 EXPO_TV: 1

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
on: on:
pull_request: pull_request:
types: [opened, edited, synchronize, reopened] types: [opened, synchronize, reopened]
branches: [develop, master] branches: [develop, master]
workflow_dispatch: workflow_dispatch:
push: push:
@@ -11,39 +11,11 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: 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: dependency-review:
name: 🔍 Vulnerable Dependencies name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
@@ -67,18 +39,26 @@ jobs:
name: 🚑 Expo Doctor Check name: 🚑 Expo Doctor Check
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
steps: steps:
- name: 🛒 Checkout repository - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 🛒 Checkout repository
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
submodules: recursive ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 submodules: recursive
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 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: with:
# renovate: datasource=npm depName=bun path: ~/.bun/install/cache
bun-version: "1.3.14" key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies (bun) - name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -103,24 +83,26 @@ jobs:
- "i18n:check" - "i18n:check"
steps: steps:
- name: "📥 Checkout PR code" - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: "📥 Checkout PR code"
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
submodules: recursive ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 submodules: recursive
- name: "🟢 Setup Node.js" - name: "🍞 Setup Bun"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=node-version depName=node versioning=node # renovate: datasource=npm depName=bun
node-version: "24.18.0" bun-version: "1.3.14"
- name: "🍞 Setup Bun" - name: 💾 Cache Bun dependencies
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
# renovate: datasource=npm depName=bun path: ~/.bun/install/cache
bun-version: "1.3.14" key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: "📦 Install dependencies" - name: "📦 Install dependencies"
run: bun install --frozen-lockfile 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: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
# Queue successive releases in order instead of dropping the extra pending run.
queue: max
on: on:
push: push:
@@ -63,18 +65,26 @@ jobs:
artifact_name: streamyfin-android-tv-apk artifact_name: streamyfin-android-tv-apk
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
fetch-depth: 0 with:
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" 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 - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -89,13 +99,6 @@ jobs:
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload 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 # tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets. # credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials - name: 🔐 Restore tvOS signing credentials

View File

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

4
.gitignore vendored
View File

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

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards ## 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 functional React components with hooks
- Use Jotai atoms for global state, React Query for server state - Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) - 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 // Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order: // expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. // EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args) => { const git = (args: string[]): string | null => {
try { try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString() .toString()
@@ -42,16 +46,16 @@ const buildMeta = {
builtAt: new Date().toISOString(), builtAt: new Date().toISOString(),
}; };
module.exports = ({ config }) => { export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") { 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", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
config.plugins.push([ config.plugins?.push([
"expo-camera", "expo-camera",
{ {
cameraPermission: cameraPermission:
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set
const androidConfig = {}; const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) { if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
} }
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
return { return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config, ...config,
}; } as ExpoConfig;
}; };

View File

@@ -71,8 +71,8 @@
], ],
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.js", "./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.js", "./plugins/withTVUserManagement.ts",
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -134,17 +134,17 @@
} }
], ],
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.js"], ["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.js"], ["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.js"], ["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.js"], ["./plugins/withTVXcodeEnv.ts"],
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.ts",
{ {
"podName": "MPVKit", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" "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 { 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 { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -22,7 +21,6 @@ import {
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
@@ -52,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -61,58 +59,6 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal(); const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const queryClient = useQueryClient(); 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) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -699,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label='Vertical Margin' label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -717,11 +663,11 @@ export default function SettingsTV() {
}} }}
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Horizontal Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel} value={alignXLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Horizontal Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions, options: alignXOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -731,11 +677,11 @@ export default function SettingsTV() {
} }
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Vertical Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_y"),
options: alignYOptions, options: alignYOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -931,72 +877,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} 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 */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -12,11 +12,16 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useAtom } from "jotai"; 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 { useTranslation } from "react-i18next";
import { import {
BackHandler,
FlatList, FlatList,
Platform, Platform,
ScrollView, ScrollView,
@@ -80,8 +85,9 @@ const Page = () => {
sortBy?: string; sortBy?: string;
sortOrder?: string; sortOrder?: string;
filterBy?: string; filterBy?: string;
fromSeeAll?: string;
}; };
const { libraryId } = searchParams; const { libraryId, fromSeeAll } = searchParams;
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
@@ -112,6 +118,22 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); 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(); const { showItemActions } = useTVItemActionModal();
// TV Filter queries // TV Filter queries
@@ -269,6 +291,23 @@ const Page = () => {
}); });
}, [library]); }, [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( const fetchItems = useCallback(
async ({ async ({
pageParam, pageParam,

View File

@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -7,12 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash"; import { orderBy, uniqBy } from "lodash";
import { import {
@@ -25,13 +20,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -52,10 +41,7 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage"; import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import { useJellyseerr } from "@/hooks/useJellyseerr";
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -120,60 +106,8 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings(); const { settings } = useSettings();
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr(); const { jellyseerrApi } = 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 [jellyseerrOrderBy, setJellyseerrOrderBy] = const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>( useState<JellyseerrSearchSort>(
JellyseerrSearchSort[ JellyseerrSearchSort[

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue"; type ViewMode = "player" | "queue";
export default function NowPlayingScreen() { export default function NowPlayingScreen() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
onRemoveFromQueue, onRemoveFromQueue,
onReorderQueue, onReorderQueue,
}) => { }) => {
const { t } = useTranslation();
const renderQueueItem = useCallback( const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => { ({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0; const index = getIndex() ?? 0;
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
ListHeaderComponent={ ListHeaderComponent={
<View className='px-4 py-2'> <View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'> <Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"} {history.length > 0
? t("music.playing_from_queue")
: t("music.up_next")}
</Text> </Text>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'> <View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text> <Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
</View> </View>
} }
/> />

View File

@@ -893,6 +893,27 @@ export default function DirectPlayerPage() {
// Check if we're transcoding // Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl); 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 // Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId( const mpvTrackId = getMpvAudioId(
stream?.mediaSource, stream?.mediaSource,
@@ -904,7 +925,14 @@ export default function DirectPlayerPage() {
await videoRef.current?.setAudioTrack?.(mpvTrackId); await videoRef.current?.setAudioTrack?.(mpvTrackId);
} }
}, },
[stream?.mediaSource], [
stream?.mediaSource,
item?.Id,
currentSubtitleIndex,
bitrateValue,
router,
progress,
],
); );
// TV subtitle track change handler // TV subtitle track change handler
@@ -1290,7 +1318,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occured_while_playing_the_video"), t("player.an_error_occurred_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Animated, Animated,
Easing, Easing,
InteractionManager,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TVFocusGuideView, TVFocusGuideView,
@@ -11,10 +12,7 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv"; import { TVOptionCard } from "@/components/tv";
import { import { useScaledTVTypography } from "@/constants/TVTypography";
useScaledTVTypography,
useTVRelativeScale,
} from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress"; import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal"; import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
@@ -25,7 +23,6 @@ export default function TVOptionModal() {
const router = useRouter(); const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom); const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const relativeScale = useTVRelativeScale();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null); const firstCardRef = useRef<View>(null);
@@ -79,6 +76,20 @@ export default function TVOptionModal() {
}, [isReady]); }, [isReady]);
const handleSelect = (value: any) => { 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); modalState?.onSelect(value);
store.set(tvOptionModalAtom, null); store.set(tvOptionModalAtom, null);
router.back(); router.back();
@@ -101,15 +112,8 @@ export default function TVOptionModal() {
} }
const { title, options } = modalState; const { title, options } = modalState;
// Honor the caller-provided card size (e.g. wider cards for long root-folder const scaledCardWidth = scaleSize(160);
// paths) and grow it in step with the user's text-scale setting so larger const scaledCardHeight = scaleSize(75);
// fonts don't get clipped.
const scaledCardWidth = scaleSize(
(modalState.cardWidth ?? 160) * relativeScale,
);
const scaledCardHeight = scaleSize(
(modalState.cardHeight ?? 75) * relativeScale,
);
return ( return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>

View File

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

View File

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

View File

@@ -192,6 +192,7 @@ const SubtitleResultCard = React.forwardRef<
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { >(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match {t("player.hash_match")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,17 +1,20 @@
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const { t } = useTranslation();
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: t("home.oops") }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText> <ThemedText type='title'>{t("not_found.title")}</ThemedText>
<Link href={"/home"} style={styles.link}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText> <ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -85,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -350,9 +351,12 @@ function Layout() {
notificationListener.current = notificationListener.current =
Notifications?.addNotificationReceivedListener( Notifications?.addNotificationReceivedListener(
(notification: Notification) => { (notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log( console.log(
"Notification received while app running", "Notification received while app running:",
notification, notification.request.content.title,
); );
}, },
); );

View File

@@ -109,10 +109,11 @@
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.20.0",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
}, },
@@ -294,6 +295,58 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@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-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=="], "@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=="], "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=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "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-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=="], "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=="], "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-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=="], "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,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
expoUIConfig, expoUIConfig,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// Handle controlled open state for Android // Handle controlled open state for Android
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
colors, colors,
...props ...props
}: Props) => { }: Props) => {
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,6 +6,7 @@ import {
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
Modal, Modal,
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps); const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const { t } = useTranslation();
const handlePlayInSession = async (sessionId: string) => { const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return; if (!api || !item.Id) return;
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
<View style={styles.centeredView}> <View style={styles.centeredView}>
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Session</Text> <Text style={styles.modalTitle}>
{t("home.sessions.select_session")}
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</View> </View>
) : !sessions || sessions.length === 0 ? ( ) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}> <Text style={styles.noSessionsText}>
No active sessions found {t("home.sessions.no_active_sessions")}
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</Text> </Text>
{session.NowPlayingItem && ( {session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}> <Text style={styles.nowPlaying} numberOfLines={1}>
Now playing:{" "} {t("home.sessions.now_playing")}{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

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 { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import React from "react";
import { Platform, View } from "react-native"; 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 }) => { const isAggregateType = (item: BaseItemDto) =>
if (Platform.isTV) { item.Type === "Series" || item.Type === "BoxSet";
// TV: Show white checkmark when watched
if ( // TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
item.UserData?.Played && const tvBadgeBase: ViewStyle = {
(item.Type === "Movie" || item.Type === "Episode") 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 ( return (
<View <View
style={{ style={[
position: "absolute", tvBadgeBase,
top: 8, { minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
right: 8, ]}
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
> >
<Ionicons name='checkmark' size={18} color='black' /> <Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{label}
</Text>
</View> </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 ( return (
<> <>
{item.UserData?.Played === false && {/* Strict === false: items without UserData (unknown state) get no ribbon */}
(item.Type === "Movie" || item.Type === "Episode") && ( {isMovieOrEpisode && item.UserData?.Played === false && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' /> <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,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && ( {isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'> <View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text> <Text className='text-xs text-purple-400'>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter(); const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!); const seriesId = items[0]?.SeriesId;
}, []); return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback( const deleteSeries = useCallback(
async () => async () =>

View File

@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const handleSeeAllPress = useCallback(() => { const handleSeeAllPress = useCallback(() => {
if (!parentId) return; 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({ router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", pathname: "/[libraryId]",
params: { params: {
libraryId: parentId, libraryId: parentId,
sortBy: SortByOption.DateCreated, sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending, sortOrder: SortOrderOption.Descending,
fromSeeAll: "true",
}, },
} as any); } as any);
}, [router, parentId]); }, [router, parentId]);
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }} // contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={ 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 <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: sizes.padding.horizontal,
}} }}
> >
{isFetchingNextPage && ( {isFetchingNextPage && (

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
disabled = false, disabled = false,
refSetter, refSetter,
}) => { }) => {
const { t } = useTranslation();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1, scaleAmount: 1,
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
LIVE {t("player.live")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
marginBottom: 24, marginBottom: 24,
}} }}
> >
Live TV {t("live_tv.title")}
</Text> </Text>
{/* Tab Bar */} {/* Tab Bar */}

View File

@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} finally { } finally {

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} finally { } finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
: t("login.an_unexpected_error_occured"); : t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
} finally { } finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} finally { } finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
: t("login.an_unexpected_error_occured"); : t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
}); });

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection"; import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges"; import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100; const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as // 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 // 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, discoverSliders,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
) : ( ) : (
<View <View
style={{ style={{
marginHorizontal: sizes.padding.horizontal, marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24, marginBottom: 24,
}} }}
> >
@@ -280,15 +280,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag' keyboardDismissMode='on-drag'
contentContainerStyle={{ 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, paddingBottom: insets.bottom + 60,
}} }}
> >
{/* Search Type Tab Badges */} {/* Search Type Tab Badges */}
{showDiscover && ( {showDiscover && (
<View style={{ marginHorizontal: sizes.padding.horizontal }}> <View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges <TVSearchTabBadges
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native"; import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label='Library' label={t("search.library")}
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label='Discover' label={t("search.discover")}
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Switch, View, type ViewProps } from "react-native"; import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -17,20 +18,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const media = useMedia(); const media = useMedia();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"]; const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"]; const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = { const alignXLabels: Record<AlignX, string> = {
left: "Left", left: t("home.settings.subtitles.align.left"),
center: "Center", center: t("home.settings.subtitles.align.center"),
right: "Right", right: t("home.settings.subtitles.align.right"),
}; };
const alignYLabels: Record<AlignY, string> = { const alignYLabels: Record<AlignY, string> = {
top: "Top", top: t("home.settings.subtitles.align.top"),
center: "Center", center: t("home.settings.subtitles.align.center"),
bottom: "Bottom", bottom: t("home.settings.subtitles.align.bottom"),
}; };
const alignXOptionGroups = useMemo(() => { const alignXOptionGroups = useMemo(() => {
@@ -60,16 +62,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title='MPV Subtitle Settings' title={t("home.settings.subtitles.mpv_settings_title")}
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player {t("home.settings.subtitles.mpv_settings_description")}
</Text> </Text>
} }
> >
{!isTv && ( {!isTv && (
<> <>
<ListItem title='Vertical Margin'> <ListItem
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper <Stepper
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
step={5} step={5}
@@ -81,7 +85,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title='Horizontal Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
<PlatformDropdown <PlatformDropdown
groups={alignXOptionGroups} groups={alignXOptionGroups}
trigger={ trigger={
@@ -96,11 +100,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Horizontal Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_x")}
/> />
</ListItem> </ListItem>
<ListItem title='Vertical Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
<PlatformDropdown <PlatformDropdown
groups={alignYOptionGroups} groups={alignYOptionGroups}
trigger={ trigger={
@@ -115,13 +119,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Vertical Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_y")}
/> />
</ListItem> </ListItem>
</> </>
)} )}
<ListItem title='Opaque Background'> <ListItem title={t("home.settings.subtitles.opaque_background")}>
<Switch <Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false} value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) => onValueChange={(value) =>
@@ -131,7 +135,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
{settings.mpvSubtitleBackgroundEnabled && ( {settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'> <ListItem title={t("home.settings.subtitles.background_opacity")}>
<Stepper <Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75} value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5} step={5}

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback(); successHapticFeedback();
Alert.alert( Alert.alert(
t("home.settings.quick_connect.success"), t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized"), t("home.settings.quick_connect.quick_connect_authorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Alert, Platform, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => { export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total, used: (app.total - app.remaining) / app.total,
}; };
}, },
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
}); });
const onDeleteClicked = async () => { const onDeleteClicked = () => {
try { Alert.alert(
await deleteAllFiles(); t("home.settings.storage.delete_all_downloaded_files_confirm"),
successHapticFeedback(); t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
} catch (_e) { [
errorHapticFeedback(); {
toast.error(t("home.settings.toasts.error_deleting_files")); text: t("common.cancel"),
} style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
}; };
const calculatePercentage = (value: number, total: number) => { const calculatePercentage = (value: number, total: number) => {

View File

@@ -12,7 +12,10 @@ import {
} from "react-native"; } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar"; import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { WatchedIndicator } from "@/components/WatchedIndicator"; import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { import {
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/> />
{PlayButtonOverlay} {PlayButtonOverlay}
{NowPlayingBadge} {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> </View>
); );
} }
@@ -459,7 +468,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/> />
{PlayButtonOverlay} {PlayButtonOverlay}
{NowPlayingBadge} {NowPlayingBadge}
<WatchedIndicator item={item} /> {showWatchedIndicator && <WatchedIndicator item={item} />}
<ProgressBar item={item} /> <ProgressBar item={item} />
</View> </View>
); );

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
const styles = createStyles(typography); const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={styles.flagText}>Hash Match</Text> <Text style={styles.flagText}>{t("player.hash_match")}</Text>
</View> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
buttonText='Skip Intro' buttonText={t("player.skip_intro")}
/> />
{/* Smart Skip Credits behavior: {/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode - Show "Skip Credits" if there's content after credits OR no next episode
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem) showSkipCreditButton && (hasContentAfterCredits || !nextItem)
} }
onPress={skipCredit} onPress={skipCredit}
buttonText='Skip Credits' buttonText={t("player.skip_credits")}
/> />
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||

View File

@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
} }
> >
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ? {t("player.still_watching")}
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

@@ -564,6 +564,9 @@ export const Controls: FC<Props> = ({
title: t("item_card.audio"), title: t("item_card.audio"),
options: audioOptions, options: audioOptions,
onSelect: handleAudioChange, 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(); controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]); }, [showOptions, t, audioOptions, handleAudioChange]);

View File

@@ -4,6 +4,7 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const insets = useControlsSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onPress={toggleOrientation} onPress={toggleOrientation}
disabled={isTogglingOrientation} disabled={isTogglingOrientation}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
accessibilityLabel='Toggle screen orientation' accessibilityLabel={t("accessibility.toggle_orientation")}
accessibilityHint='Toggles the screen orientation between portrait and landscape' accessibilityHint={t("accessibility.toggle_orientation_hint")}
> >
<MaterialIcons <MaterialIcons
name='screen-rotation' name='screen-rotation'

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

@@ -7,6 +7,7 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, Text, View } from "react-native"; import { Platform, StyleSheet, Text, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets(); const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Video: {formatCodec(info.videoCodec)} {t("player.technical_info.video")} {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text> </Text>
)} )}
{info?.audioCodec && ( {info?.audioCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Audio: {formatCodec(info.audioCodec)} {t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels {streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}` ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
: ""} : ""}
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{streamInfo?.subtitleCodec && ( {streamInfo?.subtitleCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Subtitle: {formatCodec(streamInfo.subtitleCodec)} {t("player.technical_info.subtitle")}{" "}
{formatCodec(streamInfo.subtitleCodec)}
</Text> </Text>
)} )}
{(info?.videoBitrate || info?.audioBitrate) && ( {(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}> <Text style={textStyle}>
Bitrate:{" "} {t("player.technical_info.bitrate")}{" "}
{info.videoBitrate {info.videoBitrate
? formatBitrate(info.videoBitrate) ? formatBitrate(info.videoBitrate)
: info.audioBitrate : info.audioBitrate
@@ -341,7 +344,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s {t("player.technical_info.buffer_seconds", {
seconds: info.cacheSeconds.toFixed(1),
})}
{info?.demuxerMaxBytes !== undefined {info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` + ? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
@@ -352,7 +357,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.voDriver && ( {info?.voDriver && (
<Text style={textStyle}> <Text style={textStyle}>
VO: {info.voDriver} {t("player.technical_info.vo")} {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
@@ -364,10 +369,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames {t("player.technical_info.dropped_frames", {
count: info.droppedFrames,
})}
</Text> </Text>
)} )}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>} {!info && !playMethod && (
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
); );

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { import {
type OptionGroup, type OptionGroup,
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
onRatioChange, onRatioChange,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => { const handleRatioSelect = (ratio: AspectRatio) => {
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
{ {
options: ASPECT_RATIO_OPTIONS.map((option) => ({ options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const, type: "radio" as const,
label: option.label, label:
option.id === "default"
? t("player.aspect_ratio_original")
: option.label,
value: option.id, value: option.id,
selected: option.id === currentRatio, selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id), onPress: () => handleRatioSelect(option.id),
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Aspect Ratio' title={t("player.aspect_ratio")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
bottomSheetConfig={{ bottomSheetConfig={{

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { import {
@@ -47,6 +48,7 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -101,7 +103,7 @@ const DropdownView = ({
// Quality Section // Quality Section
if (!isOffline) { if (!isOffline) {
groups.push({ groups.push({
title: "Quality", title: t("player.menu.quality"),
options: options:
BITRATES?.map((bitrate) => ({ BITRATES?.map((bitrate) => ({
type: "radio" as const, type: "radio" as const,
@@ -116,7 +118,7 @@ const DropdownView = ({
// Subtitle Section // Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) { if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({ groups.push({
title: "Subtitles", title: t("player.menu.subtitles"),
options: subtitleTracks.map((sub) => ({ options: subtitleTracks.map((sub) => ({
type: "radio" as const, type: "radio" as const,
label: sub.name, label: sub.name,
@@ -128,7 +130,7 @@ const DropdownView = ({
// Subtitle Scale Section // Subtitle Scale Section
groups.push({ groups.push({
title: "Subtitle Scale", title: t("player.menu.subtitle_scale"),
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const, type: "radio" as const,
label: preset.label, label: preset.label,
@@ -142,7 +144,7 @@ const DropdownView = ({
// Audio Section // Audio Section
if (audioTracks && audioTracks.length > 0) { if (audioTracks && audioTracks.length > 0) {
groups.push({ groups.push({
title: "Audio", title: t("player.menu.audio"),
options: audioTracks.map((track) => ({ options: audioTracks.map((track) => ({
type: "radio" as const, type: "radio" as const,
label: track.name, label: track.name,
@@ -156,7 +158,7 @@ const DropdownView = ({
// Speed Section // Speed Section
if (setPlaybackSpeed) { if (setPlaybackSpeed) {
groups.push({ groups.push({
title: "Speed", title: t("player.menu.speed"),
options: PLAYBACK_SPEEDS.map((speed) => ({ options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const, type: "radio" as const,
label: speed.label, label: speed.label,
@@ -174,8 +176,8 @@ const DropdownView = ({
{ {
type: "action" as const, type: "action" as const,
label: showTechnicalInfo label: showTechnicalInfo
? "Hide Technical Info" ? t("player.menu.hide_technical_info")
: "Show Technical Info", : t("player.menu.show_technical_info"),
onPress: onToggleTechnicalInfo, onPress: onToggleTechnicalInfo,
}, },
], ],
@@ -185,6 +187,7 @@ const DropdownView = ({
return groups; return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
t,
isOffline, isOffline,
bitrateValue, bitrateValue,
changeBitrate, changeBitrate,
@@ -217,7 +220,7 @@ const DropdownView = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Playback Options' title={t("player.menu.playback_options")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
expoUIConfig={{}} expoUIConfig={{}}

View File

@@ -3,6 +3,7 @@ import { Alert } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { useTVBackPress } from "@/hooks/useTVBackPress"; import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useTVEventHandler } from "@/hooks/useTVEventHandler"; import { useTVEventHandler } from "@/hooks/useTVEventHandler";
import i18n from "@/i18n";
interface UseRemoteControlProps { interface UseRemoteControlProps {
showControls: boolean; showControls: boolean;
@@ -124,17 +125,23 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback. // Controls are hidden, so confirm before leaving playback.
Alert.alert( Alert.alert(
"Stop Playback", i18n.t("player.stopPlayback"),
videoTitleRef.current videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?` ? i18n.t("player.stopPlayingTitle", {
: "Are you sure you want to stop playback?", title: videoTitleRef.current,
})
: i18n.t("player.stopPlayingConfirm"),
[ [
{ {
text: "Cancel", text: i18n.t("common.cancel"),
style: "cancel", style: "cancel",
onPress: () => onCancelExitRef.current?.(), onPress: () => onCancelExitRef.current?.(),
}, },
{ text: "Stop", style: "destructive", onPress: onBackRef.current }, {
text: i18n.t("common.stop"),
style: "destructive",
onPress: onBackRef.current,
},
], ],
); );
return true; return true;

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
export default { const MediaTypes = {
Audio: "Audio", Audio: "Audio",
Video: "Video", Video: "Video",
Photo: "Photo", Photo: "Photo",
Book: "Book", 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, heading: 32,
/** Overview, actor names, card titles, metadata */ /** Overview, actor names, card titles, metadata */
body: 32, body: 40,
/** Secondary text, labels, subtitles */ /** Secondary text, labels, subtitles */
callout: 26, callout: 26,
@@ -55,20 +55,6 @@ export type ScaledTVTypography = {
callout: number; 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. * Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling. * 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: Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) - [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) - [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) - [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) - [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -70,45 +70,6 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES); 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 { export enum Endpoints {
STATUS = "/status", STATUS = "/status",
API_V1 = "/api/v1", API_V1 = "/api/v1",
@@ -133,7 +94,6 @@ export enum Endpoints {
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK, DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`, DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`,
AUTH_JELLYFIN = "/auth/jellyfin", AUTH_JELLYFIN = "/auth/jellyfin",
AUTH_ME = "/auth/me",
} }
export type DiscoverEndpoint = export type DiscoverEndpoint =
@@ -183,7 +143,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = t( const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
); );
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);
@@ -490,8 +450,7 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData(); clearJellyseerrStorageData();
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined }); updateSettings({ jellyseerrServerUrl: undefined });
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] }); }, []);
}, [queryClient, setJellyseerrUser, updateSettings]);
const requestMedia = useCallback( const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => { (title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

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

View File

@@ -11,12 +11,6 @@ interface ShowRequestModalParams {
id: number; id: number;
mediaType: MediaType; mediaType: MediaType;
onRequested: () => void; 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 = () => { export const useTVRequestModal = () => {
@@ -31,11 +25,7 @@ export const useTVRequestModal = () => {
mediaType: params.mediaType, mediaType: params.mediaType,
onRequested: params.onRequested, onRequested: params.onRequested,
}); });
if (params.replace) { router.push("/(auth)/tv-request-modal");
router.replace("/(auth)/tv-request-modal");
} else {
router.push("/(auth)/tv-request-modal");
}
}, },
[router], [router],
); );

View File

View File

@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.SurfaceHolder
import android.view.View import android.view.SurfaceView
import android.view.ViewGroup import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
@@ -30,15 +28,26 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null, val cacheEnabled: String? = null,
val cacheSeconds: Int? = null, val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null, val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null val demuxerMaxBackBytes: Int? = null,
) )
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * 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), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
@@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = 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 surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking // PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper()) private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support) // SurfaceView for video rendering. Routes the surface directly to
textureView = TextureView(context).apply { // 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( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
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 // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView) pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) { override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) { if (isInPiP) {
if (!isWaitingForPiPTransition) { // Post size syncs after the PiP layout settles. Two passes
isWaitingForPiPTransition = true // catch both the immediate surface re-attach and the
pipHandler.removeCallbacksAndMessages(null) // post-animation layout pass. Replaces the old TextureView
for (delay in longArrayOf(500, 1000, 1500, 2000)) { // measure/layout polling hack (forcePiPBufferSize).
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) 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)) 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. * 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?) { private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return if (rendererStarted) return
@@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true 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 activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null syncSurfaceSizeToView()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") 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) { override fun surfaceCreated(holder: SurfaceHolder) {
this.surfaceTexture = surfaceTexture val surface = holder.surface
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
// Release the previous wrapper Surface before losing the only // The previous Surface reference is holder-owned; do NOT release
// reference to it. cleanup() only runs on detach, so without this // it (SurfaceView manages its lifecycle). Just track the new one.
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
} else { // Push the actual view dimensions immediately so mpv doesn't
pendingSurface = surface // render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
} }
// If we have a pending load, execute it now // 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) { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height) if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
}
} }
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { override fun surfaceDestroyed(holder: SurfaceHolder) {
this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() 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 // 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 // Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live // 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 // ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle. // called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load() // 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 // which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance, // same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount. // 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 rendererStarted = false
currentUrl = null 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 activeSurface = null
} }
@@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture() pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
} }
fun stopPictureInPicture() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path. * off the JS path.
*/ */
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
renderer?.delegate = null renderer?.delegate = null
// Release the Surface that wraps the SurfaceTexture. These Surface // SurfaceView owns the Surface via its holder — do NOT release it.
// 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()
activeSurface = null activeSurface = null
surfaceReady = false surfaceReady = false
currentUrl = null 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 currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// 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 videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
fun stopPictureInPicture() { 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 isInPiPMode = false
pipEntryNotified = false pipEntryNotified = false
unregisterLifecycleCallbacks() unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity() ?: return
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false) // 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 fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) { if (rate > 0) {
registerLifecycleCallbacks() registerLifecycleCallbacks()
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions()) builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0) builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
} }
return builder.build() 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": "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", "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
"doctor": "expo-doctor", "doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs", "i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", "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", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
@@ -132,10 +132,11 @@
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.20.0",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },
"expo": { "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_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< 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`, `\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
]; ];
function buildPatch() { function buildPatch(): string {
return [ return [
PATCH_START, PATCH_START,
" extra_hdrs = [", " extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch() {
].join("\n"); ].join("\n");
} }
module.exports = function withRuntimeFrameworkHeaders(config) { const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
return withPodfile(config, (config) => { return withPodfile(config, (config) => {
let podfile = config.modResults.contents; let podfile = config.modResults.contents;
@@ -125,3 +125,5 @@ end
return config; return config;
}); });
}; };
export default withRuntimeFrameworkHeaders;

View File

@@ -1,10 +1,20 @@
const { import {
type ConfigPlugin,
withAndroidColors, withAndroidColors,
withAndroidColorsNight, withAndroidColorsNight,
} = require("expo/config-plugins"); } from "expo/config-plugins";
const withAndroidAlertColors = (config) => { interface ColorResourceItem {
const setColor = (colorsList, name, value) => { $: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const existingColor = colorsList.find( const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name, (item) => item.$ && item.$.name === name,
); );
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColors(config, (config) => { config = withAndroidColors(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = colors.resources.color || []; const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#000000"); setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColorsNight(config, (config) => { config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = colors.resources.color || []; const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#FFFFFF"); setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
return 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) => { 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 // Initialize activity array if it doesn't exist
if (!mainApplication.activity) { if (!mainApplication.activity) {
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
return mod; return mod;
}); });
module.exports = _withGoogleCastAndroidManifest; export default withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
const { readFileSync, writeFileSync } = require("node:fs"); import { readFileSync, writeFileSync } from "node:fs";
const { join } = require("node:path"); import { join } from "node:path";
const { withDangerousMod } = require("expo/config-plugins"); import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const withChangeNativeAndroidTextToWhite = (expoConfig) => const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
withDangerousMod(expoConfig, [ withDangerousMod(expoConfig, [
"android", "android",
(modConfig) => { (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) => { return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents; const contents = config.modResults.contents;
@@ -32,3 +32,5 @@ configurations.all {
return config; 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) => { return withPodfile(config, (config) => {
const podfile = config.modResults.contents; 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) => { return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults; const props = exportedConfig.modResults;
const keyIdx = props.findIndex( const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key, (item) => item.type === "property" && item.key === key,
); );
const property = { const property: AndroidConfig.Properties.PropertiesItem = {
type: "property", type: "property",
key, key,
value, 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 // Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558 // https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -38,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
); );
return 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 // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; 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, withEntitlementsPlist,
withInfoPlist, withInfoPlist,
withXcodeProject, withXcodeProject,
} = require("@expo/config-plugins"); } from "expo/config-plugins";
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/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 = const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier"; "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"; return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
} }
function getAppGroupIdentifier(config) { function getAppGroupIdentifier(config: ExpoConfig): string {
return `group.${getBundleIdentifier(config)}.tvtopshelf`; return `group.${getBundleIdentifier(config)}.tvtopshelf`;
} }
function getKeychainAccessGroupIdentifier(config) { function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; 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 = const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId]; project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
const buildConfigurations = project.pbxXCBuildConfigurationSection(); const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations return configurationList.buildConfigurations
.map((config) => buildConfigurations[config.value]) .map((config: { value: string }) => buildConfigurations[config.value])
.filter(Boolean); .filter(Boolean);
} }
function ensureAppGroup(value, appGroupIdentifier) { function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier) return groups.includes(appGroupIdentifier)
? groups ? groups
: [...groups, appGroupIdentifier]; : [...groups, appGroupIdentifier];
} }
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier) return groups.includes(keychainAccessGroupIdentifier)
? groups ? groups
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
} }
function ensureAppExtension( function ensureAppExtension(
appExtensions, appExtensions: unknown,
targetName, targetName: string,
bundleIdentifier, bundleIdentifier: string,
appGroupIdentifier, appGroupIdentifier: string,
keychainAccessGroupIdentifier, keychainAccessGroupIdentifier: string,
) { ): AppExtensionConfig[] {
const extensionConfig = { const extensionConfig: AppExtensionConfig = {
targetName, targetName,
bundleIdentifier, bundleIdentifier,
entitlements: { entitlements: {
@@ -63,7 +78,9 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier], "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. // Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex( const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName, (appExtension) => appExtension?.targetName === targetName,
@@ -78,7 +95,7 @@ function ensureAppExtension(
); );
} }
const withTVOSTopShelf = (config) => { const withTVOSTopShelf: ConfigPlugin = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config); const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier = const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config); 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 * 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 // 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 // be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in // 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"); import { execSync } from "node:child_process";
const { execSync } = require("node:child_process"); import fs from "node:fs";
const fs = require("node:fs"); import path from "node:path";
const path = require("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. * 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. * 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 // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
/** /**
* Get the actual node binary path, handling nvm installations. * Get the actual node binary path, handling nvm installations.
*/ */
function getNodeBinaryPath() { function getNodeBinaryPath(): string | null {
try { try {
// First try to get node path directly (works for non-nvm installs) // First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", { const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
return null; return null;
} }
module.exports = withTVXcodeEnv; export default withTVXcodeEnv;

View File

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

View File

@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
*/ */
export function calculateTotalDownloadedSize(): number { export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems(); const items = getAllDownloadedItems();
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0); return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
} }

View File

@@ -289,7 +289,24 @@ export function useDownloadOperations({
); );
const appSizeUsage = useCallback(async () => { const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize(); let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
try { try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([ const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -310,7 +327,7 @@ export function useDownloadOperations({
appSize: totalSize, appSize: totalSize,
}; };
} }
}, []); }, [processes]);
return { return {
startBackgroundDownload, startBackgroundDownload,

View File

@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default: default:
throw new Error( throw new Error(
t( t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url", "login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
), ),
); );
} }

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file. * - Edge cases the static scan cannot see can be allow-listed in the config file.
* *
* Usage: * Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused * bun scripts/check-i18n-keys.ts # 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.ts --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.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output * bun scripts/check-i18n-keys.ts --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 --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/ */
import { import {
@@ -34,9 +34,20 @@ import {
} from "node:fs"; } from "node:fs";
import { extname, join, relative } from "node:path"; 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 ROOT = process.cwd();
const args = process.argv.slice(2); 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}=`)); const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def; if (!a) return def;
const [, v] = a.split("="); const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ---- // ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = { const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations", localesDir: "translations",
sourceLocale: "en", sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs // 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. // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [], ignoreUnused: [],
}; };
const config = existsSync(CONFIG_PATH) const config: I18nConfig = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } ? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG; : DEFAULT_CONFIG;
// ---- helpers ---- // ---- 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)) { for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k; const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v; else out[key] = v as string;
} }
return out; return out;
}; };
const globMatch = (key, pattern) => { const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*")) if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern; return key === pattern;
}; };
const walk = (dir, files = []) => { const walk = (dir: string, files: string[] = []): string[] => {
let entries; let entries: string[];
try { try {
entries = readdirSync(dir); entries = readdirSync(dir);
} catch { } catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
} }
for (const name of entries) { for (const name of entries) {
const full = join(dir, name); const full = join(dir, name);
let st; let st: ReturnType<typeof statSync>;
try { try {
st = statSync(full); st = statSync(full);
} catch { } catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ---- // ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys( const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
); );
const sourceKeySet = new Set(sourceKeys); 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 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 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 usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set(); const dynamicPrefixes = new Set<string>();
const fullyDynamic = []; // { file, line } const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals 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 // 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); // usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps // line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact. // `://` inside string URLs intact.
const stripComments = (src) => const stripComments = (src: string): string =>
src src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); .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 // 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 // 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). // 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}'`) || codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``); codeBlob.includes(`\`${key}\``);
const isUsed = (key) => const isUsed = (key: string): boolean =>
literalUsed(key) || literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) || prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g)); 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. // keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ---- // ---- 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; const [head, ...rest] = parts;
if (!(head in obj)) return; if (!(head in obj)) return;
if (rest.length === 0) { if (rest.length === 0) {
delete obj[head]; delete obj[head];
return; return;
} }
removeKey(obj[head], rest); const child = obj[head];
if ( if (!child || typeof child !== "object") return;
obj[head] && removeKey(child, rest);
typeof obj[head] === "object" && if (Object.keys(child).length === 0) delete obj[head];
Object.keys(obj[head]).length === 0
)
delete obj[head];
}; };
if (FIX_UNUSED && unused.length) { if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes // 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. // 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(".")); for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log( console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
); );
for (const k of unused) console.log(` - ${k}`); for (const k of unused) console.log(` - ${k}`);
console.log( 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( console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, ` → 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 { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs"; 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. // 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]; const raw = process.env[name];
if (raw === undefined || raw === "") return def; if (raw === undefined || raw === "") return def;
const n = Number(raw); const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/), ).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 || "") (s || "")
.toLowerCase() .toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks .replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem) .map(stem)
.filter((w) => w.length > 2); .filter((w) => w.length > 2);
const jaccard = (a, b) => { const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a); const A = new Set(a);
const B = new Set(b); const B = new Set(b);
if (!A.size || !B.size) return 0; if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE); const newTitle = tokens(TITLE);
const newBody = tokens(BODY); const newBody = tokens(BODY);
const score = (o) => const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) + 0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body)); 0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself) // fetch open issues (excluding PRs and the new issue itself)
let issues; let issues: Issue[];
if (process.env.DUP_FIXTURE) { 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 { } else {
const raw = execFileSync( const raw = execFileSync(
"gh", "gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw issues = raw
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((l) => JSON.parse(l)); .map((l) => JSON.parse(l) as Issue);
} }
const matches = issues const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and // 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 // 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. // or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) => const safeTitle = (t: string): string =>
(t || "") (t || "")
.replace(/@/g, "@") .replace(/@/g, "@")
.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"); import { execFileSync } from "node:child_process";
const process = require("node:process"); import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles // Enhanced ANSI color codes and styles
const colors = { const colors = {
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR; const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") { function log(message: string, color = "") {
if (useColor && color) { if (useColor && color) {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} else { } else {
@@ -40,7 +43,7 @@ function log(message, color = "") {
} }
} }
function formatError(errorLine) { function formatError(errorLine: string): string {
if (!useColor) return errorLine; if (!useColor) return errorLine;
// Color file paths in cyan // Color file paths in cyan
@@ -70,12 +73,15 @@ function formatError(errorLine) {
return formatted; 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 lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map(); const errorsByFile = new Map<string, number>();
const formattedErrors = []; const formattedErrors: string[] = [];
let currentError = []; let currentError: string[] = [];
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
if (!errorsByFile.has(filePath)) { if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0); errorsByFile.set(filePath, 0);
} }
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1); errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
// Start new error // Start new error
currentError.push(formatError(line)); currentError.push(formatError(line));
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
return { formattedErrors, errorsByFile }; return { formattedErrors, errorsByFile };
} }
function createErrorSummaryTable(errorsByFile) { function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
if (errorsByFile.size === 0) return ""; if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort( const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
return table; return table;
} }
function runTypeCheck() { function runTypeCheck(): { ok: boolean } {
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available // Prefer local TypeScript binary when available
@@ -150,16 +156,13 @@ function runTypeCheck() {
"false", "false",
...extraArgs, ...extraArgs,
]; ];
let execArgs = null; let execArgs: { cmd: string; args: string[] };
try { try {
const tscBin = require.resolve("typescript/bin/tsc"); const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch { } catch {
// fallback to PATH tsc // fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
execArgs = { execArgs = { cmd: "tsc", args: runnerArgs };
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
} }
try { try {
@@ -183,7 +186,21 @@ function runTypeCheck() {
); );
return { ok: true }; return { ok: true };
} catch (error) { } 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 // Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors // that generates a large volume of known type errors

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر", "no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو", "video": "فيديو",
"audio": "الصوت", "audio": "الصوت",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Vídeo", "video": "Vídeo",
"audio": "Àudio", "audio": "Àudio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici", "no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video", "video": "Video",
"audio": "Zvuk", "audio": "Zvuk",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt", "no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

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

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο", "no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο", "video": "Βίντεο",
"audio": "Ήχος", "audio": "Ήχος",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

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