mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
Compare commits
122 Commits
renovate/r
...
fix/skip-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e5dc35da | ||
|
|
5de017612d | ||
|
|
c00d2f050e | ||
|
|
ca9e9f1486 | ||
|
|
41e2dc3b81 | ||
|
|
9b2d289908 | ||
|
|
3cc4fd1a1e | ||
|
|
22739277b7 | ||
|
|
5623a5cddb | ||
|
|
e481f22e10 | ||
|
|
4487682412 | ||
|
|
994056d5f0 | ||
|
|
31cf9e8dff | ||
|
|
3a77ffc63c | ||
|
|
e6200a09ad | ||
|
|
fe36e2e0fc | ||
|
|
a1ba5d8c1d | ||
|
|
299d80ce03 | ||
|
|
1eaca76e97 | ||
|
|
9d9da134c0 | ||
|
|
c3275cda34 | ||
|
|
cd60c0ca07 | ||
|
|
6f6e3ed49f | ||
|
|
fd4215407d | ||
|
|
71b82a75ee | ||
|
|
e0cf1559bc | ||
|
|
610158efbd | ||
|
|
a2802b4570 | ||
|
|
9f08b51e1e | ||
|
|
06076b1a85 | ||
|
|
7d8014f686 | ||
|
|
e79ae53e4e | ||
|
|
4813fdab81 | ||
|
|
980245f19d | ||
|
|
d5d0976ff9 | ||
|
|
6dd98ccae8 | ||
|
|
228e847a4e | ||
|
|
15c66bc714 | ||
|
|
b4eb853048 | ||
|
|
c4209cbf8e | ||
|
|
fd504cef14 | ||
|
|
dbdf4767a5 | ||
|
|
1860863c60 | ||
|
|
69651cdfa3 | ||
|
|
25ee7dad85 | ||
|
|
76d9b3177b | ||
|
|
34d9b43dcb | ||
|
|
039d7cfaec | ||
|
|
9cdfebe908 | ||
|
|
bee8323110 | ||
|
|
8b02cfcb65 | ||
|
|
3020bf5903 | ||
|
|
1f76119082 | ||
|
|
e0017b8092 | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
4c5a3dc658 | ||
|
|
b39eb35795 | ||
|
|
bd0eef0407 | ||
|
|
c4b6390a5e | ||
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
98dfefbdd0 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
49a903f48a | ||
|
|
8618e2d6c3 | ||
|
|
95b5dab293 | ||
|
|
c5d39d40b8 | ||
|
|
2df160adb7 | ||
|
|
8d54d65b63 | ||
|
|
95f4d15e98 | ||
|
|
fbeb025d73 | ||
|
|
64b9224b7a | ||
|
|
b59759638c | ||
|
|
8219e44b5b | ||
|
|
77156630d6 | ||
|
|
680f930a47 | ||
|
|
376ccd1f93 | ||
|
|
ed8744389b | ||
|
|
5221ed1963 | ||
|
|
461d055c7a | ||
|
|
58f3646477 | ||
|
|
b879026745 | ||
|
|
33ca07ca82 | ||
|
|
e5d56d6ad1 | ||
|
|
80602ecd23 | ||
|
|
14ea18e0d2 | ||
|
|
28fcb303c6 | ||
|
|
28a75a2b8c | ||
|
|
d9677cbece | ||
|
|
90e9084949 | ||
|
|
aa0eb0a655 | ||
|
|
115c163aeb | ||
|
|
286a3cad47 | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97b6a912e0 | ||
|
|
97ef9b5ee7 | ||
|
|
cc0007926d | ||
|
|
ff708ec832 | ||
|
|
9e29305e28 | ||
|
|
ae9c05637b | ||
|
|
f820bedf6e | ||
|
|
47c5d61f28 | ||
|
|
517bc7bbb5 | ||
|
|
d6980cfc8e | ||
|
|
b256e99fc8 |
@@ -13,7 +13,7 @@ build:
|
|||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android, bun)
|
name: Prebuild (Android, bun)
|
||||||
command: bunx expo prebuild --platform android --no-install
|
command: bun x expo prebuild --platform android --no-install
|
||||||
|
|
||||||
- eas/configure_android_version
|
- eas/configure_android_version
|
||||||
- eas/inject_android_credentials
|
- eas/inject_android_credentials
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ build:
|
|||||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android TV, bun)
|
name: Prebuild (Android TV, bun)
|
||||||
command: bunx expo prebuild --platform android --no-install
|
command: bun x expo prebuild --platform android --no-install
|
||||||
|
|
||||||
- eas/configure_android_version
|
- eas/configure_android_version
|
||||||
- eas/inject_android_credentials
|
- eas/inject_android_credentials
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ build:
|
|||||||
command: bun install --frozen-lockfile
|
command: bun install --frozen-lockfile
|
||||||
|
|
||||||
# android/ is gitignored, so generate native code fresh. --no-install
|
# android/ is gitignored, so generate native code fresh. --no-install
|
||||||
# because deps are already installed above; bunx keeps it on bun.
|
# because deps are already installed above; bun x keeps it on bun.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android, bun)
|
name: Prebuild (Android, bun)
|
||||||
command: bunx expo prebuild --platform android --no-install
|
command: bun x expo prebuild --platform android --no-install
|
||||||
|
|
||||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
||||||
# in eas.json) into the freshly prebuilt android/ project.
|
# in eas.json) into the freshly prebuilt android/ project.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ build:
|
|||||||
# skips JS + pod install; we install pods explicitly below with bun deps.
|
# skips JS + pod install; we install pods explicitly below with bun deps.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (iOS/tvOS, bun)
|
name: Prebuild (iOS/tvOS, bun)
|
||||||
command: bunx expo prebuild --platform ios --no-install
|
command: bun x expo prebuild --platform ios --no-install
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install CocoaPods
|
name: Install CocoaPods
|
||||||
|
|||||||
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
|
||||||
|
|||||||
390
.github/workflows/build-apps.yml
vendored
390
.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,69 +30,71 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- parallel:
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
- name: 🗑️ Free Disk Space
|
||||||
with:
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
tool-cache: false
|
with:
|
||||||
mandb: true
|
tool-cache: false
|
||||||
android: false
|
mandb: true
|
||||||
dotnet: true
|
android: false
|
||||||
haskell: true
|
dotnet: true
|
||||||
large-packages: false
|
haskell: true
|
||||||
docker-images: true
|
large-packages: false
|
||||||
swap-storage: false
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: ☕ Set up JDK 17
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||||
with:
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
path: ~/.bun/install/cache
|
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
restore-keys: |
|
with:
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- parallel:
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches/modules-2
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
|
||||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
|
||||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches/modules-2
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -130,69 +132,71 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- parallel:
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
- name: 🗑️ Free Disk Space
|
||||||
with:
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
tool-cache: false
|
with:
|
||||||
mandb: true
|
tool-cache: false
|
||||||
android: false
|
mandb: true
|
||||||
dotnet: true
|
android: false
|
||||||
haskell: true
|
dotnet: true
|
||||||
large-packages: false
|
haskell: true
|
||||||
docker-images: true
|
large-packages: false
|
||||||
swap-storage: false
|
docker-images: true
|
||||||
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: ☕ Set up JDK 17
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||||
with:
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
path: ~/.bun/install/cache
|
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
restore-keys: |
|
with:
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- parallel:
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches/modules-2
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
|
||||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
|
||||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches/modules-2
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -229,22 +233,36 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout code
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.6"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -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,22 +306,29 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout code
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -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,22 +374,36 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout code
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.6"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -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,22 +445,29 @@ jobs:
|
|||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout code
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
fetch-depth: 0
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -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
|
||||||
|
|||||||
26
.github/workflows/check-lockfile.yml
vendored
26
.github/workflows/check-lockfile.yml
vendored
@@ -18,22 +18,22 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout repository
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
show-progress: false
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
17
.github/workflows/detect-duplicate.yml
vendored
17
.github/workflows/detect-duplicate.yml
vendored
@@ -20,17 +20,18 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
98
.github/workflows/linting.yml
vendored
98
.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,18 +39,26 @@ jobs:
|
|||||||
name: 🚑 Expo Doctor Check
|
name: 🚑 Expo Doctor Check
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 🛒 Checkout repository
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
submodules: recursive
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
# renovate: datasource=npm depName=bun
|
||||||
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
path: ~/.bun/install/cache
|
||||||
bun-version: "1.3.14"
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
- name: 📦 Install dependencies (bun)
|
- name: 📦 Install dependencies (bun)
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -103,24 +83,26 @@ jobs:
|
|||||||
- "i18n:check"
|
- "i18n:check"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: "📥 Checkout PR code"
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
with:
|
||||||
submodules: recursive
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🍞 Setup Bun"
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
# renovate: datasource=npm depName=bun
|
||||||
node-version: "24.17.0"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
path: ~/.bun/install/cache
|
||||||
bun-version: "1.3.14"
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
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
|
||||||
41
.github/workflows/release.yml
vendored
41
.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,21 +65,29 @@ jobs:
|
|||||||
artifact_name: streamyfin-android-tv-apk
|
artifact_name: streamyfin-android-tv-apk
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout code
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
fetch-depth: 0
|
with:
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -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
|
||||||
|
|||||||
25
.github/workflows/update-issue-form.yml
vendored
25
.github/workflows/update-issue-form.yml
vendored
@@ -25,19 +25,20 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- parallel:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- name: 📥 Checkout repository
|
||||||
with:
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
with:
|
||||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||||
# PR would revert any form edits made on develop since that release.
|
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||||
ref: develop
|
# PR would revert any form edits made on develop since that release.
|
||||||
|
ref: develop
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
# renovate: datasource=npm depName=bun
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔢 Populate version dropdown from GitHub releases
|
- name: 🔢 Populate version dropdown from GitHub releases
|
||||||
id: populate
|
id: populate
|
||||||
|
|||||||
4
.gitignore
vendored
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)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
26
app.json
26
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.1",
|
"version": "0.55.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -158,7 +157,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -324,32 +324,44 @@ export default function SettingsTV() {
|
|||||||
// MPV alignment options
|
// MPV alignment options
|
||||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
|
||||||
{
|
{
|
||||||
label: "Center",
|
label: t("home.settings.subtitles.align.left"),
|
||||||
|
value: "left",
|
||||||
|
selected: currentAlignX === "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.align.center"),
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignX === "center",
|
selected: currentAlignX === "center",
|
||||||
},
|
},
|
||||||
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
{
|
||||||
|
label: t("home.settings.subtitles.align.right"),
|
||||||
|
value: "right",
|
||||||
|
selected: currentAlignX === "right",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[currentAlignX],
|
[currentAlignX, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
|
||||||
{
|
{
|
||||||
label: "Center",
|
label: t("home.settings.subtitles.align.top"),
|
||||||
|
value: "top",
|
||||||
|
selected: currentAlignY === "top",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.align.center"),
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignY === "center",
|
selected: currentAlignY === "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Bottom",
|
label: t("home.settings.subtitles.align.bottom"),
|
||||||
value: "bottom",
|
value: "bottom",
|
||||||
selected: currentAlignY === "bottom",
|
selected: currentAlignY === "bottom",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentAlignY],
|
[currentAlignY, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache mode options
|
// Cache mode options
|
||||||
@@ -499,13 +511,13 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
const alignXLabel = useMemo(() => {
|
const alignXLabel = useMemo(() => {
|
||||||
const option = alignXOptions.find((o) => o.selected);
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
return option?.label || "Center";
|
return option?.label || t("home.settings.subtitles.align.center");
|
||||||
}, [alignXOptions]);
|
}, [alignXOptions, t]);
|
||||||
|
|
||||||
const alignYLabel = useMemo(() => {
|
const alignYLabel = useMemo(() => {
|
||||||
const option = alignYOptions.find((o) => o.selected);
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
return option?.label || "Bottom";
|
return option?.label || t("home.settings.subtitles.align.bottom");
|
||||||
}, [alignYOptions]);
|
}, [alignYOptions, t]);
|
||||||
|
|
||||||
const typographyScaleLabel = useMemo(() => {
|
const typographyScaleLabel = useMemo(() => {
|
||||||
const option = typographyScaleOptions.find((o) => o.selected);
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
@@ -645,7 +657,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(
|
||||||
@@ -663,11 +675,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({
|
||||||
@@ -677,11 +689,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({
|
||||||
@@ -854,6 +866,13 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, Switch, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4'
|
className='px-4 pt-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -71,7 +72,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>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -60,7 +61,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
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 { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -72,6 +75,25 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
|
const copyLog = useCallback(
|
||||||
|
async (log: NonNullable<typeof logs>[number]) => {
|
||||||
|
// Skip on builds that don't ship the expo-clipboard native module
|
||||||
|
// (probe returns null instead of throwing); same guard as Quick Connect.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = [
|
||||||
|
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
||||||
|
log.message,
|
||||||
|
log.data ? JSON.stringify(log.data, null, 2) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
await Clipboard.setStringAsync(text);
|
||||||
|
toast.success(t("home.settings.logs.copied"));
|
||||||
|
},
|
||||||
|
[logs, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -88,8 +110,15 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<ScrollView
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
// Like the sibling settings pages, let iOS auto-inset the content below the
|
||||||
|
// transparent header (no manual header-height math). The filter bar is a
|
||||||
|
// sticky header so it stays pinned just under the header while logs scroll.
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -98,7 +127,6 @@ export default function Page() {
|
|||||||
values={[order]}
|
values={[order]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={levelsFilterId}
|
id={levelsFilterId}
|
||||||
@@ -108,71 +136,80 @@ export default function Page() {
|
|||||||
values={levels}
|
values={levels}
|
||||||
title={t("home.settings.logs.level")}
|
title={t("home.settings.logs.level")}
|
||||||
renderItemLabel={(level) => level}
|
renderItemLabel={(level) => level}
|
||||||
disableSearch={true}
|
|
||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
<View className='flex flex-col space-y-2 px-4'>
|
||||||
className='pb-4 px-4'
|
{filteredLogs?.map((log, index) => (
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
>
|
<TouchableOpacity
|
||||||
<View className='flex flex-col space-y-2'>
|
disabled={!log.data}
|
||||||
{filteredLogs?.map((log, index) => (
|
onPress={() =>
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
setState((v) => ({
|
||||||
<TouchableOpacity
|
...v,
|
||||||
disabled={!log.data}
|
[log.timestamp]: !v[log.timestamp],
|
||||||
onPress={() =>
|
}))
|
||||||
setState((v) => ({
|
}
|
||||||
...v,
|
>
|
||||||
[log.timestamp]: !v[log.timestamp],
|
<View className='flex flex-row justify-between'>
|
||||||
}))
|
<Text
|
||||||
}
|
className={`mb-1
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-between'>
|
|
||||||
<Text
|
|
||||||
className={`mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
${log.level === "DEBUG" && "text-purple-500"}
|
${log.level === "DEBUG" && "text-purple-500"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{log.level}
|
{log.level}
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text selectable className='text-xs'>
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{log.data && (
|
<Text className='text-xs'>
|
||||||
<>
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
{!state[log.timestamp] && (
|
</Text>
|
||||||
<Text className='text-xs mt-0.5'>
|
</View>
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
<Text className='text-xs'>{log.message}</Text>
|
||||||
</Text>
|
{/* Keep the whole collapsed row tappable: the hint lives inside
|
||||||
)}
|
the toggle so tapping it expands too. */}
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
{log.data && !state[log.timestamp] && (
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
<Text className='text-xs mt-0.5'>
|
||||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
</Text>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</Collapsible>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
))}
|
|
||||||
{filteredLogs?.length === 0 && (
|
{log.data && (
|
||||||
<Text className='opacity-50'>
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
{t("home.settings.logs.no_logs_available")}
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
</Text>
|
<ScrollView
|
||||||
)}
|
className='rounded-xl'
|
||||||
</View>
|
style={codeBlockStyle}
|
||||||
</ScrollView>
|
nestedScrollEnabled
|
||||||
</View>
|
>
|
||||||
|
{/* Only the raw payload is selectable (per request); the
|
||||||
|
header/message stay tap-to-toggle. */}
|
||||||
|
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => copyLog(log)}
|
||||||
|
className='flex flex-row items-center self-end px-2 py-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='copy-outline' size={16} color='white' />
|
||||||
|
<Text className='text-xs ml-1'>
|
||||||
|
{t("home.settings.logs.copy")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{filteredLogs?.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.settings.logs.no_logs_available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup>
|
<ListGroup className='mt-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View className='mb-4'>
|
<View>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='p-4'>
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='px-4'>
|
||||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} 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 { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
||||||
|
// Effective (user/admin merged) URL, same source the search screen uses —
|
||||||
|
// the raw plugin value misses a user-configured Streamystats.
|
||||||
|
const hasStreamystats = !!settings?.streamyStatsServerUrl;
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
if (!marlinUrlLocked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
}, [navigation, value, marlinUrlLocked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||||
disabled={
|
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||||
pluginSettings?.searchEngine?.locked === true ||
|
<ListItem
|
||||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
title={t(
|
||||||
}
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
)}
|
||||||
|
disabledByAdmin={searchEngineLocked}
|
||||||
|
// Streamystats owns the search engine while configured — block the
|
||||||
|
// row tap too, not just the Switch, so it can't force "Jellyfin".
|
||||||
|
disabled={hasStreamystats}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItem
|
<SettingSwitch
|
||||||
title={t(
|
value={settings.searchEngine === "Marlin"}
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
disabled={searchEngineLocked || hasStreamystats}
|
||||||
)}
|
onValueChange={(val) => {
|
||||||
onPress={() => {
|
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Switch
|
</ListItem>
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({
|
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<DisabledSetting
|
<ListGroup className='mt-2'>
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
<ListItem
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
title={t("home.settings.plugins.marlin_search.url")}
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
disabledByAdmin={marlinUrlLocked}
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
|
||||||
>
|
>
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||||
className='text-white'
|
className='text-white text-right flex-1'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</DisabledSetting>
|
</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.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
// Returns undefined when the API call fails (handled internally).
|
||||||
|
const refreshed = await refreshStreamyfinPluginSettings();
|
||||||
|
if (refreshed) {
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
||||||
|
so it lives on the plugins index rather than inside Streamystats. */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} 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 { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const {
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
} = useSettings();
|
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const isStreamystatsEnabled = !!url;
|
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const movieRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
||||||
|
const seriesRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
||||||
|
const promotedWatchlistsLocked =
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
||||||
|
const hideWatchlistsTabLocked =
|
||||||
|
pluginSettings?.hideWatchlistsTab?.locked === true;
|
||||||
|
// The input renders the locked admin URL; enablement must follow the same
|
||||||
|
// effective value or every toggle stays disabled until local state syncs.
|
||||||
|
const effectiveUrl = isUrlLocked
|
||||||
|
? (settings?.streamyStatsServerUrl ?? "")
|
||||||
|
: url;
|
||||||
|
const isStreamystatsEnabled = !!effectiveUrl;
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
|
||||||
// Update local state with new values
|
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
|
||||||
setUrl(newUrl);
|
|
||||||
if (newUrl) {
|
|
||||||
setUseForSearch(true);
|
|
||||||
}
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={url}
|
value={effectiveUrl}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
disabledByAdmin={searchLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
{/* Locked controls show the live admin value and can't be toggled —
|
||||||
value={useForSearch}
|
local form state would let the switch flip while the write guard
|
||||||
disabled={!isStreamystatsEnabled}
|
drops the change. */}
|
||||||
|
<SettingSwitch
|
||||||
|
value={
|
||||||
|
searchLocked
|
||||||
|
? settings?.searchEngine === "Streamystats"
|
||||||
|
: useForSearch
|
||||||
|
}
|
||||||
|
disabled={!isStreamystatsEnabled || searchLocked}
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={movieRecsLocked}
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={movieRecs}
|
value={
|
||||||
|
movieRecsLocked
|
||||||
|
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||||
|
: movieRecs
|
||||||
|
}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={seriesRecsLocked}
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={seriesRecs}
|
value={
|
||||||
|
seriesRecsLocked
|
||||||
|
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||||
|
: seriesRecs
|
||||||
|
}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={promotedWatchlistsLocked}
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={promotedWatchlists}
|
value={
|
||||||
|
promotedWatchlistsLocked
|
||||||
|
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||||
|
: promotedWatchlists
|
||||||
|
}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
disabledByAdmin={hideWatchlistsTabLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={hideWatchlistsTab}
|
value={
|
||||||
|
hideWatchlistsTabLocked
|
||||||
|
? (settings?.hideWatchlistsTab ?? false)
|
||||||
|
: hideWatchlistsTab
|
||||||
|
}
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -349,9 +349,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -376,7 +373,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -401,9 +397,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -421,9 +414,6 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -441,9 +431,6 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -529,9 +568,6 @@ const Page = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -556,7 +592,6 @@ const Page = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -581,9 +616,6 @@ const Page = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -601,9 +633,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -621,9 +650,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -641,9 +667,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
generalFilters.find((i) => i.key === item)?.value || ""
|
generalFilters.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
|
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
||||||
|
textColor: "#fff",
|
||||||
// Android: placeholder and icon color
|
// Android: placeholder and icon color
|
||||||
hintTextColor: "#fff",
|
hintTextColor: "#fff",
|
||||||
headerIconColor: "#fff",
|
headerIconColor: "#fff",
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
||||||
|
import { TVNavBar } from "@/components/tv/TVNavBar";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
isTabRoute,
|
||||||
|
useTVHomeBackHandler,
|
||||||
|
useTVTabRootBackHandler,
|
||||||
|
} from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
|
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
||||||
|
|
||||||
|
function TVTabLayout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const currentTab = segments.find(isTabRoute);
|
||||||
|
const lastSegment = segments[segments.length - 1] ?? "";
|
||||||
|
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
|
|
||||||
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "(home)", label: t("tabs.home") },
|
||||||
|
{ key: "(search)", label: t("tabs.search") },
|
||||||
|
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||||
|
? null
|
||||||
|
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||||
|
{ key: "(libraries)", label: t("tabs.library") },
|
||||||
|
!settings?.showCustomMenuLinks
|
||||||
|
? null
|
||||||
|
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||||
|
{ key: "(settings)", label: t("tabs.settings") },
|
||||||
|
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||||
|
[
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
settings?.hideWatchlistsTab,
|
||||||
|
settings?.showCustomMenuLinks,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTabKey = currentTab ?? "(home)";
|
||||||
|
|
||||||
|
const visibleKeys = useMemo(
|
||||||
|
() => new Set(tabs.map((tab) => tab.key)),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === currentTab) return;
|
||||||
|
|
||||||
|
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||||
|
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/${key}`);
|
||||||
|
},
|
||||||
|
[currentTab, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateHome = useCallback(() => {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}, [router]);
|
||||||
|
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||||
|
|
||||||
|
// If current tab is no longer visible (setting changed), navigate to home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}
|
||||||
|
}, [visibleKeys, activeTabKey, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<SystemBars hidden={false} style='light' />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{ headerShown: false, animation: "none" }}
|
||||||
|
initialRouteName='(home)'
|
||||||
|
>
|
||||||
|
<Stack.Screen name='index' redirect />
|
||||||
|
</Stack>
|
||||||
|
<TVNavBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabKey={activeTabKey}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Handle TV back button - prevent app exit when at root
|
// Must be called before any conditional return (rules of hooks)
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
|
if (IS_ANDROID_TV) {
|
||||||
|
return <TVTabLayout />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
|
getMediaInfoApi,
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -25,6 +26,10 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
|||||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
|
import {
|
||||||
|
LOCAL_SUBTITLE_INDEX_START,
|
||||||
|
toServerSubtitleIndex,
|
||||||
|
} from "@/components/video-player/controls/types";
|
||||||
import {
|
import {
|
||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
@@ -49,15 +54,16 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
|
getExternalSubtitleUrl,
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
getMpvSubtitleId,
|
isImageBasedSubtitle,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -310,6 +316,37 @@ export default function DirectPlayerPage() {
|
|||||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||||
|
|
||||||
|
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
|
||||||
|
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
|
||||||
|
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
|
||||||
|
// handles a clean stop, but a channel switch (the player re-fetches in place)
|
||||||
|
// or an unmount after an error bypass it, so track the open live stream and
|
||||||
|
// release it on those paths too.
|
||||||
|
const apiRef = useRef(api);
|
||||||
|
useEffect(() => {
|
||||||
|
apiRef.current = api;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const releaseLiveStream = useCallback(
|
||||||
|
(liveStreamId: string | null) => {
|
||||||
|
if (!liveStreamId || !apiRef.current || offline) return;
|
||||||
|
// Best effort: a failed close must not break teardown, and the slot is
|
||||||
|
// also freed by the server's reap as a backstop.
|
||||||
|
getMediaInfoApi(apiRef.current)
|
||||||
|
.closeLiveStream({ liveStreamId })
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[offline],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The effect cleanup releases the live stream both when it changes (channel
|
||||||
|
// switch, which re-runs the effect) and when the player unmounts, so no
|
||||||
|
// manual previous-id tracking is needed.
|
||||||
|
useEffect(() => {
|
||||||
|
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
|
||||||
|
return () => releaseLiveStream(liveStreamId);
|
||||||
|
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
@@ -445,6 +482,12 @@ export default function DirectPlayerPage() {
|
|||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: currentTimeInTicks,
|
PositionTicks: currentTimeInTicks,
|
||||||
PlaySessionId: stream.sessionId,
|
PlaySessionId: stream.sessionId,
|
||||||
|
// Release the server-side live stream (and its tuner slot) on stop.
|
||||||
|
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
|
||||||
|
// the stop report carries its LiveStreamId; without it the stream leaks
|
||||||
|
// and Live TV eventually fails for everyone with "M3U simultaneous
|
||||||
|
// stream limit has been reached". Undefined for non-live items (no-op).
|
||||||
|
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
@@ -456,10 +499,23 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||||
|
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
||||||
|
// episode's player) mounts while the old 4K decoder is still alive,
|
||||||
|
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
||||||
|
// later React unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
// Pre-libmpv-1.0 used `stop()`:
|
||||||
|
// videoRef.current?.stop();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
|
// Release the keep-awake wakelock acquired during playback so it
|
||||||
|
// doesn't follow us back to the home screen and block the TV
|
||||||
|
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
||||||
|
// and only released on the "paused" event; without this, navigating
|
||||||
|
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
||||||
|
deactivateKeepAwake();
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -606,32 +662,20 @@ export default function DirectPlayerPage() {
|
|||||||
const mediaSource = stream.mediaSource;
|
const mediaSource = stream.mediaSource;
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// Get external subtitle URLs
|
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||||
// - Online: prepend API base path to server URLs
|
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||||
let externalSubs: string[] | undefined;
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
if (!offline && api?.basePath) {
|
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
)
|
||||||
(s) =>
|
.map((s) =>
|
||||||
s.Type === "Subtitle" &&
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
s.DeliveryMethod === "External" &&
|
)
|
||||||
s.DeliveryUrl,
|
.filter((u): u is string => !!u);
|
||||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
|
||||||
} else if (offline) {
|
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) =>
|
|
||||||
s.Type === "Subtitle" &&
|
|
||||||
s.DeliveryMethod === "External" &&
|
|
||||||
s.DeliveryUrl,
|
|
||||||
).map((s) => s.DeliveryUrl!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||||
const initialSubtitleId = getMpvSubtitleId(
|
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||||
mediaSource,
|
// list is known — see applySubtitleSelection / onTracksReady.
|
||||||
subtitleIndex,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -649,7 +693,6 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -697,7 +740,6 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -880,6 +922,29 @@ 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),
|
||||||
|
// A local (client-downloaded) sub only exists in the dying mpv
|
||||||
|
// instance — the server must be asked for "none" (-1) instead.
|
||||||
|
subtitleIndex: String(toServerSubtitleIndex(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,
|
||||||
@@ -891,34 +956,114 @@ 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
|
||||||
|
/**
|
||||||
|
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||||
|
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||||
|
* stays correct across external/embedded reordering and server-hidden embedded
|
||||||
|
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||||
|
* fired again after each external sub-add) and runtime changes.
|
||||||
|
*/
|
||||||
|
const applySubtitleSelection = useCallback(
|
||||||
|
async (jellyfinSubtitleIndex: number) => {
|
||||||
|
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
return applyMpvSubtitleSelection(videoRef.current, {
|
||||||
|
subtitleStreams,
|
||||||
|
jellyfinSubtitleIndex,
|
||||||
|
getExpectedExternalUrl: (s) =>
|
||||||
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[stream?.mediaSource, offline, api?.basePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-negotiate the stream with new track params (server re-processes it,
|
||||||
|
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
|
||||||
|
// replacePlayer, resuming at the live position.
|
||||||
|
const replaceWithTrackSelection = useCallback(
|
||||||
|
(params: { subtitleIndex?: string; audioIndex?: string }) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item?.Id ?? "",
|
||||||
|
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
|
||||||
|
subtitleIndex:
|
||||||
|
params.subtitleIndex ??
|
||||||
|
String(toServerSubtitleIndex(currentSubtitleIndex)),
|
||||||
|
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
}).toString();
|
||||||
|
// Destroy the current mpv instance before re-navigating, same rationale as
|
||||||
|
// goToNextItem: Expo Router briefly holds two players during the
|
||||||
|
// transition and two decoders/surfaces OOM-kill low-RAM devices.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
item?.Id,
|
||||||
|
currentAudioIndex,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
stream?.mediaSource?.Id,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
progress,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV/mobile subtitle track change handler
|
||||||
const handleSubtitleIndexChange = useCallback(
|
const handleSubtitleIndexChange = useCallback(
|
||||||
async (index: number) => {
|
async (index: number) => {
|
||||||
setCurrentSubtitleIndex(index);
|
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
|
||||||
|
// resolvable against server streams — just track the live index.
|
||||||
|
if (index <= LOCAL_SUBTITLE_INDEX_START) {
|
||||||
|
setCurrentSubtitleIndex(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we're transcoding
|
const subs = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
const target = subs?.find((s) => s.Index === index);
|
||||||
|
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
|
||||||
|
// Burned-in subs are pixels, not tracks: switching TO one and switching
|
||||||
|
// AWAY from an active one both need a server re-process (same guard as
|
||||||
|
// VideoContext's needsReplace on the mobile menu path).
|
||||||
|
const needsReplace =
|
||||||
|
isTranscoding &&
|
||||||
|
((target && isImageBasedSubtitle(target)) ||
|
||||||
|
(current && isImageBasedSubtitle(current)));
|
||||||
|
if (needsReplace) {
|
||||||
|
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (index === -1) {
|
setCurrentSubtitleIndex(index);
|
||||||
// Disable subtitles
|
const result = await applySubtitleSelection(index);
|
||||||
await videoRef.current?.disableSubtitles?.();
|
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||||
} else {
|
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||||
// Convert Jellyfin index to MPV track ID
|
// stream with it.
|
||||||
const mpvTrackId = getMpvSubtitleId(
|
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||||
stream?.mediaSource,
|
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||||
index,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
|
||||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[
|
||||||
|
applySubtitleSelection,
|
||||||
|
replaceWithTrackSelection,
|
||||||
|
stream?.mediaSource,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1037,6 +1182,10 @@ export default function DirectPlayerPage() {
|
|||||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
// Free the current mpv instance before navigating, matching goToNextItem —
|
||||||
|
// otherwise two decoders/surfaces overlap during the transition and can
|
||||||
|
// OOM-kill low-RAM devices.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
@@ -1049,9 +1198,24 @@ export default function DirectPlayerPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
const addSubtitleFile = useCallback(async (path: string) => {
|
const addSubtitleFile = useCallback(
|
||||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
async (path: string) => {
|
||||||
}, []);
|
// Set the live index to the new local sub's REAL index BEFORE the add.
|
||||||
|
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
|
||||||
|
// downloaded path's position (not a blanket sentinel, which would collide
|
||||||
|
// with the first local sub at -100 and mis-record the selection). Any
|
||||||
|
// local index resolves to notFound on the onTracksReady re-apply, so it
|
||||||
|
// still doesn't clobber the freshly selected track; carry-over now keeps
|
||||||
|
// the correct local sub.
|
||||||
|
const locals = itemId ? getSubtitlesForItem(itemId) : [];
|
||||||
|
const pos = locals.findIndex((s) => s.filePath === path);
|
||||||
|
setCurrentSubtitleIndex(
|
||||||
|
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
|
||||||
|
);
|
||||||
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
|
},
|
||||||
|
[itemId],
|
||||||
|
);
|
||||||
|
|
||||||
// TV: Refresh subtitle tracks after server-side subtitle download
|
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||||
@@ -1105,6 +1269,15 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
||||||
|
// decoder + surface buffers are freed before the new player screen
|
||||||
|
// mounts. Without this, Expo Router briefly holds two simultaneous
|
||||||
|
// mpv instances during the transition (~768 MB of surface buffers
|
||||||
|
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
||||||
|
// devices. Native stop() is idempotent so the subsequent React
|
||||||
|
// unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1115,6 +1288,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
|
videoRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1267,12 +1441,16 @@ 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);
|
||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
|
// Fired after embedded tracks enumerate and again after each
|
||||||
|
// external sub-add; re-resolve so the final fire (full track
|
||||||
|
// list) selects the right track by identity.
|
||||||
|
void applySubtitleSelection(currentSubtitleIndex);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hasPlaybackStarted && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -75,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();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
|
InteractionManager,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -192,6 +193,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 +330,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>
|
||||||
)}
|
)}
|
||||||
@@ -645,10 +647,23 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
const handleTrackSelect = useCallback(
|
const handleTrackSelect = useCallback(
|
||||||
(option: { setTrack?: () => void }) => {
|
(option: { setTrack?: () => void }) => {
|
||||||
|
if (modalState?.deferApplyUntilDismissed) {
|
||||||
|
// Player: setTrack can navigate (replacePlayer for a burn-in switch
|
||||||
|
// while transcoding); a router.replace fired while this modal is the
|
||||||
|
// active route targets the MODAL and is swallowed. Close FIRST, apply
|
||||||
|
// after dismissal.
|
||||||
|
handleClose();
|
||||||
|
InteractionManager.runAfterInteractions(() => option.setTrack?.());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Detail page: setTrack only updates state. Run it BEFORE closing so the
|
||||||
|
// re-render happens while the modal is up; deferring it until after
|
||||||
|
// dismissal re-renders the detail page after focus returns and yanks TV
|
||||||
|
// focus, leaving navigation stuck.
|
||||||
option.setTrack?.();
|
option.setTrack?.();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
[handleClose],
|
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
305
app/_layout.tsx
305
app/_layout.tsx
@@ -7,9 +7,11 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -26,6 +28,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
|||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
|
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
@@ -84,7 +87,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,
|
||||||
}),
|
}),
|
||||||
@@ -100,6 +104,22 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
||||||
|
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
||||||
|
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
||||||
|
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
||||||
|
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
||||||
|
try {
|
||||||
|
Image.configureCache({
|
||||||
|
maxMemoryCost: Platform.isTV
|
||||||
|
? 8 * 1024 * 1024 // ~8 MB on TV
|
||||||
|
: 128 * 1024 * 1024, // ~128 MB on mobile
|
||||||
|
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
||||||
|
}
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -333,9 +353,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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -404,143 +427,149 @@ function Layout() {
|
|||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<InactivityProvider>
|
<InactivityProvider>
|
||||||
<ServerUrlProvider>
|
<WifiSsidProvider>
|
||||||
<NetworkStatusProvider>
|
<ServerUrlProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<IntroSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<IntroSheetProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/now-playing'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent:
|
||||||
|
Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
{!Platform.isTV && <GlobalModal />}
|
||||||
name='(auth)/player'
|
{!Platform.isTV && (
|
||||||
options={{
|
<PendingAccountSaveModal />
|
||||||
headerShown: false,
|
)}
|
||||||
title: "",
|
</ThemeProvider>
|
||||||
header: () => null,
|
</IntroSheetProvider>
|
||||||
}}
|
</BottomSheetModalProvider>
|
||||||
/>
|
</GlobalModalProvider>
|
||||||
<Stack.Screen
|
</MusicPlayerProvider>
|
||||||
name='(auth)/now-playing'
|
</DownloadProvider>
|
||||||
options={{
|
</WebSocketProvider>
|
||||||
headerShown: false,
|
</LogProvider>
|
||||||
presentation: "modal",
|
</PlaySettingsProvider>
|
||||||
gestureEnabled: true,
|
</NetworkStatusProvider>
|
||||||
}}
|
</ServerUrlProvider>
|
||||||
/>
|
</WifiSsidProvider>
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-action-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-user-switch-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
{!Platform.isTV && <GlobalModal />}
|
|
||||||
</ThemeProvider>
|
|
||||||
</IntroSheetProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</NetworkStatusProvider>
|
|
||||||
</ServerUrlProvider>
|
|
||||||
</InactivityProvider>
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
|
|||||||
81
bun.lock
81
bun.lock
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "1.2.0",
|
"@bottom-tabs/react-navigation": "1.4.0",
|
||||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||||
"@expo/metro-runtime": "~56.0.15",
|
"@expo/metro-runtime": "~56.0.15",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
|
"expo-key-event": "^1.9.0",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-localization": "~56.0.6",
|
"expo-localization": "~56.0.6",
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.2.0",
|
"react-native-bottom-tabs": "1.4.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
@@ -109,10 +111,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.7",
|
"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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -284,7 +287,7 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
|
||||||
|
|
||||||
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.2.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-gEnLP7q9Iai0KlVxHDIdlrDgkvJ5vwPzL2+2ucz5BdPWd++Cf5GO1jPq92R4/85PrioviCZnlAD91Wx8WxPOjA=="],
|
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.4.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-EeOCrecZ4YiVmFASKtoLyQFFFBjbmkV6FBD4Oxyjs9aSexuX4jYbC8yapfnevqyMnYINz0qYTvpUEILciNL+nQ=="],
|
||||||
|
|
||||||
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
||||||
|
|
||||||
@@ -294,6 +297,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 +963,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=="],
|
||||||
@@ -946,6 +1003,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -960,7 +1019,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=="],
|
||||||
|
|
||||||
@@ -976,6 +1035,8 @@
|
|||||||
|
|
||||||
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
||||||
|
|
||||||
|
"expo-key-event": ["expo-key-event@1.9.0", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZV/+iAz/0WeACFG4IR+KOTtm/7PTIsl+GaD/PYBM89PhvueQ6zYuvjPb5A47sWX2WjDunzVN0R1rOd/HIjA8aA=="],
|
||||||
|
|
||||||
"expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
|
"expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
|
||||||
|
|
||||||
"expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="],
|
"expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="],
|
||||||
@@ -1270,7 +1331,7 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
|
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
|
||||||
|
|
||||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||||
|
|
||||||
@@ -1534,7 +1595,7 @@
|
|||||||
|
|
||||||
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
||||||
|
|
||||||
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.2.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ScVPko86ts+m6JMNtI24MCSYJCOZc1aZkn9qwS9ly3o0ubajRWDpCzgRJfRFysi08bKrcqAXKVCHZNHvNb2PTA=="],
|
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.4.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XGuKHGU2FH1j/Ii7HzFE0+6ejiLs4mrcBRQRJff2g94hRjDZnx+trB+cOFG6z1BHGPLv6CD833S0m+nQuZB9nA=="],
|
||||||
|
|
||||||
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
|
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
|
||||||
|
|
||||||
@@ -1586,7 +1647,7 @@
|
|||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
|
|
||||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||||
|
|
||||||
@@ -1808,6 +1869,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,122 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { FilterSheet } from "./filters/FilterSheet";
|
|
||||||
|
|
||||||
export type Bitrate = {
|
|
||||||
key: string;
|
|
||||||
value: number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
|
||||||
{
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "8 Mb/s",
|
|
||||||
value: 8000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "4 Mb/s",
|
|
||||||
value: 4000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "2 Mb/s",
|
|
||||||
value: 2000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "1 Mb/s",
|
|
||||||
value: 1000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "500 Kb/s",
|
|
||||||
value: 500000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "250 Kb/s",
|
|
||||||
value: 250000,
|
|
||||||
},
|
|
||||||
].sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
|
||||||
onChange: (value: Bitrate) => void;
|
|
||||||
selected?: Bitrate | null;
|
|
||||||
inverted?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BitrateSheet: React.FC<Props> = ({
|
|
||||||
onChange,
|
|
||||||
selected,
|
|
||||||
inverted,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
if (inverted)
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
}, [inverted]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex shrink'
|
|
||||||
style={{
|
|
||||||
minWidth: 60,
|
|
||||||
maxWidth: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col' {...props}>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.quality")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Text numberOfLines={1}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilterSheet
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
title={t("item_card.quality")}
|
|
||||||
data={sorted}
|
|
||||||
values={selected ? [selected] : []}
|
|
||||||
multiple={false}
|
|
||||||
searchFilter={(item, query) => {
|
|
||||||
const label = (item as any).key || "";
|
|
||||||
return label.toLowerCase().includes(query.toLowerCase());
|
|
||||||
}}
|
|
||||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as Bitrate | undefined;
|
|
||||||
if (chosen) onChange(chosen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||||
|
}, [api, item, useEpisodePoster]);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ import {
|
|||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import {
|
||||||
|
LOCAL_SUBTITLE_INDEX_START,
|
||||||
|
type Track,
|
||||||
|
} from "@/components/video-player/controls/types";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -56,6 +59,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -232,12 +236,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||||
|
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ?? [];
|
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -248,9 +253,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
// State to trigger refresh of local subtitles list
|
// State to trigger refresh of local subtitles list
|
||||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||||
|
|
||||||
// Starting index for local (client-downloaded) subtitles
|
|
||||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
|
||||||
|
|
||||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data
|
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||||
const streams =
|
// (embedded first, externals last) — same as the initial list.
|
||||||
mediaSource?.MediaStreams?.filter(
|
const streams = [
|
||||||
|
...(mediaSource?.MediaStreams?.filter(
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? [];
|
) ?? []),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
|
import { BITRATES } from "./BitrateSelector";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
(x) => x.Type === "Subtitle",
|
[
|
||||||
) || [],
|
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || []),
|
||||||
|
].sort(compareTracksForMenu),
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useCallback, 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> {
|
|
||||||
item: BaseItemDto;
|
|
||||||
onChange: (value: MediaSourceInfo) => void;
|
|
||||||
selected?: MediaSourceInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaSourceSheet: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
onChange,
|
|
||||||
selected,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
|
||||||
if (source.Name) return source.Name;
|
|
||||||
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
|
||||||
return `Source ${source.Id}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedName = useMemo(() => {
|
|
||||||
if (!selected) return "";
|
|
||||||
return getDisplayName(selected);
|
|
||||||
}, [selected, getDisplayName]);
|
|
||||||
|
|
||||||
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex shrink' style={{ minWidth: 75 }}>
|
|
||||||
<View className='flex flex-col' {...props}>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilterSheet
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
title={t("item_card.video")}
|
|
||||||
data={item.MediaSources || []}
|
|
||||||
values={selected ? [selected] : []}
|
|
||||||
multiple={false}
|
|
||||||
searchFilter={(src, query) =>
|
|
||||||
getDisplayName(src as MediaSourceInfo)
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query.toLowerCase())
|
|
||||||
}
|
|
||||||
renderItemLabel={(src) => (
|
|
||||||
<Text>{getDisplayName(src as MediaSourceInfo)}</Text>
|
|
||||||
)}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as MediaSourceInfo | undefined;
|
|
||||||
if (chosen) onChange(chosen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
52
components/PendingAccountSaveModal.tsx
Normal file
52
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import {
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
||||||
|
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
||||||
|
* shows here, AFTER the session is authorized — the login screen itself
|
||||||
|
* unmounts as soon as the user is set, so it can't host the modal.
|
||||||
|
*/
|
||||||
|
export const PendingAccountSaveModal: React.FC = () => {
|
||||||
|
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { saveCurrentAccount } = useJellyfin();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// A logout before answering drops the intent — it must not resurface on
|
||||||
|
// the next (possibly different) login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user && pending) setPending(null);
|
||||||
|
}, [user, pending, setPending]);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={!!pending && !!user}
|
||||||
|
username={user?.Name ?? ""}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onSave={(securityType, pinCode) => {
|
||||||
|
const serverName = pending?.serverName;
|
||||||
|
setPending(null);
|
||||||
|
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
||||||
|
(error) => {
|
||||||
|
writeErrorLog(`Failed to save account: ${error?.message ?? error}`);
|
||||||
|
toast.error(t("save_account.not_saved"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
@@ -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")
|
position: "absolute",
|
||||||
) {
|
top: scaleSize(8),
|
||||||
|
right: scaleSize(8),
|
||||||
|
height: scaleSize(28),
|
||||||
|
borderRadius: scaleSize(14),
|
||||||
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mobile uses raw dp — no scaling.
|
||||||
|
const mobileBadgeBase: ViewStyle = {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#9333ea",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||||
|
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||||
|
* watched items, or items with no unplayed count, so it is safe to mount
|
||||||
|
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||||
|
* the watched checkmark is drawn natively and only the count needs RN).
|
||||||
|
*/
|
||||||
|
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
|
({ item }) => {
|
||||||
|
if (!isAggregateType(item)) return null;
|
||||||
|
if (item.UserData?.Played) return null;
|
||||||
|
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||||
|
if (unplayed <= 0) return null;
|
||||||
|
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||||
|
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={[
|
||||||
position: "absolute",
|
tvBadgeBase,
|
||||||
top: 8,
|
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||||
right: 8,
|
]}
|
||||||
backgroundColor: "rgba(255,255,255,0.9)",
|
|
||||||
borderRadius: 14,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Ionicons name='checkmark' size={18} color='black' />
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: scaleSize(15),
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return (
|
||||||
|
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||||
|
const isAggregate = isAggregateType(item);
|
||||||
|
const isPlayed = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// Fully watched → white checkmark badge (top-right)
|
||||||
|
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||||
|
return (
|
||||||
|
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||||
|
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Series/BoxSet with remaining episodes → count badge
|
||||||
|
return <UnplayedCountBadge item={item} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: Show purple triangle for unwatched
|
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.UserData?.Played === false &&
|
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||||
|
{isAggregate && isPlayed && (
|
||||||
|
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||||
|
<Ionicons name='checkmark' size={13} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||||
|
<UnplayedCountBadge item={item} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
@@ -23,7 +23,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons name='arrow-back' size={24} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -49,13 +49,18 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' rounded-full p-2'
|
// Match the Settings page back button: chevron flush to the edge with a
|
||||||
|
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
||||||
|
// too far right). drop-shadow keeps it readable over images. hitSlop
|
||||||
|
// keeps the touch area at ~44dp now that the padding is gone.
|
||||||
|
style={{ marginRight: 16 }}
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
40
components/common/SettingSwitch.tsx
Normal file
40
components/common/SettingSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
||||||
|
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
||||||
|
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
||||||
|
* FIXED-SIZE box (overflow hidden) and center it:
|
||||||
|
* - the fixed height caps the row height (compact, uniform rows),
|
||||||
|
* - the fixed width + centering keep the switch in the exact same spot in the
|
||||||
|
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
||||||
|
* which shifted the switch sideways on toggle).
|
||||||
|
* iOS renders the switch untouched.
|
||||||
|
*
|
||||||
|
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
||||||
|
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
||||||
|
*/
|
||||||
|
const BOX_W = 40;
|
||||||
|
const BOX_H = 30;
|
||||||
|
const SCALE = 0.9;
|
||||||
|
|
||||||
|
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
||||||
|
if (Platform.OS !== "android") return <Switch {...props} />;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: BOX_W,
|
||||||
|
height: BOX_H,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
{...props}
|
||||||
|
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 () =>
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { FilterSheetContent } from "./FilterSheetContent";
|
||||||
|
|
||||||
interface FilterButtonProps<T> extends ViewProps {
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
id: string;
|
id: string;
|
||||||
disableSearch?: boolean;
|
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
values: T[];
|
values: T[];
|
||||||
title: string;
|
title: string;
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
queryFn: (params: any) => Promise<any>;
|
queryFn: (params: any) => Promise<any>;
|
||||||
searchFilter?: (item: T, query: string) => boolean;
|
renderItemLabel: (item: T) => string;
|
||||||
renderItemLabel: (item: T) => React.ReactNode;
|
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
icon?: "filter" | "sort";
|
icon?: "filter" | "sort";
|
||||||
}
|
}
|
||||||
@@ -27,13 +25,11 @@ export const FilterButton = <T,>({
|
|||||||
values, // selected values
|
values, // selected values
|
||||||
title,
|
title,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
searchFilter,
|
|
||||||
disableSearch = false,
|
|
||||||
multiple = false,
|
multiple = false,
|
||||||
icon = "filter",
|
icon = "filter",
|
||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
const [open, setOpen] = useState(false);
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
@@ -42,61 +38,59 @@ export const FilterButton = <T,>({
|
|||||||
enabled: !!id && !!queryFn && !!queryKey,
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const openSheet = () => {
|
||||||
<>
|
if (!filters?.length) return;
|
||||||
<TouchableOpacity
|
showModal(
|
||||||
onPress={() => {
|
<FilterSheetContent<T>
|
||||||
filters?.length && setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
|
||||||
${
|
|
||||||
values.length > 0
|
|
||||||
? "bg-purple-600 border border-purple-700"
|
|
||||||
: "bg-neutral-900 border border-neutral-900"
|
|
||||||
}
|
|
||||||
${filters?.length === 0 && "opacity-50"}
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
|
||||||
text-xs font-semibold`}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{icon === "filter" ? (
|
|
||||||
<Ionicons
|
|
||||||
name='filter'
|
|
||||||
size={14}
|
|
||||||
color='white'
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesome
|
|
||||||
name='sort'
|
|
||||||
size={14}
|
|
||||||
color='white'
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<FilterSheet<T>
|
|
||||||
title={title}
|
title={title}
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
data={filters}
|
data={filters}
|
||||||
values={values}
|
initialValues={values}
|
||||||
set={set}
|
set={set}
|
||||||
renderItemLabel={renderItemLabel}
|
renderItemLabel={renderItemLabel}
|
||||||
searchFilter={searchFilter}
|
|
||||||
disableSearch={disableSearch}
|
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
/>
|
onClose={hideModal}
|
||||||
</>
|
/>,
|
||||||
|
{ snapPoints: ["85%"] },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={openSheet}>
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
|
${
|
||||||
|
values.length > 0
|
||||||
|
? "bg-purple-600 border border-purple-700"
|
||||||
|
: "bg-neutral-900 border border-neutral-900"
|
||||||
|
}
|
||||||
|
${filters?.length === 0 ? "opacity-50" : ""}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||||
|
text-xs font-semibold`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{icon === "filter" ? (
|
||||||
|
<Ionicons
|
||||||
|
name='filter'
|
||||||
|
size={14}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesome
|
||||||
|
name='sort'
|
||||||
|
size={14}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { isEqual } from "lodash";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
data?: T[] | null;
|
|
||||||
values: T[];
|
|
||||||
set: (value: T[]) => void;
|
|
||||||
title: string;
|
|
||||||
searchFilter?: (item: T, query: string) => boolean;
|
|
||||||
renderItemLabel: (item: T) => React.ReactNode;
|
|
||||||
disableSearch?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIMIT = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FilterSheet Component
|
|
||||||
*
|
|
||||||
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
|
||||||
*
|
|
||||||
* @template T - The type of items in the list
|
|
||||||
*
|
|
||||||
* @param {Object} props - The component props
|
|
||||||
* @param {boolean} props.open - Whether the bottom sheet is open
|
|
||||||
* @param {function} props.setOpen - Function to set the open state
|
|
||||||
* @param {T[] | null} [props.data] - The full list of items to filter from
|
|
||||||
* @param {T[]} props.values - The currently selected items
|
|
||||||
* @param {function} props.set - Function to update the selected items
|
|
||||||
* @param {string} props.title - The title of the bottom sheet
|
|
||||||
* @param {function} props.searchFilter - Function to filter items based on search query
|
|
||||||
* @param {function} props.renderItemLabel - Function to render the label for each item
|
|
||||||
* @param {boolean} [props.disableSearch=false] - Whether to disable the search input
|
|
||||||
*
|
|
||||||
* @returns {React.ReactElement} The FilterSheet component
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Displays a list of items in a bottom sheet
|
|
||||||
* - Allows searching and filtering of items
|
|
||||||
* - Supports single selection of items
|
|
||||||
* - Loads items in batches for performance optimization
|
|
||||||
* - Customizable item rendering
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const FilterSheet = <T,>({
|
|
||||||
values,
|
|
||||||
data: _data,
|
|
||||||
open,
|
|
||||||
set,
|
|
||||||
setOpen,
|
|
||||||
title,
|
|
||||||
searchFilter,
|
|
||||||
renderItemLabel,
|
|
||||||
disableSearch = false,
|
|
||||||
multiple = false,
|
|
||||||
}: Props<T>) => {
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const [data, setData] = useState<T[]>([]);
|
|
||||||
const [offset, setOffset] = useState<number>(0);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
|
|
||||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!search) return _data;
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
|
||||||
if (_data && searchFilter?.(_data[i], search)) {
|
|
||||||
results.push(_data[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results.slice(0, 100);
|
|
||||||
}, [search, _data, searchFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
|
||||||
if (data.length > 15) {
|
|
||||||
setShowSearch(true);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// Loads data in batches of LIMIT size, starting from offset,
|
|
||||||
// to implement efficient "load more" functionality
|
|
||||||
useEffect(() => {
|
|
||||||
if (!_data || _data.length === 0) return;
|
|
||||||
|
|
||||||
const newData = [...data];
|
|
||||||
|
|
||||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
|
||||||
const item = _data[i];
|
|
||||||
// Check if this item already exists in our data array
|
|
||||||
// some dups happened with re-renders during testing
|
|
||||||
const exists = newData.some((existingItem) =>
|
|
||||||
isEqual(existingItem, item),
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
newData.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(newData);
|
|
||||||
}, [offset, _data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) bottomSheetModalRef.current?.present();
|
|
||||||
else bottomSheetModalRef.current?.dismiss();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
|
||||||
if (search.length > 0 && showSearch) return filteredData;
|
|
||||||
return data;
|
|
||||||
}, [search, filteredData, data]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
index={0}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
style={{}}
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='mt-2 mb-8'
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
|
||||||
<Text className='mb-2 text-neutral-500'>
|
|
||||||
{t("search.x_items", { count: _data?.length })}
|
|
||||||
</Text>
|
|
||||||
{showSearch && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
className='my-2 border-neutral-800 border'
|
|
||||||
value={search}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setSearch(text);
|
|
||||||
}}
|
|
||||||
returnKeyType='done'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{renderData?.map((item, index) => (
|
|
||||||
<View key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (multiple) {
|
|
||||||
if (!values.includes(item)) set(values.concat(item));
|
|
||||||
else set(values.filter((v) => v !== item));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
} else {
|
|
||||||
if (!values.includes(item)) {
|
|
||||||
set([item]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
|
||||||
{values.some((i) => isEqual(i, item)) ? (
|
|
||||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='h-1 divide-neutral-700 '
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
{data.length < (_data?.length || 0) && (
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
setOffset(offset + LIMIT);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
134
components/filters/FilterSheetContent.tsx
Normal file
134
components/filters/FilterSheetContent.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
title: string;
|
||||||
|
data: T[];
|
||||||
|
initialValues: T[];
|
||||||
|
set: (value: T[]) => void;
|
||||||
|
renderItemLabel: (item: T) => string;
|
||||||
|
multiple?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_THRESHOLD = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet content for FilterButton, rendered inside the GlobalModal bottom
|
||||||
|
* sheet. The GlobalModal stores its content as a static snapshot, so this
|
||||||
|
* component owns the selection state locally and mirrors changes back to the
|
||||||
|
* caller through `set`.
|
||||||
|
*
|
||||||
|
* Uses a virtualized BottomSheetFlatList — filter lists (genres, tags, years)
|
||||||
|
* can contain thousands of entries.
|
||||||
|
*/
|
||||||
|
export const FilterSheetContent = <T,>({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
initialValues,
|
||||||
|
set,
|
||||||
|
renderItemLabel,
|
||||||
|
multiple = false,
|
||||||
|
onClose,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [values, setValues] = useState<T[]>(initialValues);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const showSearch = data.length > SEARCH_THRESHOLD;
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!search || !showSearch) return data;
|
||||||
|
const query = search.toLowerCase();
|
||||||
|
return data.filter((item) =>
|
||||||
|
renderItemLabel(item).toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [data, search, showSearch, renderItemLabel]);
|
||||||
|
|
||||||
|
const select = (item: T) => {
|
||||||
|
const selected = values.some((v) => isEqual(v, item));
|
||||||
|
if (multiple) {
|
||||||
|
const next = selected
|
||||||
|
? values.filter((v) => !isEqual(v, item))
|
||||||
|
: values.concat(item);
|
||||||
|
setValues(next);
|
||||||
|
set(next);
|
||||||
|
} else {
|
||||||
|
if (!selected) {
|
||||||
|
setValues([item]);
|
||||||
|
set([item]);
|
||||||
|
}
|
||||||
|
setTimeout(() => onClose(), 250);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='font-bold text-2xl mt-2'>{title}</Text>
|
||||||
|
<Text className='mb-2 text-neutral-500'>
|
||||||
|
{t("search.x_items", { count: data.length })}
|
||||||
|
</Text>
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
className='my-2 border-neutral-800 border'
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomSheetFlatList
|
||||||
|
data={filteredData}
|
||||||
|
keyExtractor={(item, index) => `${renderItemLabel(item)}-${index}`}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
initialNumToRender={20}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{ height: StyleSheet.hairlineWidth }}
|
||||||
|
className='bg-neutral-700'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const selected = values.some((v) => isEqual(v, item));
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => select(item)}
|
||||||
|
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={selected ? "radio-button-on" : "radio-button-off"}
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import {
|
||||||
|
apiAtom,
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
|
// Gate the intro so it can't steal presentation from the post-login
|
||||||
|
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
||||||
|
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
if (!hasShownIntro) {
|
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
||||||
|
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
||||||
|
if (!hasShownIntro && !pendingAccountSave) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro]);
|
}, [showIntro, pendingAccountSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -221,22 +230,33 @@ const HomeMobile = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
// Use getItems (not getLatestMedia) so we get item-level results
|
||||||
const allData =
|
// filtered by type from a specific library. getLatestMedia is
|
||||||
(
|
// episode-oriented and groups results, which drops Series when
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
// combined with a parentId + includeItemTypes filter.
|
||||||
userId: user?.Id,
|
//
|
||||||
limit: 10,
|
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||||
imageTypeLimit: 1,
|
//
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||||
includeItemTypes,
|
// a new episode is added (series cards for new episodes, matching how
|
||||||
parentId,
|
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||||
})
|
const response = await getItemsApi(api).getItems({
|
||||||
).data || [];
|
userId: user?.Id,
|
||||||
|
parentId,
|
||||||
// Simulate pagination by slicing
|
includeItemTypes,
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
recursive: true,
|
||||||
|
sortBy: includeItemTypes.includes("Series")
|
||||||
|
? ["DateLastContentAdded"]
|
||||||
|
: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -250,9 +270,7 @@ const HomeMobile = () => {
|
|||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey: string[] = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||||
|
// decoded ARGB) is too large to pin in the memory cache on every
|
||||||
|
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -201,12 +203,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]);
|
||||||
@@ -225,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
|
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -237,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
|
settings?.useEpisodeImagesForNextUp,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -326,9 +336,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={5}
|
initialNumToRender={4}
|
||||||
maxToRenderPerBatch={3}
|
maxToRenderPerBatch={2}
|
||||||
windowSize={5}
|
windowSize={3}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
@@ -348,11 +358,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 && (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
// For episodes, always use series thumb
|
// For episodes, always use series thumb.
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentThumbImageTag) {
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
@@ -256,8 +257,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
|
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
||||||
|
// out of the memory cache avoids bloat when the user cycles through
|
||||||
|
// hero items quickly.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
@@ -379,7 +383,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
const tvosTopPadding = scaleSize(145);
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
{title ? (
|
||||||
{title}
|
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||||
</Text>
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -32,14 +33,22 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
disabledByAdmin = false,
|
disabledByAdmin = false,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const { t } = useTranslation();
|
||||||
|
const effectiveSubtitle = disabledByAdmin
|
||||||
|
? t("home.settings.disabled_by_admin")
|
||||||
|
: subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
|
// Keep the row floor uniform; Android trims padding slightly (its native
|
||||||
|
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
||||||
|
// rows match non-toggle rows.
|
||||||
|
const rowSizing =
|
||||||
|
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -58,7 +67,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import type { SavedServer } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -35,6 +37,7 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -42,9 +45,11 @@ export const Login: React.FC = () => {
|
|||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
|
stopQuickConnectPolling,
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +69,50 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state
|
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Close the code sheet as soon as the session is authorized — the native
|
||||||
|
// Alert used before had no programmatic dismiss and stayed open after login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) setQuickConnectCode(null);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
};
|
||||||
|
}, [stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Going back to server selection keeps this component mounted (same screen,
|
||||||
|
// different state), so the unmount cleanup above doesn't run. Without this a
|
||||||
|
// code authorized after leaving would silently log the user in later.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api?.basePath) {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
setQuickConnectCode(null);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Save account state — only the intent lives here; the protection picker is
|
||||||
|
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||||
username: string;
|
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
|
// the protection picker shows globally once the session exists (this screen
|
||||||
|
// unmounts on login, so it can't host the modal).
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
if (quickConnectActive && saveAccount) {
|
||||||
|
setPendingAccountSave({ serverName });
|
||||||
|
}
|
||||||
|
setQuickConnectActive(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,55 +159,34 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
setPendingAccountSave({ serverName });
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} 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"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPendingLogin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccountConfirm = async (
|
|
||||||
securityType: AccountSecurityType,
|
|
||||||
pinCode?: string,
|
|
||||||
) => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
if (pendingLogin) {
|
|
||||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
|
||||||
saveAccount: true,
|
|
||||||
securityType,
|
|
||||||
pinCode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,15 +280,8 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(
|
setQuickConnectActive(true);
|
||||||
t("login.quick_connect"),
|
setQuickConnectCode(code);
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -402,7 +416,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
aria-label={t("server.server_url")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -444,14 +458,11 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
<SaveAccountModal
|
{/* Dismissing only hides the code — polling continues so the login still
|
||||||
visible={showSaveModal}
|
completes if the code is authorized from another device afterwards. */}
|
||||||
onClose={() => {
|
<QuickConnectCodeModal
|
||||||
setShowSaveModal(false);
|
code={quickConnectCode}
|
||||||
setPendingLogin(null);
|
onClose={() => setQuickConnectCode(null)}
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The Quick Connect code to display, or null when hidden. */
|
||||||
|
code: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the Quick Connect code while the app polls for authorization.
|
||||||
|
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||||
|
* session is authorized — a native alert has no programmatic dismiss and
|
||||||
|
* lingers over the app after login completes.
|
||||||
|
*/
|
||||||
|
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||||
|
// when the parent clears the code to close the sheet.
|
||||||
|
const lastCodeRef = useRef<string | null>(null);
|
||||||
|
if (code) lastCodeRef.current = code;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else if (isPresentedRef.current) {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
const value = code ?? lastCodeRef.current;
|
||||||
|
if (!value) return;
|
||||||
|
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||||
|
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||||
|
// and skip — importing the JS wrapper there would error out.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
toast.success(t("login.code_copied"));
|
||||||
|
}, [code, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||||
|
onPress={copyCode}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className='text-center font-bold text-5xl text-neutral-100'
|
||||||
|
style={{ letterSpacing: 10 }}
|
||||||
|
>
|
||||||
|
{code ?? lastCodeRef.current}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='copy-outline'
|
||||||
|
size={22}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||||
|
{t("login.tap_code_to_copy")}
|
||||||
|
</Text>
|
||||||
|
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||||
|
{t("login.quick_connect_instructions")}
|
||||||
|
</Text>
|
||||||
|
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||||
|
{t("login.got_it")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
}
|
}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -128,7 +127,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
values={[jellyseerrSortOrder]}
|
values={[jellyseerrSortOrder]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +1,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch } from "react-native";
|
import { Linking } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
|
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.use_episode_images_next_up_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.hide_remote_session_button_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
|
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
// Only the server URL is admin-lockable — the password stays editable so
|
||||||
|
// the user can still sign in to the admin-pinned Jellyseerr server.
|
||||||
|
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(settings?.jellyseerrServerUrl || undefined);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
// When the URL is admin-pinned, ignore any locally-typed value so the login
|
||||||
|
// targets the same server the (read-only) input displays.
|
||||||
|
const effectiveServerUrl = urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||||
throw new Error("Missing server url");
|
|
||||||
if (!user?.Name)
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
const jellyseerrTempApi = new JellyseerrApi(
|
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
|
||||||
);
|
|
||||||
const testResult = await jellyseerrTempApi.test();
|
const testResult = await jellyseerrTempApi.test();
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
updateSettings({ jellyseerrServerUrl });
|
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
@@ -115,30 +121,41 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
<Text className='font-bold mb-1'>
|
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
<Text className='font-bold mb-1'>
|
||||||
</Text>
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
<View className='flex flex-col shrink mb-2'>
|
|
||||||
<Text className='text-xs text-gray-600'>
|
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
<View className='flex flex-col shrink mb-2'>
|
||||||
<Input
|
<Text className='text-xs text-gray-600'>
|
||||||
className='border border-neutral-800 mb-2'
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
placeholder={t(
|
</Text>
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
</View>
|
||||||
|
<Input
|
||||||
|
className='border border-neutral-800 mb-2'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={
|
||||||
|
urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
||||||
|
/>
|
||||||
|
{urlLocked && (
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.disabled_by_admin")}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
</View>
|
||||||
defaultValue={
|
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
|
||||||
}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={setjellyseerrServerUrl}
|
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
|
||||||
/>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, Text, View } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
|
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className=''>
|
<ListGroup>
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<ListItem
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
disabledByAdmin={locked}
|
||||||
</Text>
|
>
|
||||||
|
<SettingSwitch
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
value={isEnabled}
|
||||||
<Text className='text-white'>
|
disabled={locked}
|
||||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
</Text>
|
/>
|
||||||
|
</ListItem>
|
||||||
<Switch
|
</ListGroup>
|
||||||
value={isEnabled}
|
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
|
||||||
trackColor={{ false: "#555", true: "purple" }}
|
|
||||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
|
import { openLocationSettings } from "@/modules/wifi-ssid";
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
import {
|
||||||
@@ -26,16 +28,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
|
|||||||
|
|
||||||
interface StatusDisplayProps {
|
interface StatusDisplayProps {
|
||||||
currentSSID: string | null;
|
currentSSID: string | null;
|
||||||
|
connectedToWifi: boolean;
|
||||||
isUsingLocalUrl: boolean;
|
isUsingLocalUrl: boolean;
|
||||||
|
locationBlocked: boolean;
|
||||||
|
onOpenLocationSettings: () => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDisplay({
|
function StatusDisplay({
|
||||||
currentSSID,
|
currentSSID,
|
||||||
|
connectedToWifi,
|
||||||
isUsingLocalUrl,
|
isUsingLocalUrl,
|
||||||
|
locationBlocked,
|
||||||
|
onOpenLocationSettings,
|
||||||
t,
|
t,
|
||||||
}: StatusDisplayProps): React.ReactElement {
|
}: StatusDisplayProps): React.ReactElement {
|
||||||
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
|
const wifiStatus = currentSSID
|
||||||
|
? currentSSID
|
||||||
|
: connectedToWifi
|
||||||
|
? t("home.settings.network.ssid_hidden")
|
||||||
|
: t("home.settings.network.not_connected");
|
||||||
const urlType = isUsingLocalUrl
|
const urlType = isUsingLocalUrl
|
||||||
? t("home.settings.network.local")
|
? t("home.settings.network.local")
|
||||||
: t("home.settings.network.remote");
|
: t("home.settings.network.remote");
|
||||||
@@ -55,6 +67,23 @@ function StatusDisplay({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text className={urlTypeColor}>{urlType}</Text>
|
<Text className={urlTypeColor}>{urlType}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{locationBlocked && (
|
||||||
|
<View className='mt-2 pt-2 border-t border-neutral-800'>
|
||||||
|
<Text className='text-xs text-amber-400'>
|
||||||
|
{t("home.settings.network.location_off_description")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onOpenLocationSettings}
|
||||||
|
className='mt-2 self-start'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text className='text-xs text-blue-400 font-semibold'>
|
||||||
|
{t("home.settings.network.open_location_settings")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,7 +91,17 @@ function StatusDisplay({
|
|||||||
export function LocalNetworkSettings(): React.ReactElement | null {
|
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { permissionStatus, requestPermission } = useWifiSSID();
|
const { permissionStatus, requestPermission } = useWifiSSID();
|
||||||
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
|
const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
|
||||||
|
useServerUrl();
|
||||||
|
|
||||||
|
// Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
|
||||||
|
// on Android this means device Location services are off.
|
||||||
|
const locationBlocked =
|
||||||
|
permissionStatus === "granted" && connectedToWifi && !currentSSID;
|
||||||
|
|
||||||
|
const handleOpenLocationSettings = useCallback(() => {
|
||||||
|
openLocationSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const remoteUrl = storage.getString("serverUrl");
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||||
@@ -147,7 +186,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
title={t("home.settings.network.auto_switch_enabled")}
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
subtitle={t("home.settings.network.auto_switch_description")}
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
>
|
>
|
||||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
<SettingSwitch
|
||||||
|
value={config.enabled}
|
||||||
|
onValueChange={handleToggleEnabled}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
@@ -195,18 +237,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className='py-2'>
|
{!locationBlocked && (
|
||||||
<Button
|
<View className='py-2'>
|
||||||
onPress={handleAddCurrentNetwork}
|
<Button
|
||||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
onPress={handleAddCurrentNetwork}
|
||||||
>
|
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||||
{addNetworkButtonText}
|
>
|
||||||
</Button>
|
{addNetworkButtonText}
|
||||||
</View>
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatusDisplay
|
<StatusDisplay
|
||||||
currentSSID={currentSSID}
|
currentSSID={currentSSID}
|
||||||
|
connectedToWifi={connectedToWifi}
|
||||||
isUsingLocalUrl={isUsingLocalUrl}
|
isUsingLocalUrl={isUsingLocalUrl}
|
||||||
|
locationBlocked={locationBlocked}
|
||||||
|
onOpenLocationSettings={handleOpenLocationSettings}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -17,20 +19,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 +63,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 +86,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 +101,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,14 +120,14 @@ 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
|
<SettingSwitch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||||
@@ -131,7 +136,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}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { Linking, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title={"Jellyseerr"}
|
title='Jellyseerr'
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
|
||||||
title='Marlin Search'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Streamystats'
|
title='Streamystats'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||||
|
title='Marlin Search'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -5,11 +6,13 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -58,7 +61,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();
|
||||||
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
|
const pasteCode = useCallback(async () => {
|
||||||
|
// Builds without the expo-clipboard native module: probe first (no-op).
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = await Clipboard.getStringAsync();
|
||||||
|
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
||||||
|
if (digits) setQuickConnectCode(digits);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={pasteCode}
|
||||||
|
className='flex-row items-center justify-center self-center'
|
||||||
|
>
|
||||||
|
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{t("home.settings.quick_connect.paste_code")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the bar moving while a download is writing to disk.
|
||||||
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = () => {
|
||||||
try {
|
Alert.alert(
|
||||||
await deleteAllFiles();
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
successHapticFeedback();
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
} catch (_e) {
|
[
|
||||||
errorHapticFeedback();
|
{
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
text: t("common.cancel"),
|
||||||
}
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
successHapticFeedback();
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
} finally {
|
||||||
|
// Reflect the freed space immediately instead of waiting for
|
||||||
|
// the next poll.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
@@ -102,7 +125,7 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup>
|
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteClicked}
|
onPress={onDeleteClicked}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
|
className='mb-4'
|
||||||
title={t("home.settings.subtitles.subtitle_title")}
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.set_subtitle_track")}
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
155
components/tv/TVNavBar.tsx
Normal file
155
components/tv/TVNavBar.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { TVPadding } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
|
export interface TVNavBarTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVNavBarProps {
|
||||||
|
tabs: TVNavBarTab[];
|
||||||
|
activeTabKey: string;
|
||||||
|
onTabChange: (key: string) => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVNavBarTabItem: React.FC<{
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onLayout: (e: {
|
||||||
|
nativeEvent: { layout: { x: number; width: number } };
|
||||||
|
}) => void;
|
||||||
|
hasTVPreferredFocus: boolean;
|
||||||
|
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bg = focused
|
||||||
|
? "rgba(255, 255, 255, 0.95)"
|
||||||
|
: isActive
|
||||||
|
? "rgba(255, 255, 255, 0.15)"
|
||||||
|
: "transparent";
|
||||||
|
|
||||||
|
const textColor = focused
|
||||||
|
? "#000"
|
||||||
|
: isActive
|
||||||
|
? "#fff"
|
||||||
|
: "rgba(255, 255, 255, 0.7)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
onLayout={onLayout}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: scaleSize(24),
|
||||||
|
borderWidth: isActive && !focused ? 1 : 0,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
paddingHorizontal: scaleSize(28),
|
||||||
|
paddingVertical: scaleSize(14),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: isActive || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
||||||
|
tabs,
|
||||||
|
activeTabKey,
|
||||||
|
onTabChange,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const scrollRef = React.useRef<ScrollView>(null);
|
||||||
|
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleTabLayout = React.useCallback(
|
||||||
|
(key: string) =>
|
||||||
|
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
|
||||||
|
tabLayouts.current[key] = e.nativeEvent.layout;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = React.useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
onTabChange(key);
|
||||||
|
|
||||||
|
const layout = tabLayouts.current[key];
|
||||||
|
if (layout && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: scaleSize(12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TVNavBarTabItem
|
||||||
|
key={tab.key}
|
||||||
|
label={tab.label}
|
||||||
|
isActive={tab.key === activeTabKey}
|
||||||
|
onSelect={() => handleTabChange(tab.key)}
|
||||||
|
onLayout={handleTabLayout(tab.key)}
|
||||||
|
hasTVPreferredFocus={tab.key === activeTabKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
@@ -69,6 +72,9 @@ export interface TVPosterCardProps {
|
|||||||
|
|
||||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
|
||||||
|
/** For horizontal episodes, prefer the episode's own image over the series thumb */
|
||||||
|
preferEpisodeImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +111,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
glowColor = "white",
|
glowColor = "white",
|
||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
|
preferEpisodeImage = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -136,9 +143,14 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// First try parent/series thumb (horizontal series artwork)
|
// Opt-in: use the episode's own image instead of the series thumb.
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
// First try parent/series thumb (horizontal series artwork).
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
// Fall back to episode's own primary image
|
// Fall back to episode's own primary image
|
||||||
if (item.ImageTags?.Primary) {
|
if (item.ImageTags?.Primary) {
|
||||||
@@ -170,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
item,
|
item,
|
||||||
width: width * 2, // 2x for quality on large screens
|
width: width * 2, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item, orientation, width, imageUrlGetter]);
|
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
||||||
|
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -427,6 +439,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -448,8 +466,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
placeholder={{ blurhash }}
|
placeholder={{ blurhash }}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
|
||||||
source={{ uri: imageUrl }}
|
source={{ uri: imageUrl }}
|
||||||
|
recyclingKey={item.Id}
|
||||||
cachePolicy='memory-disk'
|
cachePolicy='memory-disk'
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
@@ -459,7 +477,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 && (
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
|
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||||
|
export { TVNavBar } from "./TVNavBar";
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user