mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 09:23:07 +01:00
Compare commits
16 Commits
feat/tv-se
...
chore/i18n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039d7cfaec | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
bd0eef0407 | ||
|
|
95b5dab293 | ||
|
|
2df160adb7 | ||
|
|
8d54d65b63 | ||
|
|
95f4d15e98 | ||
|
|
77156630d6 | ||
|
|
680f930a47 | ||
|
|
ed8744389b | ||
|
|
b879026745 | ||
|
|
28a75a2b8c | ||
|
|
aa0eb0a655 | ||
|
|
286a3cad47 | ||
|
|
97b6a912e0 |
@@ -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.
|
|
||||||
|
|||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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
|
||||||
|
|||||||
138
.github/workflows/build-apps.yml
vendored
138
.github/workflows/build-apps.yml
vendored
@@ -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,6 +30,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
with:
|
with:
|
||||||
@@ -56,19 +57,6 @@ jobs:
|
|||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
- name: ☕ Set up JDK 17
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
# 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
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
@@ -78,6 +66,15 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
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
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
@@ -88,6 +85,11 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
with:
|
with:
|
||||||
@@ -156,19 +159,6 @@ jobs:
|
|||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
- name: ☕ Set up JDK 17
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
# 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
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
@@ -178,6 +168,15 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
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
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
@@ -188,6 +187,11 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
@@ -229,6 +233,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -243,6 +248,19 @@ jobs:
|
|||||||
# 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
|
||||||
with:
|
with:
|
||||||
@@ -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,6 +306,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -315,6 +321,12 @@ jobs:
|
|||||||
# 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
|
||||||
with:
|
with:
|
||||||
@@ -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,6 +374,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -382,6 +389,19 @@ jobs:
|
|||||||
# 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
|
||||||
with:
|
with:
|
||||||
@@ -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,6 +445,7 @@ 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:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -452,6 +460,12 @@ jobs:
|
|||||||
# 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
|
||||||
with:
|
with:
|
||||||
@@ -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
|
||||||
|
|||||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
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 }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 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
|
||||||
|
|||||||
3
.github/workflows/detect-duplicate.yml
vendored
3
.github/workflows/detect-duplicate.yml
vendored
@@ -20,6 +20,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ jobs:
|
|||||||
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 }}
|
||||||
|
|||||||
64
.github/workflows/linting.yml
vendored
64
.github/workflows/linting.yml
vendored
@@ -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,12 +39,12 @@ jobs:
|
|||||||
name: 🚑 Expo Doctor Check
|
name: 🚑 Expo Doctor Check
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
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 }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 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
|
||||||
@@ -80,6 +52,14 @@ jobs:
|
|||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
- name: 📦 Install dependencies (bun)
|
- name: 📦 Install dependencies (bun)
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
@@ -103,18 +83,12 @@ jobs:
|
|||||||
- "i18n:check"
|
- "i18n:check"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR 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 }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
|
||||||
node-version: "24.18.0"
|
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
@@ -122,6 +96,14 @@ jobs:
|
|||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
- name: "📦 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
81
.github/workflows/pr-title-comment.yml
vendored
Normal 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
42
.github/workflows/pr-title.yml
vendored
Normal 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
|
||||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -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,6 +65,7 @@ jobs:
|
|||||||
artifact_name: streamyfin-android-tv-apk
|
artifact_name: streamyfin-android-tv-apk
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -76,6 +79,13 @@ jobs:
|
|||||||
# 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
|
||||||
with:
|
with:
|
||||||
@@ -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
|
||||||
|
|||||||
1
.github/workflows/update-issue-form.yml
vendored
1
.github/workflows/update-issue-form.yml
vendored
@@ -25,6 +25,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
|
- parallel:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
24
app.json
24
app.json
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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[
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
61
bun.lock
61
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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")
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: scaleSize(8),
|
||||||
right: 8,
|
right: scaleSize(8),
|
||||||
backgroundColor: "rgba(255,255,255,0.9)",
|
height: scaleSize(28),
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
width: 28,
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
height: 28,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mobile uses raw dp — no scaling.
|
||||||
|
const mobileBadgeBase: ViewStyle = {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#9333ea",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||||
|
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||||
|
* watched items, or items with no unplayed count, so it is safe to mount
|
||||||
|
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||||
|
* the watched checkmark is drawn natively and only the count needs RN).
|
||||||
|
*/
|
||||||
|
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
|
({ item }) => {
|
||||||
|
if (!isAggregateType(item)) return null;
|
||||||
|
if (item.UserData?.Played) return null;
|
||||||
|
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||||
|
if (unplayed <= 0) return null;
|
||||||
|
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||||
|
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tvBadgeBase,
|
||||||
|
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: scaleSize(15),
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "black",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='checkmark' size={18} 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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () =>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -684,22 +684,30 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
variant='secondary'
|
variant='secondary'
|
||||||
hasTVPreferredFocus={!hasJellyfinMedia}
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||||
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='bag-add'
|
name='bag-add'
|
||||||
size={28}
|
size={20}
|
||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 10 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("jellyseerr.request_all")}
|
{t("jellyseerr.request_all")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -710,22 +718,30 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<TVButton
|
<TVButton
|
||||||
onPress={handleOpenSeasonSelectModal}
|
onPress={handleOpenSeasonSelectModal}
|
||||||
variant='secondary'
|
variant='secondary'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='list'
|
name='list'
|
||||||
size={28}
|
size={20}
|
||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 10 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("jellyseerr.request_seasons")}
|
{t("jellyseerr.request_seasons")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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={{}}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.replace("/(auth)/tv-request-modal");
|
|
||||||
} else {
|
|
||||||
router.push("/(auth)/tv-request-modal");
|
router.push("/(auth)/tv-request-modal");
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
// post-animation layout pass. Replaces the old TextureView
|
||||||
|
// measure/layout polling hack (forcePiPBufferSize).
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isWaitingForPiPTransition = false
|
// Restore from PiP: surface resized back to fullscreen.
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
restoreFromPiP()
|
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 {
|
|
||||||
this.surfaceTexture = null
|
|
||||||
surfaceReady = false
|
|
||||||
renderer?.detachSurface()
|
|
||||||
return false // mpv manages the SurfaceTexture
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
// Called every frame — no action needed, mpv drives rendering directly
|
surfaceReady = false
|
||||||
|
renderer?.detachSurface()
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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").`,
|
||||||
@@ -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, " ")
|
||||||
@@ -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;
|
|
||||||
// }
|
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user